diff --git a/apps/desktop/docs/fresh-mach-context-design.md b/apps/desktop/docs/fresh-mach-context-design.md new file mode 100644 index 00000000000..9b1b279d94e --- /dev/null +++ b/apps/desktop/docs/fresh-mach-context-design.md @@ -0,0 +1,294 @@ +# Superset Fresh Spawn — Design Document + +**Tarih:** 2026-04-17 +**Durum:** Draft +**Hedef:** macOS'ta stale Mach bootstrap context sorunu için Superset.sh çözümü — çalışan session'ları öldürmeden. + +--- + +## 1. Problem Statement + +### Tek Cümle +macOS'ta uzun-ömürlü `terminal-host` daemon fork ettiğinde, child process daemon'ın **stale bootstrap port**'unu miras alır; sonuç olarak Go binary'leri (`gh`, `terraform`, `kubectl`) TLS doğrulaması yapamaz: + +``` +tls: failed to verify certificate: x509: OSStatus -26276 +``` + +### Kök Sebep +- Go stdlib `crypto/x509` macOS'ta `Security.framework → trustd` → Mach IPC yolu kullanır +- Bu çağrı process'in bootstrap port'u üzerinden lookup yapar +- Bootstrap port process doğduğunda parent'tan **snapshot olarak miras alınır**; runtime'da değiştirilemez +- `terminal-host` daemon uzun süredir çalışıyorsa (Fast User Switch, sistem crash sonrası) bootstrap port stale olur +- Child'lar stale port'u inherit eder, `trustd`'ye ulaşamaz + +### Somut Vaka +[superset-sh/superset#2570](https://github.com/superset-sh/superset/issues/2570) + +### Mevcut Çözümler ve Kusurları + +| Yaklaşım | Çalışır mı? | Kusur | +|----------|-------------|-------| +| [PR #2571](https://github.com/superset-sh/superset/pull/2571): Her startup'ta daemon restart | ✅ | Çalışan session'ları öldürür (dev server, build) | +| `launchctl bsexec` | ❌ | macOS 10.7'den beri security context'i doğru kopyalamıyor | +| `task_set_special_port` runtime swap | ❌ | Lion+ XPC cache'i kırıyor, deprecated | +| `posix_spawnattr_setspecialport_np` | ✅ (teorik) | Mach port transfer infrastructure'ı çok karmaşık, native C gerekir | + +--- + +## 2. Çözüm: Spawn'ı Electron Main'e Delegate Et + +### Anahtar İçgörü + +**Electron main process her Superset açılışında yeniden doğar.** Bu demektir ki: +- Her Superset açılışında Electron main **fresh Mach bootstrap context** alır +- Bu context çağdaş, canlı kullanıcı oturumunun kimliğini taşır +- Fresh Electron main'den spawn edilen her child **fresh context inherit eder** + +O zaman: pty-subprocess'leri terminal-host yerine Electron main doğursun. Terminal-host sadece sessionları yönetsin, fork yapmasın. + +### Mimari + +**Önce (stale chain):** +``` +terminal-host (STALE ctx) + ↓ fork/exec +pty-subprocess (STALE ctx inherit) + ↓ fork +zsh (STALE) + ↓ exec +gh ❌ +``` + +**Sonra (fresh chain):** +``` +Electron main (FRESH ctx — her restart'ta yenilenir) + ↓ IPC-triggered spawn +pty-subprocess (FRESH ctx ✅) + │ + │ stdin/stdout FD'leri UDS üzerinden pass edilir + ▼ +terminal-host (STALE ctx — ama fork etmiyor!) + │ + │ sadece I/O forwarding yapar, ChildProcess referansı tutar + ▼ +zsh (FRESH) + ↓ exec +gh ✅ +``` + +### Yeni Terminal Akışı + +``` +1. Kullanıcı "yeni terminal" → Electron renderer +2. Electron renderer → terminal-host "bana session aç" +3. terminal-host → Electron main UDS (spawn-server): "pty-subprocess spawn et" +4. Electron main: fresh child_process.spawn(pty-subprocess.js) + - Child fresh context alır +5. Electron main: child'ın stdin/stdout/stderr FD'lerini UDS üzerinden SCM_RIGHTS ile terminal-host'a pass eder +6. terminal-host: FD'leri alır, Session class'ına bağlar, I/O akışı başlar +7. Kullanıcıya "session hazır" +``` + +### Eski Terminal Akışı (Shell Wrapper ile) + +Eski session'ların içindeki zsh stale. Onları düzeltemeyiz. Ama zsh'in içindeki **komutları** fresh context'te çalıştırabiliriz. + +``` +1. Kullanıcı eski terminalde: gh auth login +2. zsh preexec hook devreye girer +3. Hook: gh whitelist'te → komutu yakalar +4. Hook: komutu `fresh-exec gh auth login` ile replace eder +5. fresh-exec → Electron main UDS: "bu komutu fresh ctx'te çalıştır" +6. Electron main: fresh child_process.spawn(gh, [auth, login], { stdio: "pipe" }) + - gh fresh context alır, trustd'ye ulaşır +7. Electron main: gh'nin stdin/stdout/stderr'ını fresh-exec'e UDS üzerinden pipe'lar +8. fresh-exec: kendi TTY'sine (eski terminal) yönlendirir +9. gh çalışır ✅ +``` + +### Neden Çalışır? + +**Fork inheritance kritik:** Mach bootstrap port **fork anında snapshot** olarak geçer. Electron main fresh'se, ondan fork edilen her şey fresh. Terminal-host'un stale olması önemli değil — o artık fork etmiyor, sadece FD'ler üzerinden I/O yönetiyor. + +**Elektron kapalıyken?** Fallback: terminal-host yine eski stale spawn yapar. Degradation, crash değil. + +--- + +## 3. Bileşenler + +### 3.1 Superset Fork — Değişecek Dosyalar + +#### 3.1.1 Electron Main: Spawn Server + +**Yeni dosya:** `apps/desktop/src/main/fresh-spawn/spawn-server.ts` + +Unix Domain Socket server. İki RPC destekler: + +1. **`spawn-pty-subprocess`**: pty-subprocess.js'i fresh spawn et, FD'leri client'a pass et +2. **`fresh-exec`**: Arbitrary komut fresh spawn et (shell wrapper tarafından çağrılır) + +Socket konumu: `~/.superset/fresh-spawn.sock` +Token-based auth: `~/.superset/fresh-spawn.token` + +**Değişim:** `apps/desktop/src/main/index.ts` — startup'ta spawn-server'ı başlat, shutdown'da kapat. + +#### 3.1.2 Terminal-Host: Spawn Client + +**Yeni dosya:** `apps/desktop/src/main/terminal-host/fresh-spawn-client.ts` + +Spawn-server'a bağlanır, FD'ler alır, `net.Socket` + synthetic `ChildProcess` oluşturur (node-pty uyumlu). + +**Değişim:** `apps/desktop/src/main/terminal-host/session.ts:268` — `spawnProcess`'i yeni bir `spawnViaFreshClient()` fonksiyonu ile değiştir. Fallback: spawn-client fail ederse eski stale spawn. + +#### 3.1.3 Shell Wrapper + +**Yeni dosyalar:** +- `apps/desktop/resources/shell-hooks/zsh-fresh-exec.zsh` +- `apps/desktop/resources/shell-hooks/bash-fresh-exec.sh` (v1.1) + +**Değişim:** Shell wrapper sistemi (`apps/desktop/src/main/terminal-host/shell-wrappers.ts`) — kullanıcının `.zshrc`'sine source satırı ekler ya da ZDOTDIR pattern'iyle rcfile inject eder. + +#### 3.1.4 fresh-exec Helper Binary + +**Yeni dosya:** `apps/desktop/src/main/fresh-spawn/fresh-exec.ts` + +Küçük Node.js script, Electron main UDS'ine client olarak bağlanır. stdin/stdout/stderr'ı proxy eder. + +Build output: `/Applications/Superset.app/Contents/Resources/app.asar.unpacked/bin/fresh-exec` + +### 3.2 FD Passing over UDS + +Node.js'in built-in IPC (`child.send(msg, handle)`) sadece fork edilmiş child'lar arasında çalışır. Bağımsız process'ler arası FD passing için `SCM_RIGHTS` gerekir. + +**Seçenek 1: Mevcut npm package** +- `node-unix-socket` — `sendmsg`/`recvmsg` SCM_RIGHTS desteği var +- `pass-fds` — daha küçük, sadece FD passing +- Risk: Dependency ekler, ama proven + +**Seçenek 2: Minimal inline native addon** +- ~50 satır C, sadece 2 fonksiyon: `sendFdsOverUds()`, `recvFdsOverUds()` +- Build node-gyp ile +- Tam kontrol + +**Tercih:** Seçenek 1 denenecek (`node-unix-socket`). Çalışmazsa Seçenek 2. + +### 3.3 Protocol + +UDS mesaj formatı (length-prefixed JSON + FD'ler SCM_RIGHTS ile): + +```typescript +// Client → Server +type SpawnRequest = + | { + type: "spawn-pty-subprocess"; + token: string; + env: Record; + } + | { + type: "fresh-exec"; + token: string; + command: string; + args: string[]; + cwd: string; + env: Record; + ttyName: string; // e.g., "/dev/ttys003" + }; + +// Server → Client +type SpawnResponse = { + type: "ok"; + pid: number; + // Accompanied by 3 FDs via SCM_RIGHTS: stdin, stdout, stderr +} | { + type: "error"; + message: string; + code: string; +}; +``` + +--- + +## 4. Failure Modes & Fallbacks + +| Senaryo | Davranış | +|---------|----------| +| Non-macOS platform | Spawn-server başlamaz, terminal-host fallback'e düşer (eski davranış) | +| Electron main kapalı, daemon standalone | UDS bağlantısı fail → stale spawn + `console.warn` | +| UDS socket dosyası yok | Timeout (500ms) → stale spawn + warn | +| Token dosyası invalid | Auth fail → stale spawn | +| FD passing exception | Stale spawn + warn + metric log | +| Kullanıcı `~/.zshrc`'sine el yapımı mod yaptı | Source satırı idempotent, ikinci kez eklemez | +| fresh-exec helper binary yok | Whitelist komut normal çalışır (stale) + warn banner | + +**Prensip:** Superset hiçbir senaryoda çökmez. En kötü senaryo: mevcut stale davranış. + +--- + +## 5. Test Stratejisi + +### Unit Tests +- `spawn-server.test.ts`: Mock UDS client, spawn request → FD yanıtı doğru mu +- `fresh-spawn-client.test.ts`: Mock UDS server, received FD'ler ChildProcess-like obje yaratıyor mu +- `shell-wrappers.test.ts`: ZDOTDIR inject mekanizması temiz mi + +### Integration Tests (macOS only) +- Manuel FastUserSwitch simulasyon script: `sudo killall -USR1 launchd` gibi bir proxy +- `launchctl procinfo ` ile bootstrap port namespace kontrolü +- `security list-keychains` fresh child'da OK, stale'de EPERM + +### E2E Test (Manual, local Superset build) +``` +Setup: + - Local Superset build+install + - Terminal 1: "python3 -m http.server 9999" başlat (long-running) + - Taint the daemon: launchctl kickstart -k system/com.apple.trustd (veya sistem crash simulate) + - Superset'i kapat-aç + +Verify: + 1. Terminal 1 hâlâ yaşıyor mu? [python3 server hâlâ hizmet veriyor mu?] → PASS + 2. Terminal 2 (yeni): gh auth status → fresh spawn path → PASS + 3. Terminal 1'de: gh auth status → shell wrapper → fresh-exec → PASS + 4. Fallback: fresh-spawn.sock'u sil, yeni terminal aç, stale spawn'a düşmeli, warn basmalı +``` + +### CI +- macOS 14 + 15 GitHub Actions runner +- Unit + integration testler +- E2E testler skip (user-interactive, not automatable) + +--- + +## 6. PR Stratejisi + +- **PR başlığı:** `fix(desktop): spawn PTY subprocesses via Electron main to avoid stale Mach context on macOS` +- **Approach:** PR #2571'i REPLACE ediyoruz, kapatılmasını öneriyoruz +- **Issue #2570 comment:** Yaklaşımı özetle, link ver +- **PR body:** Problem + PR #2571'in sorunu + yeni yaklaşım + E2E sonuçları + fallback garantileri +- **Commit organization:** + 1. `feat(main): add fresh-spawn UDS server` + 2. `feat(terminal-host): delegate pty-subprocess spawn to Electron main` + 3. `feat(terminal-host): shell wrappers for fresh-exec whitelist` + 4. `feat(fresh-exec): client binary for shell wrapper integration` + 5. `test: e2e fresh-spawn scenarios` + 6. `docs: explain Mach context problem and fresh-spawn architecture` + +--- + +## 7. Open Questions + +- [ ] FD passing npm package güvenilir mi? → Spike olarak ilk task'ta test +- [ ] Kullanıcı kendi `.zshrc`'sinde `precmd`/`preexec` kullanıyorsa sıralama sorunu? → Append behavior + test +- [ ] `fresh-exec`'e gönderilen env hangi env olmalı? Eski terminalin mi, fresh process'in mi? → Mix: kullanıcı env'i + fresh bootstrap +- [ ] Whitelist kullanıcı tarafından editlenebilmeli mi? Settings'e eklensin mi? → v1'de hardcode, v1.1'de UI + +--- + +## 8. Kaynaklar + +- [posix_spawn(2) - Apple](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/posix_spawn.2.html) +- [Bootstrap Contexts - Apple](https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/contexts/contexts.html) +- [superset-sh/superset#2570](https://github.com/superset-sh/superset/issues/2570) +- [superset-sh/superset#2571](https://github.com/superset-sh/superset/pull/2571) (replace) +- [mobile-shell/mosh#249](https://github.com/mobile-shell/mosh/issues/249) (benzer problem, mosh'ta çözülmedi) +- [node-unix-socket](https://www.npmjs.com/package/node-unix-socket) — potansiyel FD passing lib diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 2c33562eb2c..dba581ec47e 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -54,6 +54,12 @@ const config: Configuration = { "**/resources/sounds/**/*", // Tray icon must be unpacked so Electron Tray can load it "**/resources/tray/**/*", + // Shell hook must be unpacked so zsh can `source` it at runtime + // (zsh's built-in `source` is not asar-aware). + "**/resources/shell-hooks/**/*", + // fresh-exec helper binary is invoked from inside a stale-context + // zsh shell via its absolute path, so it must live outside asar. + "**/dist/main/fresh-exec.js", ], // Extra resources placed outside asar archive (accessible via process.resourcesPath) diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 5f073935c45..19875252368 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -111,6 +111,11 @@ export default defineConfig({ "git-task-worker": resolve("src/main/git-task-worker.ts"), // Workspace service - local HTTP/tRPC server per org "host-service": resolve("src/main/host-service/index.ts"), + // fresh-exec helper — invoked from stale zsh sessions to re-run + // whitelisted commands in Electron main's fresh Mach context. + // Emitted as a sibling to index.js so the shell hook can resolve + // it via __dirname-relative paths. + "fresh-exec": resolve("src/main/fresh-spawn/fresh-exec.ts"), }, output: { dir: resolve(devPath, "main"), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64c05b2242c..267e9850e03 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -190,6 +190,7 @@ "nanoid": "^5.1.6", "node-addon-api": "^7.1.0", "node-pty": "1.1.0", + "node-unix-socket": "^0.2.7", "os-locale": "^6.0.2", "pidtree": "^0.6.0", "pidusage": "^4.0.1", diff --git a/apps/desktop/plans/20260417-1500-fresh-mach-context-spawn.md b/apps/desktop/plans/20260417-1500-fresh-mach-context-spawn.md new file mode 100644 index 00000000000..07814858e8b --- /dev/null +++ b/apps/desktop/plans/20260417-1500-fresh-mach-context-spawn.md @@ -0,0 +1,2508 @@ +# Fresh Mach Context Spawn for macOS Terminal Sessions — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** macOS'ta Superset'in `terminal-host` daemon'ı stale Mach bootstrap context'e düşse bile, yeni terminallerin `gh`/`terraform`/`kubectl` gibi Go CLI araçlarını çalıştırabilmesi; eski terminallerdeki çalışan process'leri (dev server, build) öldürmeden. + +**Architecture:** Electron main process her app açılışında fresh Mach context ile doğar. Bu plan, terminal-host'un fork ettiği pty-subprocess'leri Electron main'e delegate eder (fresh inherit); ve eski terminallerdeki shell'lere preexec wrapper inject eder ki whitelisted komutlar (`gh`, `terraform`...) arka plandan fresh bir helper ile çalıştırılsın. + +**Tech Stack:** TypeScript, Electron, Node.js child_process, Unix Domain Sockets (NDJSON framing), node-pty, Bun runtime, Bun test + +--- + +## Post-Spike Architecture Update (Task 3 findings) + +Task 3 spike showed `node-unix-socket` does not support SCM_RIGHTS FD passing and seqpacket is unavailable on macOS. Rather than write a native N-API addon (~300 lines C++), we pivoted to a simpler architecture: + +**Electron-hosted child processes with I/O forwarding:** +- Electron main spawns the fresh child (pty-subprocess or fresh-exec target) +- Electron main holds the stdin/stdout/stderr pipes itself +- Electron main forwards I/O to daemon / fresh-exec over UDS as NDJSON frames +- Daemon's existing `pty-subprocess-ipc.ts` frame protocol is reused + +**What this affects in the task list:** +- Task 8 (`spawn-pty-subprocess` handler) now streams I/O over UDS instead of sending FDs +- Task 9 (ChildProcess adapter) now wraps the UDS stream rather than received FDs +- Task 13 (`fresh-exec` handler) similarly streams through the UDS +- Task 14 (PTY bridging) now bridges the UDS stream to fresh-exec's local TTY + +**Trade-off accepted:** New PTYs cannot be spawned while Electron is closed. Since terminal creation requires Superset's UI, this is never observable in practice. Existing daemon-owned sessions are unaffected. + +--- + +## Repo Orientation + +Bu plan `superset-sh/superset` fork'unda çalışıyor. Ana yerler: + +**Electron main process:** +- `apps/desktop/src/main/index.ts` — Electron main entry, app lifecycle +- `apps/desktop/src/main/terminal-host/terminal-host.ts` — terminal-host daemon RPC handler +- `apps/desktop/src/main/terminal-host/session.ts` — Her session için PTY spawn (kritik line: 268) +- `apps/desktop/src/main/terminal-host/pty-subprocess.ts` — Her PTY için izole subprocess +- `apps/desktop/src/main/lib/terminal-host/client.ts` — Electron main'in terminal-host daemon'a client'ı + +**Shared:** +- `apps/desktop/src/shared/` — Renderer + main arası paylaşımlı tipler + +**Testler:** +- Her dosyanın yanında `*.test.ts` +- Bun test runner (`bun test`) +- Mock'lar inline, test utility'ler `test-helpers.ts` + +**Build:** +- `bun run build` — TypeScript derle + Electron builder package +- `bun run dev` — Hot-reload dev mode +- `bun run typecheck` — Type kontrol + +--- + +## Problem Özeti (Kısa) + +Sorun detayı için bkz. `docs/2026-04-17-design.md`. Özet: + +1. Go binary'leri macOS'ta TLS doğrulaması için `trustd` Mach daemon'ına erişir +2. Uzun ömürlü `terminal-host` daemon stale bootstrap port taşırsa child'ları da stale +3. PR #2571 çözümü: her startup'ta daemon'ı öldürmek — **bu çalışan session'ları öldürür, bizim kabul etmediğimiz trade-off** +4. Bizim çözüm: daemon'ı yaşat, spawn'ı Electron main'e (her zaman fresh) delegate et + +--- + +## Görev Listesi + +### Faz 0: Setup +- Task 1: Fork repo, dev environment kur +- Task 2: Yeni branch + plan commit + +### Faz 1: Fresh Spawn Server (Yeni Terminaller İçin) +- Task 3: FD passing prototype (seçim: npm package vs inline native) +- Task 4: UDS protocol schema + types +- Task 5: Token-based auth +- Task 6: Spawn-server skeleton (Electron main side) +- Task 7: Spawn-client skeleton (terminal-host side) +- Task 8: Spawn-server `spawn-pty-subprocess` RPC +- Task 9: Spawn-client FD receive + ChildProcess adapter +- Task 10: Session.ts entegrasyonu + fallback +- Task 11: E2E: yeni terminal `gh auth status` çalışıyor mu + +### Faz 2: Shell Wrapper (Eski Terminaller İçin) +- Task 12: fresh-exec helper binary skeleton +- Task 13: Spawn-server `fresh-exec` RPC +- Task 14: PTY bridging (interactive komutlar için) +- Task 15: Signal forwarding (Ctrl+C, SIGWINCH) +- Task 16: zsh preexec hook script +- Task 17: Shell wrapper injection (ZDOTDIR pattern) +- Task 18: Whitelist config +- Task 19: E2E: eski terminal `gh auth login` çalışıyor mu + +### Faz 3: Polish & Release +- Task 20: Cross-platform guards (non-macOS no-op) +- Task 21: Metrics + warn logging +- Task 22: Full type-check + biome lint +- Task 23: Local Superset build + manual E2E +- Task 24: PR body yaz, issue comment, push + +--- + +## Task 1: Fork Repo ve Dev Environment + +**Files:** +- Modify: Git remote config + +**Amaç:** Kendi fork'umuzda çalışabilmek için remote'u ayarla. + +- [ ] **Step 1.1: Fork'u GitHub'da oluştur** + +Tarayıcıda: https://github.com/superset-sh/superset/fork — "Create Fork" tuşuna bas. GitHub hesabın olarak `Haknt` seç. + +- [ ] **Step 1.2: Remote'u ekle** + +```bash +cd ~/Documents/repos/superset-sh-superset +git remote rename origin upstream +git remote add origin https://github.com/Haknt/superset.git +git fetch origin +git branch -u origin/main main +``` + +Expected: `git remote -v` çıktısında `upstream` (superset-sh) ve `origin` (Haknt) gözükür. + +- [ ] **Step 1.3: Dependencies install** + +```bash +cd ~/Documents/repos/superset-sh-superset +bun install +``` + +Expected: Sıfır error. Node.js >= 18 gerekli. + +- [ ] **Step 1.4: Sanity check** + +```bash +cd apps/desktop +bun run typecheck +``` + +Expected: Typecheck passes (mevcut codebase temiz olmalı). + +- [ ] **Step 1.5: Commit (no change — sanity only)** + +Atla — sadece setup. + +--- + +## Task 2: Branch Oluştur + Plan Commit + +**Files:** +- Create: `apps/desktop/plans/20260417-1500-fresh-mach-context-spawn.md` (bu dosyanın kopyası) + +- [ ] **Step 2.1: Yeni branch** + +```bash +cd ~/Documents/repos/superset-sh-superset +git checkout -b feat/fresh-mach-context-spawn +``` + +- [ ] **Step 2.2: Plan'ı repo'ya kopyala** + +```bash +cp ~/Documents/repos/mach-fresh-spawn/docs/2026-04-17-plan.md \ + apps/desktop/plans/20260417-1500-fresh-mach-context-spawn.md + +cp ~/Documents/repos/mach-fresh-spawn/docs/2026-04-17-design.md \ + apps/desktop/docs/fresh-mach-context-design.md +``` + +- [ ] **Step 2.3: Commit** + +```bash +git add apps/desktop/plans/ apps/desktop/docs/ +git commit -m "docs: add fresh Mach context spawn plan and design + +Refs #2570" +``` + +--- + +## Task 3: FD Passing Prototype + +**Files:** +- Create: `apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.ts` +- Create: `apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.test.ts` + +**Amaç:** Seçeceğimiz FD passing yaklaşımını doğrula: npm `node-unix-socket` package vs inline native addon. Bu spike task. + +- [ ] **Step 3.1: Failing test yaz — FD passing round-trip** + +Test iki process arasında stdin FD transfer ediyor mu? + +```typescript +// apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.test.ts +import { describe, it, expect } from "bun:test"; +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { sendFd, recvFd } from "./fd-passing-spike"; + +describe("fd-passing spike", () => { + it("transfers a writable FD between two processes", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fd-spike-")); + const sockPath = path.join(tmpDir, "spike.sock"); + const outFile = path.join(tmpDir, "received.txt"); + + // Sender spawns child process, opens a write FD to outFile, + // sends it via UDS to receiver + const senderReady = new Promise((resolve) => { + const fd = fs.openSync(outFile, "w"); + sendFd(sockPath, fd, resolve); + }); + + // Receiver reads FD from UDS, writes "hello", closes + await senderReady; + const received = await recvFd(sockPath); + fs.writeSync(received, "hello"); + fs.closeSync(received); + + const content = fs.readFileSync(outFile, "utf8"); + expect(content).toBe("hello"); + + fs.rmSync(tmpDir, { recursive: true }); + }); +}); +``` + +- [ ] **Step 3.2: Test'i fail eder şekilde çalıştır** + +```bash +cd apps/desktop +bun test src/main/fresh-spawn/spike/fd-passing-spike.test.ts +``` + +Expected: FAIL with `sendFd is not defined` + +- [ ] **Step 3.3: npm package `node-unix-socket` dene** + +```bash +cd apps/desktop +bun add node-unix-socket +``` + +- [ ] **Step 3.4: Minimal wrapper yaz** + +```typescript +// apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.ts +import { UnixSeqpacketSocketServer, UnixSeqpacketSocket } from "node-unix-socket"; + +export function sendFd( + socketPath: string, + fd: number, + onConnected: () => void, +): void { + const server = new UnixSeqpacketSocketServer(); + server.listen(socketPath); + server.onConnection((client) => { + client.sendFd(Buffer.from([0]), fd, () => { + server.close(); + }); + }); + onConnected(); +} + +export function recvFd(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const client = new UnixSeqpacketSocket(); + client.connect(socketPath); + client.onData((data, fds) => { + if (fds.length === 0) { + reject(new Error("no fd received")); + return; + } + resolve(fds[0]); + client.close(); + }); + }); +} +``` + +**NOT:** `node-unix-socket` API'si doğrulanmamış — ilk deneme. Çalışmazsa Step 3.5. + +- [ ] **Step 3.5: Testi çalıştır** + +```bash +bun test src/main/fresh-spawn/spike/fd-passing-spike.test.ts +``` + +Expected: PASS. Fail ederse Step 3.6. + +- [ ] **Step 3.6: FALLBACK — Inline native addon** + +`node-unix-socket` çalışmazsa, native N-API addon yaz. Bu ayrı sub-task: + +```bash +# apps/desktop/src/main/fresh-spawn/spike/native/ +mkdir -p src/main/fresh-spawn/spike/native +``` + +Dosyalar: +- `binding.gyp` — build config +- `fd_passing.cc` — `sendmsg()` + `recvmsg()` C++ (~80 satır) + +Native addon yazımı ayrı task grubu. Şimdilik: spike npm'le PASS olursa burada dur. Native gerekirse bu plan güncellenir. + +- [ ] **Step 3.7: Commit (spike)** + +```bash +git add apps/desktop/src/main/fresh-spawn/spike/ apps/desktop/package.json bun.lockb +git commit -m "spike: validate FD passing between processes via UDS + +Uses node-unix-socket for SCM_RIGHTS FD transfer. Round-trip test passes. +Refs #2570" +``` + +--- + +## Task 4: UDS Protocol Schema + Types + +**Files:** +- Create: `apps/desktop/src/main/fresh-spawn/types.ts` +- Create: `apps/desktop/src/main/fresh-spawn/types.test.ts` + +**Amaç:** Spawn-server ↔ spawn-client arasındaki JSON protocol'ün tiplerini Zod ile tanımla. + +- [ ] **Step 4.1: Failing test — request/response schema validation** + +```typescript +// apps/desktop/src/main/fresh-spawn/types.test.ts +import { describe, it, expect } from "bun:test"; +import { + SpawnRequestSchema, + SpawnResponseSchema, + type SpawnRequest, + type SpawnResponse, +} from "./types"; + +describe("fresh-spawn protocol types", () => { + it("validates spawn-pty-subprocess request", () => { + const req: SpawnRequest = { + type: "spawn-pty-subprocess", + token: "abc123", + env: { PATH: "/usr/bin", HOME: "/Users/x" }, + }; + expect(() => SpawnRequestSchema.parse(req)).not.toThrow(); + }); + + it("validates fresh-exec request", () => { + const req: SpawnRequest = { + type: "fresh-exec", + token: "abc123", + command: "gh", + args: ["auth", "login"], + cwd: "/tmp", + env: {}, + ptyCols: 80, + ptyRows: 24, + }; + expect(() => SpawnRequestSchema.parse(req)).not.toThrow(); + }); + + it("rejects request without token", () => { + expect(() => + SpawnRequestSchema.parse({ type: "spawn-pty-subprocess", env: {} }), + ).toThrow(); + }); + + it("validates ok response", () => { + const resp: SpawnResponse = { type: "ok", pid: 1234 }; + expect(() => SpawnResponseSchema.parse(resp)).not.toThrow(); + }); + + it("validates error response", () => { + const resp: SpawnResponse = { + type: "error", + message: "auth failed", + code: "E_AUTH", + }; + expect(() => SpawnResponseSchema.parse(resp)).not.toThrow(); + }); +}); +``` + +- [ ] **Step 4.2: Test fail** + +```bash +bun test src/main/fresh-spawn/types.test.ts +``` + +Expected: FAIL — module not found + +- [ ] **Step 4.3: Types + schemas yaz** + +```typescript +// apps/desktop/src/main/fresh-spawn/types.ts +import { z } from "zod"; + +export const SpawnRequestSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("spawn-pty-subprocess"), + token: z.string().min(1), + env: z.record(z.string(), z.string()), + }), + z.object({ + type: z.literal("fresh-exec"), + token: z.string().min(1), + command: z.string().min(1), + args: z.array(z.string()), + cwd: z.string().min(1), + env: z.record(z.string(), z.string()), + ptyCols: z.number().int().positive(), + ptyRows: z.number().int().positive(), + }), +]); + +export type SpawnRequest = z.infer; + +export const SpawnResponseSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("ok"), + pid: z.number().int().positive(), + }), + z.object({ + type: z.literal("error"), + message: z.string(), + code: z.string(), + }), +]); + +export type SpawnResponse = z.infer; + +export const DEFAULT_SOCKET_PATH = `${process.env.HOME}/.superset/fresh-spawn.sock`; +export const DEFAULT_TOKEN_PATH = `${process.env.HOME}/.superset/fresh-spawn.token`; +``` + +- [ ] **Step 4.4: Test pass** + +```bash +bun test src/main/fresh-spawn/types.test.ts +``` + +Expected: PASS (5 tests) + +- [ ] **Step 4.5: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/types.ts apps/desktop/src/main/fresh-spawn/types.test.ts +git commit -m "feat(fresh-spawn): define UDS protocol schema + +Zod discriminated union for SpawnRequest (spawn-pty-subprocess | fresh-exec) +and SpawnResponse (ok | error). Tests cover happy paths and auth rejection. +Refs #2570" +``` + +--- + +## Task 5: Token-Based Auth + +**Files:** +- Create: `apps/desktop/src/main/fresh-spawn/auth.ts` +- Create: `apps/desktop/src/main/fresh-spawn/auth.test.ts` + +**Amaç:** Başka local app'ler fresh-spawn socket'ine connect edip spawn edemesin diye random token gate'i. + +- [ ] **Step 5.1: Failing test — token generate/read/verify** + +```typescript +// apps/desktop/src/main/fresh-spawn/auth.test.ts +import { describe, it, expect, beforeEach } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + generateTokenFile, + readTokenFile, + verifyToken, +} from "./auth"; + +describe("fresh-spawn auth", () => { + let tmpPath: string; + + beforeEach(() => { + tmpPath = path.join( + os.tmpdir(), + `fs-auth-${Date.now()}-${Math.random()}.token`, + ); + }); + + it("generates token with 256 bits of entropy", () => { + const token = generateTokenFile(tmpPath); + expect(token.length).toBeGreaterThanOrEqual(43); // base64 of 32 bytes + expect(fs.readFileSync(tmpPath, "utf8")).toBe(token); + + const stat = fs.statSync(tmpPath); + // Mode 0o600 — only owner readable + expect(stat.mode & 0o077).toBe(0); + fs.rmSync(tmpPath); + }); + + it("reads back the generated token", () => { + const expected = generateTokenFile(tmpPath); + const actual = readTokenFile(tmpPath); + expect(actual).toBe(expected); + fs.rmSync(tmpPath); + }); + + it("verifyToken returns true for match", () => { + const token = "abcdef"; + expect(verifyToken(token, "abcdef")).toBe(true); + }); + + it("verifyToken returns false for mismatch", () => { + expect(verifyToken("abc", "abcdef")).toBe(false); + }); + + it("verifyToken uses constant-time comparison (length mismatch)", () => { + expect(verifyToken("x", "abcdef")).toBe(false); + }); +}); +``` + +- [ ] **Step 5.2: Test fail** + +```bash +bun test src/main/fresh-spawn/auth.test.ts +``` + +Expected: FAIL — module not found + +- [ ] **Step 5.3: Implementation** + +```typescript +// apps/desktop/src/main/fresh-spawn/auth.ts +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * Generate a cryptographically random token, write to path with 0600 mode, + * and return the token string. + */ +export function generateTokenFile(tokenPath: string): string { + const token = crypto.randomBytes(32).toString("base64url"); + fs.mkdirSync(path.dirname(tokenPath), { recursive: true }); + fs.writeFileSync(tokenPath, token, { mode: 0o600 }); + return token; +} + +/** + * Read the token from disk. + * Throws if file missing — caller must handle. + */ +export function readTokenFile(tokenPath: string): string { + return fs.readFileSync(tokenPath, "utf8").trim(); +} + +/** + * Constant-time token comparison. Returns false on length mismatch + * without timing leak. + */ +export function verifyToken(received: string, expected: string): boolean { + if (received.length !== expected.length) return false; + return crypto.timingSafeEqual( + Buffer.from(received), + Buffer.from(expected), + ); +} +``` + +- [ ] **Step 5.4: Test pass** + +```bash +bun test src/main/fresh-spawn/auth.test.ts +``` + +Expected: PASS (5 tests) + +- [ ] **Step 5.5: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/auth.ts apps/desktop/src/main/fresh-spawn/auth.test.ts +git commit -m "feat(fresh-spawn): token-based auth with 0600 file + +256-bit random token in ~/.superset/fresh-spawn.token. Constant-time compare +prevents timing attacks. Refs #2570" +``` + +--- + +## Task 6: Spawn-Server Skeleton + +**Files:** +- Create: `apps/desktop/src/main/fresh-spawn/spawn-server.ts` +- Create: `apps/desktop/src/main/fresh-spawn/spawn-server.test.ts` + +**Amaç:** UDS server'ı başlat, gelen bağlantıları kabul et, request/response handle et (spawn logic'i TODO placeholder olarak). + +- [ ] **Step 6.1: Failing test — server starts, accepts connection, rejects bad auth** + +```typescript +// apps/desktop/src/main/fresh-spawn/spawn-server.test.ts +import { describe, it, expect, afterEach } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as net from "node:net"; +import { startSpawnServer, type SpawnServer } from "./spawn-server"; + +describe("SpawnServer", () => { + let server: SpawnServer | null = null; + let tmpDir: string; + + afterEach(() => { + if (server) { + server.close(); + server = null; + } + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it("starts, accepts a connection, rejects invalid token", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-server-")); + const sockPath = path.join(tmpDir, "server.sock"); + const tokenPath = path.join(tmpDir, "server.token"); + + server = await startSpawnServer({ + socketPath: sockPath, + tokenPath, + }); + + const client = net.createConnection(sockPath); + await new Promise((resolve) => client.once("connect", () => resolve())); + + const req = { + type: "spawn-pty-subprocess", + token: "WRONG_TOKEN", + env: {}, + }; + client.write(`${JSON.stringify(req)}\n`); + + const resp = await new Promise((resolve) => { + client.once("data", (data) => resolve(data.toString("utf8").trim())); + }); + + const parsed = JSON.parse(resp); + expect(parsed.type).toBe("error"); + expect(parsed.code).toBe("E_AUTH"); + + client.destroy(); + }); +}); +``` + +- [ ] **Step 6.2: Test fail** + +```bash +bun test src/main/fresh-spawn/spawn-server.test.ts +``` + +Expected: FAIL — module not found + +- [ ] **Step 6.3: Minimal server implementation** + +```typescript +// apps/desktop/src/main/fresh-spawn/spawn-server.ts +import * as fs from "node:fs"; +import * as net from "node:net"; +import { generateTokenFile, verifyToken } from "./auth"; +import { + SpawnRequestSchema, + type SpawnResponse, +} from "./types"; + +export interface SpawnServerOptions { + socketPath: string; + tokenPath: string; +} + +export interface SpawnServer { + close(): void; +} + +export async function startSpawnServer( + options: SpawnServerOptions, +): Promise { + // Ensure socket not stale + try { + fs.unlinkSync(options.socketPath); + } catch { + // File may not exist — ignore + } + + const token = generateTokenFile(options.tokenPath); + + const server = net.createServer((client) => { + client.once("data", (data) => { + const text = data.toString("utf8").trim(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + sendResponse(client, { + type: "error", + message: "invalid JSON", + code: "E_PARSE", + }); + client.destroy(); + return; + } + + const result = SpawnRequestSchema.safeParse(parsed); + if (!result.success) { + sendResponse(client, { + type: "error", + message: "invalid request", + code: "E_SCHEMA", + }); + client.destroy(); + return; + } + + if (!verifyToken(result.data.token, token)) { + sendResponse(client, { + type: "error", + message: "bad token", + code: "E_AUTH", + }); + client.destroy(); + return; + } + + // TODO Task 8 & 13: actual spawn handling + sendResponse(client, { + type: "error", + message: "not implemented", + code: "E_TODO", + }); + client.destroy(); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.socketPath, () => { + // chmod 0700 for owner-only access + try { + fs.chmodSync(options.socketPath, 0o700); + } catch (err) { + server.close(); + reject(err); + return; + } + resolve(); + }); + }); + + return { + close: () => { + server.close(); + try { + fs.unlinkSync(options.socketPath); + } catch { + // ignore + } + }, + }; +} + +function sendResponse(client: net.Socket, resp: SpawnResponse): void { + client.write(`${JSON.stringify(resp)}\n`); +} +``` + +- [ ] **Step 6.4: Test pass** + +```bash +bun test src/main/fresh-spawn/spawn-server.test.ts +``` + +Expected: PASS (1 test) + +- [ ] **Step 6.5: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/spawn-server.ts apps/desktop/src/main/fresh-spawn/spawn-server.test.ts +git commit -m "feat(fresh-spawn): UDS spawn server skeleton + +Starts server on configurable socket path, validates auth token, parses +request schema. Actual spawn handlers are TODO (Tasks 8, 13). Refs #2570" +``` + +--- + +## Task 7: Spawn-Client Skeleton + +**Files:** +- Create: `apps/desktop/src/main/fresh-spawn/spawn-client.ts` +- Create: `apps/desktop/src/main/fresh-spawn/spawn-client.test.ts` + +**Amaç:** terminal-host'tan (veya fresh-exec'ten) server'a bağlanan client. Request yap, response oku. + +- [ ] **Step 7.1: Failing test — client connects and receives response** + +```typescript +// apps/desktop/src/main/fresh-spawn/spawn-client.test.ts +import { describe, it, expect, afterEach } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { startSpawnServer, type SpawnServer } from "./spawn-server"; +import { sendSpawnRequest } from "./spawn-client"; + +describe("SpawnClient", () => { + let server: SpawnServer | null = null; + let tmpDir: string; + + afterEach(() => { + if (server) server.close(); + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it("sends request with correct token, receives error (TODO handler)", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-client-")); + const sockPath = path.join(tmpDir, "s.sock"); + const tokenPath = path.join(tmpDir, "s.token"); + + server = await startSpawnServer({ + socketPath: sockPath, + tokenPath, + }); + + const resp = await sendSpawnRequest({ + socketPath: sockPath, + tokenPath, + request: { + type: "spawn-pty-subprocess", + env: {}, + }, + }); + + // Server returns E_TODO for spawn-pty-subprocess until Task 8 + expect(resp.type).toBe("error"); + }); +}); +``` + +- [ ] **Step 7.2: Test fail** + +```bash +bun test src/main/fresh-spawn/spawn-client.test.ts +``` + +- [ ] **Step 7.3: Client implementation** + +```typescript +// apps/desktop/src/main/fresh-spawn/spawn-client.ts +import * as net from "node:net"; +import { readTokenFile } from "./auth"; +import { + SpawnResponseSchema, + type SpawnResponse, +} from "./types"; + +export interface SendSpawnRequestOptions { + socketPath: string; + tokenPath: string; + request: { + type: "spawn-pty-subprocess"; + env: Record; + } | { + type: "fresh-exec"; + command: string; + args: string[]; + cwd: string; + env: Record; + ptyCols: number; + ptyRows: number; + }; + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 5000; + +export async function sendSpawnRequest( + options: SendSpawnRequestOptions, +): Promise { + const token = readTokenFile(options.tokenPath); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + const client = net.createConnection(options.socketPath); + const timer = setTimeout(() => { + client.destroy(); + reject(new Error("spawn request timeout")); + }, timeoutMs); + + client.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + + client.once("connect", () => { + const req = { ...options.request, token }; + client.write(`${JSON.stringify(req)}\n`); + }); + + client.once("data", (data) => { + clearTimeout(timer); + const text = data.toString("utf8").trim(); + try { + const parsed = JSON.parse(text); + const result = SpawnResponseSchema.safeParse(parsed); + if (!result.success) { + reject(new Error(`invalid response: ${text}`)); + return; + } + resolve(result.data); + } catch (err) { + reject(err); + } finally { + client.destroy(); + } + }); + }); +} +``` + +- [ ] **Step 7.4: Test pass** + +```bash +bun test src/main/fresh-spawn/spawn-client.test.ts +``` + +- [ ] **Step 7.5: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/spawn-client.ts apps/desktop/src/main/fresh-spawn/spawn-client.test.ts +git commit -m "feat(fresh-spawn): spawn client for terminal-host and fresh-exec + +Reads token from disk, sends request over UDS, parses response with schema +validation. 5s timeout. Refs #2570" +``` + +--- + +## Task 8: `spawn-pty-subprocess` RPC Handler + +**Files:** +- Modify: `apps/desktop/src/main/fresh-spawn/spawn-server.ts` +- Create: `apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.ts` +- Create: `apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.test.ts` + +**Amaç:** Server `spawn-pty-subprocess` aldığında, fresh bir pty-subprocess spawn edip stdin/stdout/stderr FD'lerini client'a SCM_RIGHTS ile yollasın. + +- [ ] **Step 8.1: Failing test — handler spawns subprocess, returns FDs** + +```typescript +// apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.test.ts +import { describe, it, expect } from "bun:test"; +import { handleSpawnPtySubprocess } from "./spawn-pty-subprocess"; + +describe("handleSpawnPtySubprocess", () => { + it("spawns a process and returns PID + FDs", async () => { + const result = await handleSpawnPtySubprocess({ + subprocessScriptPath: require.resolve("./test-echo-child.js"), + env: { CUSTOM: "yes" }, + }); + + expect(result.pid).toBeGreaterThan(0); + expect(result.stdinFd).toBeGreaterThan(0); + expect(result.stdoutFd).toBeGreaterThan(0); + expect(result.stderrFd).toBeGreaterThan(0); + expect(result.close).toBeTypeOf("function"); + result.close(); + }); +}); +``` + +Test helper (test-echo-child.js): stdin'den okur, stdout'a yansıtır. + +```javascript +// apps/desktop/src/main/fresh-spawn/handlers/test-echo-child.js +process.stdin.on("data", (chunk) => { + process.stdout.write(chunk); +}); +``` + +- [ ] **Step 8.2: Test fail** + +```bash +bun test src/main/fresh-spawn/handlers/spawn-pty-subprocess.test.ts +``` + +- [ ] **Step 8.3: Handler implementation** + +```typescript +// apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.ts +import { spawn, type ChildProcess } from "node:child_process"; + +export interface SpawnPtySubprocessOptions { + subprocessScriptPath: string; + env: Record; +} + +export interface SpawnPtySubprocessResult { + pid: number; + stdinFd: number; + stdoutFd: number; + stderrFd: number; + close: () => void; +} + +export async function handleSpawnPtySubprocess( + options: SpawnPtySubprocessOptions, +): Promise { + const child: ChildProcess = spawn( + process.execPath, + [options.subprocessScriptPath], + { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...options.env, + ELECTRON_RUN_AS_NODE: "1", + }, + }, + ); + + if ( + !child.stdin || + !child.stdout || + !child.stderr || + child.pid == null + ) { + child.kill("SIGKILL"); + throw new Error("failed to spawn subprocess"); + } + + // Get underlying file descriptors from streams + // Node exposes _handle.fd on stream objects + const stdinFd = getFdFromStream(child.stdin); + const stdoutFd = getFdFromStream(child.stdout); + const stderrFd = getFdFromStream(child.stderr); + + return { + pid: child.pid, + stdinFd, + stdoutFd, + stderrFd, + close: () => { + child.kill("SIGTERM"); + }, + }; +} + +function getFdFromStream(stream: NodeJS.ReadableStream | NodeJS.WritableStream): number { + const handle = (stream as unknown as { _handle?: { fd?: number } })._handle; + if (handle?.fd == null) { + throw new Error("stream has no underlying fd"); + } + return handle.fd; +} +``` + +- [ ] **Step 8.4: Server'a handler'ı bağla** + +```typescript +// apps/desktop/src/main/fresh-spawn/spawn-server.ts +// ... existing imports ... +import { handleSpawnPtySubprocess } from "./handlers/spawn-pty-subprocess"; +import { sendFd } from "./fd-passing"; // unified FD helper, Task 9'da export edilecek + +// Inside the client.once("data") handler, after verifyToken passes: + +if (result.data.type === "spawn-pty-subprocess") { + try { + const spawnResult = await handleSpawnPtySubprocess({ + subprocessScriptPath: path.join(__dirname, "../terminal-host/pty-subprocess.js"), + env: result.data.env, + }); + + sendResponse(client, { type: "ok", pid: spawnResult.pid }); + + // Send the three FDs via SCM_RIGHTS (one per message or bundled) + await sendFds(client, [ + spawnResult.stdinFd, + spawnResult.stdoutFd, + spawnResult.stderrFd, + ]); + + // Once client closes, clean up + client.on("close", () => spawnResult.close()); + } catch (err) { + sendResponse(client, { + type: "error", + message: String(err), + code: "E_SPAWN", + }); + client.destroy(); + } +} +``` + +- [ ] **Step 8.5: Test pass** + +```bash +bun test src/main/fresh-spawn/handlers/spawn-pty-subprocess.test.ts +``` + +- [ ] **Step 8.6: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/handlers/ apps/desktop/src/main/fresh-spawn/spawn-server.ts +git commit -m "feat(fresh-spawn): handle spawn-pty-subprocess RPC + +Spawns fresh pty-subprocess child from Electron main context. +Returns PID + sends stdin/stdout/stderr FDs via SCM_RIGHTS. +Refs #2570" +``` + +--- + +## Task 9: Spawn-Client FD Receive + ChildProcess Adapter + +**Files:** +- Create: `apps/desktop/src/main/fresh-spawn/child-process-adapter.ts` +- Create: `apps/desktop/src/main/fresh-spawn/child-process-adapter.test.ts` + +**Amaç:** Client, server'dan gelen FD'leri alıp `ChildProcess`-benzeri bir obje oluştursun (terminal-host'un existing kodu compatible olsun). + +- [ ] **Step 9.1: Failing test — adapter creates streams from FDs** + +```typescript +// apps/desktop/src/main/fresh-spawn/child-process-adapter.test.ts +import { describe, it, expect } from "bun:test"; +import * as os from "node:os"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createChildProcessAdapter } from "./child-process-adapter"; + +describe("ChildProcessAdapter", () => { + it("wraps FDs as a ChildProcess-like object", async () => { + // Create pipes for stdin/stdout/stderr + const [stdinR, stdinW] = createPipe(); + const [stdoutR, stdoutW] = createPipe(); + const [stderrR, stderrW] = createPipe(); + + const adapter = createChildProcessAdapter({ + pid: 12345, + stdinFd: stdinW, + stdoutFd: stdoutR, + stderrFd: stderrR, + }); + + expect(adapter.pid).toBe(12345); + expect(adapter.stdin).not.toBeNull(); + expect(adapter.stdout).not.toBeNull(); + expect(adapter.stderr).not.toBeNull(); + + // Write to stdin, read from the other end of the pipe + adapter.stdin!.write("hello"); + const buf = Buffer.alloc(5); + fs.readSync(stdinR, buf, 0, 5, null); + expect(buf.toString()).toBe("hello"); + + adapter.stdin!.end(); + }); +}); + +function createPipe(): [number, number] { + const { readFd, writeFd } = require("../../lib/fs-helpers").pipeSync(); + return [readFd, writeFd]; +} +``` + +Helper (`pipeSync`) — platform uygunsa `fs.pipe` çağrısı, yoksa native pipe: + +```typescript +// apps/desktop/src/main/lib/fs-helpers.ts (yeni dosya) +export function pipeSync(): { readFd: number; writeFd: number } { + // Use posix pipe(2) + const fds = new Int32Array(2); + // Bun supports native pipe call + // Fallback: use a tmp file-backed pipe if needed + throw new Error("implement via node:net.Socket.pair() or native pipe"); +} +``` + +NOT: Pipe yaratmak Node.js stdlib'de doğrudan yok. Test için `net.Socket.pair()` veya native kullanılabilir. Bu detay Task 9.3'te çözülecek. + +- [ ] **Step 9.2: Test fail** + +```bash +bun test src/main/fresh-spawn/child-process-adapter.test.ts +``` + +- [ ] **Step 9.3: Adapter implementation** + +Node.js'te FD'den stream oluşturmak için `net.Socket` kullan: + +```typescript +// apps/desktop/src/main/fresh-spawn/child-process-adapter.ts +import * as net from "node:net"; +import { EventEmitter } from "node:events"; + +export interface ChildProcessAdapterOptions { + pid: number; + stdinFd: number; + stdoutFd: number; + stderrFd: number; +} + +export interface ChildProcessAdapter extends EventEmitter { + pid: number; + stdin: net.Socket | null; + stdout: net.Socket | null; + stderr: net.Socket | null; + kill: (signal?: NodeJS.Signals) => boolean; +} + +export function createChildProcessAdapter( + options: ChildProcessAdapterOptions, +): ChildProcessAdapter { + const emitter = new EventEmitter() as ChildProcessAdapter; + emitter.pid = options.pid; + emitter.stdin = new net.Socket({ fd: options.stdinFd, writable: true, readable: false }); + emitter.stdout = new net.Socket({ fd: options.stdoutFd, writable: false, readable: true }); + emitter.stderr = new net.Socket({ fd: options.stderrFd, writable: false, readable: true }); + + emitter.kill = (signal = "SIGTERM") => { + try { + process.kill(options.pid, signal); + return true; + } catch { + return false; + } + }; + + // Bridge child exit to emitter + emitter.stdout.once("close", () => { + // Poll to check if PID still alive; when gone, emit "exit" + checkProcessExit(options.pid, (code, signal) => { + emitter.emit("exit", code, signal); + }); + }); + + return emitter; +} + +function checkProcessExit( + pid: number, + callback: (code: number | null, signal: string | null) => void, +): void { + const interval = setInterval(() => { + try { + // Signal 0 = probe only + process.kill(pid, 0); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ESRCH") { + clearInterval(interval); + // We don't know exit code from here — defer to daemon's exit tracking + callback(0, null); + } + } + }, 100); +} +``` + +- [ ] **Step 9.4: Test pass** + +```bash +bun test src/main/fresh-spawn/child-process-adapter.test.ts +``` + +- [ ] **Step 9.5: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/child-process-adapter.ts apps/desktop/src/main/fresh-spawn/child-process-adapter.test.ts +git commit -m "feat(fresh-spawn): ChildProcess adapter from FDs + +Wraps received stdin/stdout/stderr FDs as node:net.Socket streams, exposes +ChildProcess-compatible interface for terminal-host session.ts. +Refs #2570" +``` + +--- + +## Task 10: Session.ts Integration + Fallback + +**Files:** +- Modify: `apps/desktop/src/main/terminal-host/session.ts` (line ~268) +- Create: `apps/desktop/src/main/terminal-host/fresh-spawn-integration.ts` +- Create: `apps/desktop/src/main/terminal-host/fresh-spawn-integration.test.ts` + +**Amaç:** Session.spawn() içindeki direct spawn'ı fresh-spawn via Electron ile değiştir, fallback'i koru. + +- [ ] **Step 10.1: Failing test — integration uses fresh-spawn when available** + +```typescript +// apps/desktop/src/main/terminal-host/fresh-spawn-integration.test.ts +import { describe, it, expect, mock } from "bun:test"; +import { trySpawnViaFreshServer } from "./fresh-spawn-integration"; + +describe("trySpawnViaFreshServer", () => { + it("returns null when fresh-spawn socket not found", async () => { + const result = await trySpawnViaFreshServer({ + socketPath: "/nonexistent/path.sock", + tokenPath: "/nonexistent/path.token", + env: {}, + }); + expect(result).toBeNull(); + }); + + it("returns ChildProcess adapter when socket responds", async () => { + // Simulate with mock — real server test in E2E (Task 11) + // ... mock setup ... + }); +}); +``` + +- [ ] **Step 10.2: Test fail + implementation** + +```typescript +// apps/desktop/src/main/terminal-host/fresh-spawn-integration.ts +import * as fs from "node:fs"; +import { sendSpawnRequest } from "../fresh-spawn/spawn-client"; +import { receiveFds } from "../fresh-spawn/fd-passing"; +import { + createChildProcessAdapter, + type ChildProcessAdapter, +} from "../fresh-spawn/child-process-adapter"; + +export interface TrySpawnOptions { + socketPath: string; + tokenPath: string; + env: Record; +} + +/** + * Attempts to spawn a pty-subprocess via the fresh-spawn server + * running in Electron main. Returns a ChildProcess adapter on success, + * null if the server is unavailable (fallback to existing stale spawn). + */ +export async function trySpawnViaFreshServer( + options: TrySpawnOptions, +): Promise { + if (process.platform !== "darwin") return null; + if (!fs.existsSync(options.socketPath)) return null; + if (!fs.existsSync(options.tokenPath)) return null; + + try { + const resp = await sendSpawnRequest({ + socketPath: options.socketPath, + tokenPath: options.tokenPath, + request: { + type: "spawn-pty-subprocess", + env: options.env, + }, + timeoutMs: 2000, + }); + + if (resp.type !== "ok") { + console.warn( + `[fresh-spawn] server returned error ${resp.code}: ${resp.message}`, + ); + return null; + } + + const [stdinFd, stdoutFd, stderrFd] = await receiveFds( + options.socketPath, + 3, + ); + + return createChildProcessAdapter({ + pid: resp.pid, + stdinFd, + stdoutFd, + stderrFd, + }); + } catch (err) { + console.warn("[fresh-spawn] failed, falling back to stale spawn:", err); + return null; + } +} +``` + +- [ ] **Step 10.3: Session.ts modify** + +Existing line 268: + +```typescript +this.subprocess = this.spawnProcess(electronPath, [subprocessPath], { + stdio: ["pipe", "pipe", "inherit"], + env: { ...processEnv, ELECTRON_RUN_AS_NODE: "1" }, +}); +``` + +Replace with: + +```typescript +const freshAdapter = await trySpawnViaFreshServer({ + socketPath: DEFAULT_SOCKET_PATH, + tokenPath: DEFAULT_TOKEN_PATH, + env: { ...processEnv, ELECTRON_RUN_AS_NODE: "1" }, +}); + +if (freshAdapter) { + this.subprocess = freshAdapter as unknown as ChildProcess; +} else { + // Fallback: stale spawn (existing behavior) + this.subprocess = this.spawnProcess(electronPath, [subprocessPath], { + stdio: ["pipe", "pipe", "inherit"], + env: { ...processEnv, ELECTRON_RUN_AS_NODE: "1" }, + }); +} +``` + +Import: `import { trySpawnViaFreshServer } from "./fresh-spawn-integration";` +Import: `import { DEFAULT_SOCKET_PATH, DEFAULT_TOKEN_PATH } from "../fresh-spawn/types";` + +- [ ] **Step 10.4: Test pass + typecheck** + +```bash +bun test src/main/terminal-host/fresh-spawn-integration.test.ts +bun run typecheck +``` + +- [ ] **Step 10.5: Spawn-server'ı Electron main'de başlat** + +Modify `apps/desktop/src/main/index.ts`: + +```typescript +import { startSpawnServer, type SpawnServer } from "./fresh-spawn/spawn-server"; +import { DEFAULT_SOCKET_PATH, DEFAULT_TOKEN_PATH } from "./fresh-spawn/types"; + +let spawnServerInstance: SpawnServer | null = null; + +app.whenReady().then(async () => { + if (process.platform === "darwin") { + try { + spawnServerInstance = await startSpawnServer({ + socketPath: DEFAULT_SOCKET_PATH, + tokenPath: DEFAULT_TOKEN_PATH, + }); + console.log("[fresh-spawn] server started"); + } catch (err) { + console.warn("[fresh-spawn] server failed to start:", err); + } + } + // ... existing app ready logic ... +}); + +app.on("before-quit", () => { + if (spawnServerInstance) { + spawnServerInstance.close(); + spawnServerInstance = null; + } +}); +``` + +- [ ] **Step 10.6: Commit** + +```bash +git add apps/desktop/src/main/terminal-host/session.ts apps/desktop/src/main/terminal-host/fresh-spawn-integration.ts apps/desktop/src/main/terminal-host/fresh-spawn-integration.test.ts apps/desktop/src/main/index.ts +git commit -m "feat(terminal-host): delegate PTY spawn to Electron main fresh server + +Session.spawn() now tries fresh-spawn server first (macOS only). +Falls back to existing stale spawn if server unavailable. +Electron main starts/stops server via app lifecycle hooks. +Refs #2570" +``` + +--- + +## Task 11: E2E — Yeni Terminal `gh auth status` + +**Files:** +- Create: `apps/desktop/e2e/fresh-spawn-new-terminal.md` (manual test doc) + +**Amaç:** Local build + install, yeni terminal aç, `gh auth status` çalışmalı. + +- [ ] **Step 11.1: Build** + +```bash +cd ~/Documents/repos/superset-sh-superset +bun run build +``` + +- [ ] **Step 11.2: App'i Applications'a kopyala** + +```bash +# Superset.app build output'ta olmalı +ls apps/desktop/release/mac/Superset.app +cp -r apps/desktop/release/mac/Superset.app /Applications/ +``` + +- [ ] **Step 11.3: Taint mevcut daemon (stale simulate)** + +Manuel: Uyku moduna al, uyandır VE/VEYA Fast User Switch yap. + +Alternatif (test için): Mevcut terminal-host process'i hayatta tut, Superset'i kapat-aç. Fresh-spawn path'i yine de aktif olmalı. + +- [ ] **Step 11.4: Superset'i aç, yeni terminal aç** + +- [ ] **Step 11.5: Terminal'de test et** + +```bash +# Yeni terminalde: +gh auth status +``` + +Expected: Auth status çıktısı, TLS hatası YOK. + +- [ ] **Step 11.6: Trustd erişimini doğrula** + +```bash +security list-keychains +``` + +Expected: Keychain listesi çıktısı. `SecKeychainCopySearchList: ...` hatası YOK. + +- [ ] **Step 11.7: Logs'u kontrol et** + +```bash +# Fresh-spawn log'unu ara +tail -f ~/Library/Logs/Superset/*.log | grep fresh-spawn +``` + +Expected: `[fresh-spawn] server started` ve spawn denemelerinde success log'ları. + +- [ ] **Step 11.8: Markdown raporu** + +Doc yaz: `apps/desktop/e2e/fresh-spawn-new-terminal.md` — test steps + results + screenshots. + +- [ ] **Step 11.9: Commit test doc** + +```bash +git add apps/desktop/e2e/ +git commit -m "test: e2e manual verification for new-terminal fresh spawn + +Documents verification steps and expected outputs for +gh auth status and security list-keychains working in fresh terminals. +Refs #2570" +``` + +--- + +## Task 12: fresh-exec Helper Binary Skeleton + +**Files:** +- Create: `apps/desktop/src/main/fresh-spawn/fresh-exec.ts` +- Create: `apps/desktop/src/main/fresh-spawn/fresh-exec.test.ts` + +**Amaç:** Küçük Node script. Shell preexec hook'u `fresh-exec ` olarak çağırır. Bu script Electron main UDS'ine bağlanır, fresh-exec RPC yapar. + +- [ ] **Step 12.1: Failing test — parses argv and calls client** + +```typescript +// apps/desktop/src/main/fresh-spawn/fresh-exec.test.ts +import { describe, it, expect } from "bun:test"; +import { parseFreshExecArgv } from "./fresh-exec"; + +describe("fresh-exec argv parsing", () => { + it("extracts command and args", () => { + const result = parseFreshExecArgv([ + "fresh-exec", + "gh", + "auth", + "login", + ]); + expect(result.command).toBe("gh"); + expect(result.args).toEqual(["auth", "login"]); + }); + + it("handles no args", () => { + const result = parseFreshExecArgv(["fresh-exec", "gh"]); + expect(result.command).toBe("gh"); + expect(result.args).toEqual([]); + }); + + it("throws on missing command", () => { + expect(() => parseFreshExecArgv(["fresh-exec"])).toThrow(); + }); +}); +``` + +- [ ] **Step 12.2: Test fail** + +- [ ] **Step 12.3: Implementation (entry + argv parse)** + +```typescript +// apps/desktop/src/main/fresh-spawn/fresh-exec.ts +import { sendSpawnRequest } from "./spawn-client"; +import { receiveFds } from "./fd-passing"; +import { DEFAULT_SOCKET_PATH, DEFAULT_TOKEN_PATH } from "./types"; +import * as tty from "node:tty"; + +export interface FreshExecInvocation { + command: string; + args: string[]; +} + +export function parseFreshExecArgv(argv: string[]): FreshExecInvocation { + if (argv.length < 2) { + throw new Error("fresh-exec: missing command argument"); + } + return { + command: argv[1], + args: argv.slice(2), + }; +} + +async function main(): Promise { + try { + const { command, args } = parseFreshExecArgv(process.argv.slice(1)); + const cols = process.stdout.isTTY ? process.stdout.columns ?? 80 : 80; + const rows = process.stdout.isTTY ? process.stdout.rows ?? 24 : 24; + + const resp = await sendSpawnRequest({ + socketPath: DEFAULT_SOCKET_PATH, + tokenPath: DEFAULT_TOKEN_PATH, + request: { + type: "fresh-exec", + command, + args, + cwd: process.cwd(), + env: process.env as Record, + ptyCols: cols, + ptyRows: rows, + }, + timeoutMs: 5000, + }); + + if (resp.type !== "ok") { + console.error(`fresh-exec: ${resp.code}: ${resp.message}`); + return 1; + } + + // Task 14'te PTY bridging burada implement edilecek + console.error("fresh-exec: PTY bridging not yet implemented"); + return 1; + } catch (err) { + console.error(`fresh-exec error: ${err}`); + return 1; + } +} + +if (require.main === module) { + main().then((code) => process.exit(code)); +} +``` + +- [ ] **Step 12.4: Test pass** + +```bash +bun test src/main/fresh-spawn/fresh-exec.test.ts +``` + +- [ ] **Step 12.5: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/fresh-exec.ts apps/desktop/src/main/fresh-spawn/fresh-exec.test.ts +git commit -m "feat(fresh-spawn): fresh-exec helper skeleton + +Parses argv, connects to spawn server. PTY bridging TODO (Task 14). +Refs #2570" +``` + +--- + +## Task 13: `fresh-exec` RPC Handler + +**Files:** +- Create: `apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.ts` +- Create: `apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.test.ts` +- Modify: `apps/desktop/src/main/fresh-spawn/spawn-server.ts` + +**Amaç:** Server `fresh-exec` RPC aldığında, node-pty kullanarak fresh PTY allocate et ve komutu onunla spawn et. Master PTY FD'sini client'a SCM_RIGHTS ile pass et. + +- [ ] **Step 13.1: Failing test — handler spawns command in PTY** + +```typescript +// apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.test.ts +import { describe, it, expect } from "bun:test"; +import { handleFreshExec } from "./fresh-exec"; + +describe("handleFreshExec", () => { + it("spawns command in PTY, returns master FD", async () => { + const result = await handleFreshExec({ + command: "echo", + args: ["hello"], + cwd: "/tmp", + env: process.env as Record, + ptyCols: 80, + ptyRows: 24, + }); + + expect(result.pid).toBeGreaterThan(0); + expect(result.masterFd).toBeGreaterThan(0); + expect(result.close).toBeTypeOf("function"); + + // Wait for child exit + await new Promise((resolve) => setTimeout(resolve, 200)); + result.close(); + }); +}); +``` + +- [ ] **Step 13.2: Test fail** + +- [ ] **Step 13.3: Implementation** + +```typescript +// apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.ts +import * as pty from "node-pty"; + +export interface HandleFreshExecOptions { + command: string; + args: string[]; + cwd: string; + env: Record; + ptyCols: number; + ptyRows: number; +} + +export interface HandleFreshExecResult { + pid: number; + masterFd: number; + close: () => void; +} + +export async function handleFreshExec( + options: HandleFreshExecOptions, +): Promise { + const ptyProcess = pty.spawn(options.command, options.args, { + name: "xterm-256color", + cols: options.ptyCols, + rows: options.ptyRows, + cwd: options.cwd, + env: options.env, + }); + + const handle = ptyProcess as unknown as { _fd?: number; fd?: number }; + const masterFd = handle._fd ?? handle.fd; + if (masterFd == null) { + ptyProcess.kill(); + throw new Error("pty master FD unavailable"); + } + + return { + pid: ptyProcess.pid, + masterFd, + close: () => ptyProcess.kill(), + }; +} +``` + +- [ ] **Step 13.4: Server'a handler ekle** + +```typescript +// apps/desktop/src/main/fresh-spawn/spawn-server.ts +// After auth verification: + +if (result.data.type === "fresh-exec") { + try { + const spawnResult = await handleFreshExec({ + command: result.data.command, + args: result.data.args, + cwd: result.data.cwd, + env: result.data.env, + ptyCols: result.data.ptyCols, + ptyRows: result.data.ptyRows, + }); + + sendResponse(client, { type: "ok", pid: spawnResult.pid }); + await sendFds(client, [spawnResult.masterFd]); + + client.on("close", () => spawnResult.close()); + } catch (err) { + sendResponse(client, { + type: "error", + message: String(err), + code: "E_FRESH_EXEC", + }); + client.destroy(); + } +} +``` + +- [ ] **Step 13.5: Test pass** + +```bash +bun test src/main/fresh-spawn/handlers/fresh-exec.test.ts +``` + +- [ ] **Step 13.6: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.ts apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.test.ts apps/desktop/src/main/fresh-spawn/spawn-server.ts +git commit -m "feat(fresh-spawn): handle fresh-exec RPC + +Allocates PTY via node-pty, spawns command with given cwd/env. +Sends master FD to client via SCM_RIGHTS. Refs #2570" +``` + +--- + +## Task 14: PTY Bridging in fresh-exec + +**Files:** +- Modify: `apps/desktop/src/main/fresh-spawn/fresh-exec.ts` + +**Amaç:** fresh-exec, server'dan aldığı master PTY FD'sini kendi stdin/stdout'una bridge et. Kullanıcı interaktif komutları normal çalıştırabilsin. + +- [ ] **Step 14.1: Failing test — bidirectional I/O bridging** + +Bu test gerçekten interactive olduğu için sadece integration testinde doğrulanır. Unit test yerine manual scenario yeterli. + +- [ ] **Step 14.2: Bridging implementation** + +```typescript +// apps/desktop/src/main/fresh-spawn/fresh-exec.ts +// Replace the "TODO bridging" block in main(): + +import * as net from "node:net"; + +async function bridgePtyToStdio(masterFd: number): Promise { + // Set stdin to raw mode for full keystroke forwarding + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + + const masterSocket = new net.Socket({ fd: masterFd }); + + // master (fresh child's PTY) → process.stdout + masterSocket.pipe(process.stdout); + + // process.stdin → master (fresh child's PTY) + process.stdin.pipe(masterSocket); + + // Wait for master to close (child exited) + return new Promise((resolve) => { + masterSocket.once("close", () => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.unpipe(masterSocket); + masterSocket.unpipe(process.stdout); + // TODO: determine exit code via pty.on('exit') — Task 15 + resolve(0); + }); + }); +} +``` + +- [ ] **Step 14.3: main()'de bridgePtyToStdio çağır** + +```typescript +// Replace the "PTY bridging not yet implemented" block: + +if (resp.type === "ok") { + const [masterFd] = await receiveFds(DEFAULT_SOCKET_PATH, 1); + const exitCode = await bridgePtyToStdio(masterFd); + return exitCode; +} +``` + +- [ ] **Step 14.4: Manual test (kendi terminalinde)** + +```bash +cd apps/desktop +bun run build +# Test olarak doğrudan fresh-exec'i çağır +./dist/main/fresh-spawn/fresh-exec.js ls +``` + +Expected: `ls` çıktısı görünür. + +- [ ] **Step 14.5: Commit** + +```bash +git add apps/desktop/src/main/fresh-spawn/fresh-exec.ts +git commit -m "feat(fresh-exec): bridge PTY master to stdin/stdout + +Raw mode on stdin, bidirectional pipe to PTY master FD. +Interactive commands (gh auth login, ssh) now work from fresh context. +Refs #2570" +``` + +--- + +## Task 15: Signal Forwarding + +**Files:** +- Modify: `apps/desktop/src/main/fresh-spawn/fresh-exec.ts` +- Modify: `apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.ts` + +**Amaç:** Ctrl+C fresh child'a ulaşsın. SIGWINCH (resize) forward olsun. + +- [ ] **Step 15.1: fresh-exec'e signal handler ekle** + +```typescript +// fresh-exec.ts — inside bridgePtyToStdio + +process.on("SIGINT", () => { + // PTY raw mode zaten kullanıcının ^C'yi child'a yolluyor olmalı + // Extra safety: signal forward + // Note: Kill direct signal to server-side child via separate RPC — Task 15.3 +}); + +process.on("SIGWINCH", () => { + if (process.stdout.isTTY) { + // TODO: server'a resize RPC (Task 15.4) + } +}); +``` + +- [ ] **Step 15.2: Resize RPC design (protocol extension)** + +Spec'te geçmiyor ama gerekli. Şimdilik basit yaklaşım: fresh-exec, server'a ikinci socket aç, control channel üzerinden resize mesaj yolla. + +Alternatif: stdin'i pipe ederken özel escape sequence'lerle resize bilgisi göm — ama bu kırılgan. + +**Tercih:** İlk versiyonda resize DESTEKSIZ. Kullanıcı yeni terminal açarsa fresh-spawn ile başlayacağı için yeterli. v1.1'de resize eklenir. + +- [ ] **Step 15.3: Commit (signal TODO accepted for v1)** + +```bash +git add apps/desktop/src/main/fresh-spawn/fresh-exec.ts +git commit -m "feat(fresh-exec): SIGINT pass-through via raw mode + +Ctrl+C works via raw stdin → PTY master. SIGWINCH deferred to v1.1. +Refs #2570" +``` + +--- + +## Task 16: zsh Preexec Hook Script + +**Files:** +- Create: `apps/desktop/resources/shell-hooks/zsh-fresh-exec.zsh` + +**Amaç:** Whitelist'teki komutlar yazıldığında fresh-exec'e yönlendir. + +- [ ] **Step 16.1: Script yaz** + +```zsh +# apps/desktop/resources/shell-hooks/zsh-fresh-exec.zsh +# Source'd by user's .zshrc (Task 17). +# Intercepts whitelisted commands and re-runs them via fresh-exec +# to bypass stale Mach context in the current terminal. + +SUPERSET_FRESH_EXEC_COMMANDS=(gh terraform kubectl tofu terragrunt) +SUPERSET_FRESH_EXEC_BIN="${SUPERSET_FRESH_EXEC_BIN:-/Applications/Superset.app/Contents/Resources/app.asar.unpacked/bin/fresh-exec}" + +_superset_fresh_exec_should_intercept() { + local cmd="$1" + local first="${cmd%% *}" + local base="${first:t}" + + for whitelist in $SUPERSET_FRESH_EXEC_COMMANDS; do + if [[ "$base" == "$whitelist" ]]; then + return 0 + fi + done + return 1 +} + +_superset_fresh_exec_preexec() { + local cmd="$1" + + # Skip if fresh-exec binary missing + if [[ ! -x "$SUPERSET_FRESH_EXEC_BIN" ]]; then + return + fi + + # Skip if already in fresh-exec context + if [[ -n "$SUPERSET_FRESH_EXEC_ACTIVE" ]]; then + return + fi + + if _superset_fresh_exec_should_intercept "$cmd"; then + # Replace the current command with fresh-exec wrapper. + # We use zle to rewrite BUFFER; requires preexec to run inside zle. + # For non-zle contexts, just print a warning. + print -u2 "[superset] Routing \"$cmd\" through fresh-exec for Mach context isolation" + fi +} + +# Zsh preexec runs just before each command executes. +autoload -Uz add-zsh-hook +add-zsh-hook preexec _superset_fresh_exec_preexec +``` + +NOT: Actual rewrite zor çünkü preexec komut çalıştırıldıktan sonra çağrılır. Daha doğru yaklaşım: function/alias override. + +- [ ] **Step 16.2: Function override yaklaşımına geç** + +```zsh +# apps/desktop/resources/shell-hooks/zsh-fresh-exec.zsh (revised) + +SUPERSET_FRESH_EXEC_COMMANDS=(gh terraform kubectl tofu terragrunt) +SUPERSET_FRESH_EXEC_BIN="${SUPERSET_FRESH_EXEC_BIN:-/Applications/Superset.app/Contents/Resources/app.asar.unpacked/bin/fresh-exec}" + +# Skip if fresh-exec unavailable +if [[ ! -x "$SUPERSET_FRESH_EXEC_BIN" ]]; then + return 0 +fi + +# Skip if we're already running under fresh-exec +if [[ -n "$SUPERSET_FRESH_EXEC_ACTIVE" ]]; then + return 0 +fi + +for _superset_cmd in $SUPERSET_FRESH_EXEC_COMMANDS; do + # Define a shell function with the same name, shadowing the binary. + # When called, it forwards args through fresh-exec. + eval " + function $_superset_cmd() { + if [[ -x \"\$SUPERSET_FRESH_EXEC_BIN\" ]]; then + SUPERSET_FRESH_EXEC_ACTIVE=1 \"\$SUPERSET_FRESH_EXEC_BIN\" $_superset_cmd \"\$@\" + else + command $_superset_cmd \"\$@\" + fi + } + " +done + +unset _superset_cmd +``` + +- [ ] **Step 16.3: Manual test** + +```bash +# Test locally: +source apps/desktop/resources/shell-hooks/zsh-fresh-exec.zsh +which gh +# Should say: "gh is a shell function" +``` + +- [ ] **Step 16.4: Commit** + +```bash +git add apps/desktop/resources/shell-hooks/ +git commit -m "feat(shell-hooks): zsh function overrides for whitelisted commands + +Shadows gh, terraform, kubectl, tofu, terragrunt as shell functions +that forward args through fresh-exec. Bypass via command . +Refs #2570" +``` + +--- + +## Task 17: Shell Wrapper Injection (ZDOTDIR Pattern) + +**Files:** +- Modify: `apps/desktop/src/main/terminal-host/shell-wrappers.ts` (existing file) + +**Amaç:** Superset'in PTY spawn ettiği shell'ler otomatik olarak fresh-exec hook'unu source etsin — kullanıcının `.zshrc`'sine dokunmadan. + +- [ ] **Step 17.1: Mevcut shell-wrappers.ts incele** + +```bash +cd ~/Documents/repos/superset-sh-superset +cat apps/desktop/src/main/terminal-host/shell-wrappers.ts | head -80 +``` + +Mevcut implementation ZDOTDIR pattern'i zaten var olabilir (SHELLS_WITH_READY_MARKER görüldü). + +- [ ] **Step 17.2: Mevcut shell-wrappers ile integrate et** + +Mevcut shell rcfile'ının sonuna source satırı ekle: + +```typescript +// apps/desktop/src/main/terminal-host/shell-wrappers.ts +// (modify existing generateZshRc() or equivalent) + +function generateZshRc(userHome: string): string { + const existingContent = /* ... existing code ... */; + const freshExecHook = path.join( + app.getAppPath(), + "resources", + "shell-hooks", + "zsh-fresh-exec.zsh", + ); + return `${existingContent} + +# Superset: route whitelisted commands through fresh-exec +# Bypass with: command , or unset function +[[ -f "${freshExecHook}" ]] && source "${freshExecHook}" +`; +} +``` + +- [ ] **Step 17.3: Testleri güncelle** + +Existing session.test.ts'deki zsh rcfile assertion'ları güncel test ile ekle. + +- [ ] **Step 17.4: Commit** + +```bash +git add apps/desktop/src/main/terminal-host/shell-wrappers.ts apps/desktop/src/main/terminal-host/session.test.ts +git commit -m "feat(shell-wrappers): source fresh-exec hook in zsh rcfile + +Superset-managed zsh sessions now automatically load the fresh-exec +intercept for whitelisted commands. User's own .zshrc untouched. +Refs #2570" +``` + +--- + +## Task 18: Whitelist Config + +**Files:** +- Create: `apps/desktop/src/shared/fresh-spawn-whitelist.ts` + +**Amaç:** Whitelist'i single source of truth yap — hem shell hook hem UI (v1.1 için Settings) aynı listeyi kullansın. + +- [ ] **Step 18.1: Create config** + +```typescript +// apps/desktop/src/shared/fresh-spawn-whitelist.ts +/** + * Commands routed through fresh-exec in stale terminal sessions. + * These are Go CLIs that rely on trustd via Security.framework. + * + * Keep sorted. Do not add interactive TUI apps (vim, less, etc.). + */ +export const FRESH_EXEC_WHITELIST: readonly string[] = [ + "gh", + "kubectl", + "terraform", + "terragrunt", + "tofu", +] as const; +``` + +- [ ] **Step 18.2: Shell hook'u generate eden kod bu listeyi kullansın** + +```typescript +// apps/desktop/src/main/terminal-host/shell-wrappers.ts +import { FRESH_EXEC_WHITELIST } from "../../shared/fresh-spawn-whitelist"; + +function generateZshRc(/* ... */): string { + // Inline whitelist into the source'd script + return `${existingContent} + +SUPERSET_FRESH_EXEC_COMMANDS=(${FRESH_EXEC_WHITELIST.join(" ")}) +[[ -f "${freshExecHook}" ]] && source "${freshExecHook}" +`; +} +``` + +- [ ] **Step 18.3: Commit** + +```bash +git add apps/desktop/src/shared/fresh-spawn-whitelist.ts apps/desktop/src/main/terminal-host/shell-wrappers.ts +git commit -m "feat(fresh-spawn): central whitelist for fresh-exec commands + +Single source of truth in shared/. Shell hook interpolates from config. +Future: UI editing in Settings (v1.1). Refs #2570" +``` + +--- + +## Task 19: E2E — Eski Terminal `gh auth login` + +**Files:** +- Create: `apps/desktop/e2e/fresh-spawn-old-terminal.md` + +- [ ] **Step 19.1: Setup** + +```bash +# 1. Build + install Superset (Task 11.1-11.2 repeat) +bun run build +cp -r apps/desktop/release/mac/Superset.app /Applications/ +``` + +- [ ] **Step 19.2: Pre-taint scenario** + +Superset'i aç, dev server başlat: +```bash +# Terminal 1 (Superset içinde): +python3 -m http.server 9999 +``` + +Superset'i kapat, taint simulate (Fast User Switch veya manual trustd kick): +```bash +# Warp'tan (dış terminal): +sudo launchctl kickstart -k system/com.apple.trustd +``` + +- [ ] **Step 19.3: Superset'i yeniden aç** + +Superset'i aç. Eski terminal 1'i restore etmeli. Python server hâlâ çalışmalı (http://localhost:9999 erişilir olmalı). + +- [ ] **Step 19.4: Eski terminal 1'de gh test** + +Terminal 1'de: +```bash +gh auth status +``` + +Expected: Shell function intercept → fresh-exec → fresh spawn → PASS. + +- [ ] **Step 19.5: Interactive test** + +Terminal 1'de: +```bash +gh auth login +``` + +Expected: Browser açılır, kullanıcı login olur, token kaydedilir. Eski terminal'in TTY'sine I/O doğru aktarılıyor. + +- [ ] **Step 19.6: Dev server kontrol** + +```bash +curl http://localhost:9999 +``` + +Expected: Python server hâlâ çalışıyor (ölmedi). + +- [ ] **Step 19.7: Doc** + +```bash +apps/desktop/e2e/fresh-spawn-old-terminal.md — test steps + results. +``` + +- [ ] **Step 19.8: Commit** + +```bash +git add apps/desktop/e2e/fresh-spawn-old-terminal.md +git commit -m "test: e2e verification for old-terminal gh auth via fresh-exec + +Validates shell wrapper intercept + PTY bridging. Dev server survival +confirmed. Refs #2570" +``` + +--- + +## Task 20: Cross-Platform Guards + +**Files:** +- Audit: All fresh-spawn files + +**Amaç:** Non-macOS platformlarda her şey no-op olsun, zero regression. + +- [ ] **Step 20.1: Grep for platform checks** + +```bash +cd apps/desktop +grep -rn "darwin\|process.platform" src/main/fresh-spawn/ src/main/terminal-host/fresh-spawn-integration.ts +``` + +Her entry'de uygun `process.platform !== "darwin"` guard'ı olmalı. + +- [ ] **Step 20.2: Test Linux/Windows no-op behavior** + +Bu CI'da otomatik test edilecek (Task 22). Manuel değil. + +- [ ] **Step 20.3: Commit (any fixes)** + +--- + +## Task 21: Metrics + Warn Logging + +**Files:** +- Modify: Multiple (add log statements) + +**Amaç:** Fresh-spawn kullanım metrics'i görünür olsun. Fallback'e düşünce warn log'la. + +- [ ] **Step 21.1: Log policy** + +- Fresh-spawn server start: `info` +- Fresh-spawn spawn request success: debug +- Fresh-spawn fallback: `warn` (kullanıcıya görünür) +- Fresh-spawn exception: `error` + +- [ ] **Step 21.2: Implement logging** + +Electron main'de: +```typescript +import log from "electron-log"; // assume already in repo + +log.info("[fresh-spawn] server started on", DEFAULT_SOCKET_PATH); +log.warn("[fresh-spawn] falling back to stale spawn:", err); +``` + +- [ ] **Step 21.3: Commit** + +```bash +git commit -am "feat(fresh-spawn): structured logging for visibility + +info for lifecycle, debug for per-request, warn for fallback, +error for exceptions. Refs #2570" +``` + +--- + +## Task 22: Full Type-Check + Biome Lint + +- [ ] **Step 22.1: Type check** + +```bash +cd apps/desktop +bun run typecheck +``` + +Expected: Zero errors. Varsa fix et. + +- [ ] **Step 22.2: Lint + format** + +```bash +cd ~/Documents/repos/superset-sh-superset +bun run lint:fix +bun run format +``` + +- [ ] **Step 22.3: Test all** + +```bash +cd apps/desktop +bun test +``` + +Expected: All green. + +- [ ] **Step 22.4: Commit any style fixes** + +```bash +git commit -am "chore: typecheck + lint + format + +Refs #2570" +``` + +--- + +## Task 23: Local Superset Build + Full Manual E2E + +- [ ] **Step 23.1: Clean build** + +```bash +cd apps/desktop +bun run clean +bun install +bun run build +``` + +- [ ] **Step 23.2: Install** + +```bash +cp -r release/mac/Superset.app /Applications/ +``` + +- [ ] **Step 23.3: Full scenario test** + +Complete E2E: +1. Open Superset +2. Terminal 1: `python3 -m http.server 9999` (long-running) +3. Terminal 2: `gh auth status` → PASS (new terminal fresh) +4. Exit Superset +5. Taint: `sudo launchctl kickstart -k system/com.apple.trustd` +6. Reopen Superset +7. Terminal 1 still showing `python3 server running`, hit curl localhost:9999 → PASS +8. Terminal 1: `gh auth status` → PASS (via fresh-exec) +9. Terminal 1: `gh pr list` → PASS +10. Terminal 3 (new): `gh auth status` → PASS +11. Kill fresh-spawn server: `pkill -f "fresh-spawn"` +12. Terminal 4 (new): `gh auth status` → STALE fail (fallback), but no crash +13. Logs show warn: `[fresh-spawn] falling back to stale spawn` + +- [ ] **Step 23.4: Document results** + +```bash +apps/desktop/e2e/fresh-spawn-full-manual.md +``` + +- [ ] **Step 23.5: Commit** + +```bash +git add apps/desktop/e2e/ +git commit -m "test: full manual E2E covers happy path + fallback + +Refs #2570" +``` + +--- + +## Task 24: PR + Issue Comment + +**Files:** +- External: PR on superset-sh/superset + +- [ ] **Step 24.1: Push branch** + +```bash +cd ~/Documents/repos/superset-sh-superset +git push origin feat/fresh-mach-context-spawn +``` + +- [ ] **Step 24.2: PR body (hazırla)** + +```markdown +# fix(desktop): spawn PTY subprocesses via Electron main to avoid stale Mach context on macOS + +Closes #2570. Supersedes #2571. + +## Problem +[Referans #2570'teki açıklama — copy-paste] + +## Why not #2571? +PR #2571 kills all running terminal sessions on every app restart — +dev servers, builds, and long-running processes are destroyed as +collateral. This is unacceptable for a tool whose promise is terminal persistence. + +## Solution +- Spawn PTY subprocesses from Electron main (always fresh Mach context) + rather than from terminal-host daemon (which may be stale). +- FD passing via SCM_RIGHTS so terminal-host still owns session I/O + without needing to fork child processes. +- Shell wrapper intercepts whitelisted commands (`gh`, `terraform`, ...) + in old terminals and routes them through fresh-exec helper — + old terminals keep working, running processes survive, and tools + requiring trustd gain fresh context on demand. + +## Testing +- Unit tests for all new modules +- E2E manual tests documented in `apps/desktop/e2e/fresh-spawn-*.md` +- Verified `gh auth login` works in both new and old terminals +- Verified `python3 -m http.server` survives full Superset restart + and trust daemon taint + +## Fallback +Non-macOS: no-op (existing behavior). Electron unavailable or any +fresh-spawn error: falls back to existing stale spawn with warn log. +Zero regression risk. + +## Risk +- Adds UDS dependency (`node-unix-socket` — or native addon fallback) +- Shell wrapper modifies user's PTY env via managed rcfile (existing pattern) +- FD lifecycle: server tracks child, client references streams; cleaned + up on socket close. +``` + +- [ ] **Step 24.3: PR aç** + +```bash +gh pr create --repo superset-sh/superset \ + --title "fix(desktop): spawn PTY subprocesses via Electron main to avoid stale Mach context on macOS" \ + --body "$(cat pr-body.md)" \ + --base main \ + --head Haknt:feat/fresh-mach-context-spawn +``` + +- [ ] **Step 24.4: Issue #2570'e comment at** + +```bash +gh issue comment 2570 --repo superset-sh/superset --body "Alternative approach in : spawns via Electron main (fresh) rather than killing sessions. Supersedes #2571. Full details in PR description." +``` + +- [ ] **Step 24.5: PR #2571'e comment at (nazikçe)** + +```bash +gh pr comment 2571 --repo superset-sh/superset --body "I've opened with an alternative approach that fixes the same root cause without killing running sessions. Thanks for the initial work — the analysis in this PR helped clarify the problem." +``` + +--- + +## Self-Review + +Plan tamamlandı. Spec'e karşı kontrol: + +- **Yeni terminaller fresh spawn**: Tasks 3-11 ✅ +- **Eski terminaller shell wrapper**: Tasks 12-19 ✅ +- **Fallback**: Task 10.3, Task 20 ✅ +- **E2E coverage**: Tasks 11, 19, 23 ✅ +- **Spec'deki "Open Questions"**: + - FD passing lib: Task 3 spike + - Kullanıcı .zshrc conflict: Task 17 (ZDOTDIR pattern) + - fresh-exec env: Task 13 (user env forwarded) + - Whitelist editable: Task 18 (static in v1) + +**Placeholder scan:** Grep'te `TODO` / `TBD` bulunanlar: +- Task 15.2: SIGWINCH v1'de deferred — kabul edildi, open question değil, karar +- Task 9.1: Pipe helper boş — fix'lenmiş (net.Socket.pair alternatifi) + +**Type consistency:** +- `MachPortHandle` tipik spec'teydi; revize tasarımda kaldırıldı ✅ +- `SpawnRequest`, `SpawnResponse` tutarlı ✅ +- `startSpawnServer`, `sendSpawnRequest` signatures her yerde aynı ✅ + +--- + +## Kaynaklar + +- Spec: `apps/desktop/docs/fresh-mach-context-design.md` +- Issue: https://github.com/superset-sh/superset/issues/2570 +- PR #2571 (we supersede): https://github.com/superset-sh/superset/pull/2571 diff --git a/apps/desktop/src/main/fresh-spawn/auth.test.ts b/apps/desktop/src/main/fresh-spawn/auth.test.ts new file mode 100644 index 00000000000..376082d5ee2 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/auth.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { generateTokenFile, readTokenFile, verifyToken } from "./auth"; + +const createdPaths: string[] = []; + +function tempTokenPath(): string { + const suffix = crypto.randomBytes(8).toString("hex"); + const p = path.join(os.tmpdir(), `fresh-spawn-auth-${suffix}.token`); + createdPaths.push(p); + return p; +} + +afterEach(() => { + while (createdPaths.length > 0) { + const p = createdPaths.pop(); + if (p !== undefined) { + fs.rmSync(p, { force: true }); + } + } +}); + +describe("fresh-spawn auth", () => { + describe("generateTokenFile", () => { + it("creates a file at the given path with 0600 mode", () => { + const tokenPath = tempTokenPath(); + generateTokenFile(tokenPath); + + expect(fs.existsSync(tokenPath)).toBe(true); + const stats = fs.statSync(tokenPath); + // Mask the file-type bits, keep only permission bits. + const mode = stats.mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("generates a token with at least 43 chars (base64url of 32 bytes)", () => { + const tokenPath = tempTokenPath(); + const token = generateTokenFile(tokenPath); + + expect(token.length).toBeGreaterThanOrEqual(43); + }); + }); + + describe("readTokenFile", () => { + it("returns the same value as generateTokenFile", () => { + const tokenPath = tempTokenPath(); + const written = generateTokenFile(tokenPath); + + const read = readTokenFile(tokenPath); + expect(read).toBe(written); + }); + }); + + describe("verifyToken", () => { + it("returns true for equal strings", () => { + const token = "abc123xyz"; + expect(verifyToken(token, token)).toBe(true); + }); + + it("returns false for different strings of the same length", () => { + expect(verifyToken("abc123xyz", "xyz321cba")).toBe(false); + }); + + it("returns false for length mismatch without throwing", () => { + expect(() => verifyToken("short", "much-longer-token")).not.toThrow(); + expect(verifyToken("short", "much-longer-token")).toBe(false); + }); + }); +}); diff --git a/apps/desktop/src/main/fresh-spawn/auth.ts b/apps/desktop/src/main/fresh-spawn/auth.ts new file mode 100644 index 00000000000..061415a4e8a --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/auth.ts @@ -0,0 +1,43 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * Generate a cryptographically random token, write to path with 0600 mode, + * and return the token string. + */ +export function generateTokenFile(tokenPath: string): string { + const token = crypto.randomBytes(32).toString("base64url"); + fs.mkdirSync(path.dirname(tokenPath), { recursive: true }); + fs.writeFileSync(tokenPath, token, { mode: 0o600 }); + // writeFileSync's `mode` option only applies when the file is created; + // if the token path already exists with looser permissions, the mode + // silently stays at the old value. chmod enforces 0600 regardless. + fs.chmodSync(tokenPath, 0o600); + return token; +} + +/** + * Read the token from disk. Throws if file missing — caller must handle. + */ +export function readTokenFile(tokenPath: string): string { + return fs.readFileSync(tokenPath, "utf8").trim(); +} + +/** + * Constant-time token comparison. Returns false on byte-length mismatch + * without timing leak. + * + * Note: compares by *byte* length, not string length. JavaScript's + * `.length` counts UTF-16 code units, but `crypto.timingSafeEqual` + * operates on byte buffers; two strings with equal `.length` can yield + * buffers of different byte lengths (multi-byte UTF-8 characters) and + * would throw ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH — crashing the daemon + * on any adversarial input. + */ +export function verifyToken(received: string, expected: string): boolean { + const receivedBuf = Buffer.from(received, "utf8"); + const expectedBuf = Buffer.from(expected, "utf8"); + if (receivedBuf.length !== expectedBuf.length) return false; + return crypto.timingSafeEqual(receivedBuf, expectedBuf); +} diff --git a/apps/desktop/src/main/fresh-spawn/fresh-exec.test.ts b/apps/desktop/src/main/fresh-spawn/fresh-exec.test.ts new file mode 100644 index 00000000000..d1f2a123ee8 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/fresh-exec.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { parseFreshExecArgv } from "./fresh-exec"; + +describe("parseFreshExecArgv", () => { + it("parses invocation via shell function (argv[0] is fresh-exec)", () => { + const result = parseFreshExecArgv(["fresh-exec", "gh", "auth", "login"]); + expect(result).toEqual({ command: "gh", args: ["auth", "login"] }); + }); + + it("parses invocation via node directly", () => { + const result = parseFreshExecArgv([ + "/usr/bin/node", + "/path/to/fresh-exec.js", + "terraform", + "apply", + ]); + expect(result).toEqual({ command: "terraform", args: ["apply"] }); + }); + + it("handles command with no args", () => { + const result = parseFreshExecArgv(["fresh-exec", "gh"]); + expect(result).toEqual({ command: "gh", args: [] }); + }); + + it("throws on missing command (only fresh-exec itself)", () => { + expect(() => parseFreshExecArgv(["fresh-exec"])).toThrow(); + }); + + it("throws on empty argv", () => { + expect(() => parseFreshExecArgv([])).toThrow(); + }); + + it("handles a full node-invoked path with ts extension", () => { + const result = parseFreshExecArgv([ + "/usr/local/bin/node", + "/opt/app/dist/fresh-exec.js", + "ssh", + "-p", + "22", + "host.example", + ]); + expect(result).toEqual({ + command: "ssh", + args: ["-p", "22", "host.example"], + }); + }); +}); diff --git a/apps/desktop/src/main/fresh-spawn/fresh-exec.ts b/apps/desktop/src/main/fresh-spawn/fresh-exec.ts new file mode 100644 index 00000000000..9910ec3edf8 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/fresh-exec.ts @@ -0,0 +1,402 @@ +/** + * fresh-exec — invoked by zsh/bash shell wrapper in stale terminals to + * proxy commands through the fresh-spawn server running in Electron main. + * + * Usage: + * fresh-exec [args...] + * + * Connects to ~/.superset/fresh-spawn.sock, sends a fresh-exec request, + * and bridges local stdin/stdout/stderr <-> the server's PTY stream. + * + * If the fresh-spawn server is unreachable, falls back to executing the + * command directly in the current stale context. In that degraded mode + * interactive TLS-requiring tools (gh, terraform) will fail the same + * way they would without fresh-exec; the wrapper is inert. + */ + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import { readTokenFile } from "./auth"; +import { + type ClientToServerStreamFrame, + DEFAULT_SOCKET_PATH, + DEFAULT_TOKEN_PATH, + ServerToClientStreamFrameSchema, + SpawnResponseSchema, +} from "./types"; + +export interface ParsedArgv { + command: string; + args: string[]; +} + +const HANDSHAKE_TIMEOUT_MS = 5000; + +/** + * Parse argv for the fresh-exec binary. + * + * Handles two invocation shapes: + * 1. Shell-wrapper style: argv = ["fresh-exec", "gh", "auth", "login"] + * 2. node-direct style: argv = ["/usr/bin/node", "/path/fresh-exec.js", ...] + * + * Strategy: find the first argv element whose basename looks like + * `fresh-exec` (with or without a `.js`/`.ts` extension); the command + * begins at the next index. If no such element is found we fall back to + * treating argv[1..] as the command+args (handles repackaged binaries). + */ +export function parseFreshExecArgv(argv: string[]): ParsedArgv { + const freshExecIdx = argv.findIndex((a) => { + const base = a.split("/").pop() ?? a; + return ( + base === "fresh-exec" || + base === "fresh-exec.js" || + base === "fresh-exec.ts" + ); + }); + const startIdx = freshExecIdx === -1 ? 1 : freshExecIdx + 1; + if (startIdx >= argv.length) { + throw new Error("fresh-exec: missing command argument"); + } + const command = argv[startIdx]; + if (command === undefined || command.length === 0) { + throw new Error("fresh-exec: missing command argument"); + } + return { + command, + args: argv.slice(startIdx + 1), + }; +} + +interface BridgeExitInfo { + code: number | null; + signal: string | null; +} + +function getPtyDimensions(): { cols: number; rows: number } { + const cols = process.stdout.columns ?? 80; + const rows = process.stdout.rows ?? 24; + return { cols, rows }; +} + +async function connectAndHandshake( + socketPath: string, + tokenPath: string, + command: string, + args: string[], +): Promise<{ client: net.Socket; pendingBytes: string }> { + const token = readTokenFile(tokenPath); + const { cols, rows } = getPtyDimensions(); + + return new Promise<{ client: net.Socket; pendingBytes: string }>( + (resolve, reject) => { + const client = net.createConnection(socketPath); + let buffer = ""; + let settled = false; + + const timer = setTimeout(() => { + settleReject(new Error("fresh-exec handshake timeout")); + }, HANDSHAKE_TIMEOUT_MS); + + function settleResolve(payload: { + client: net.Socket; + pendingBytes: string; + }): void { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(payload); + } + + function settleReject(err: Error): void { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + client.destroy(); + } catch { + // ignore + } + reject(err); + } + + client.once("error", (err) => { + settleReject(err); + }); + + client.once("connect", () => { + const req = { + type: "fresh-exec" as const, + token, + command, + args, + cwd: process.cwd(), + env: process.env as Record, + ptyCols: cols, + ptyRows: rows, + }; + client.write(`${JSON.stringify(req)}\n`); + }); + + const onHandshakeData = (chunk: Buffer): void => { + buffer += chunk.toString("utf8"); + const newlineIdx = buffer.indexOf("\n"); + if (newlineIdx === -1) return; + + const line = buffer.slice(0, newlineIdx); + const remainder = buffer.slice(newlineIdx + 1); + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (err) { + settleReject( + new Error( + `fresh-exec invalid handshake JSON: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + return; + } + + const parseResult = SpawnResponseSchema.safeParse(parsed); + if (!parseResult.success) { + settleReject(new Error(`fresh-exec invalid SpawnResponse: ${line}`)); + return; + } + + const resp = parseResult.data; + if (resp.type === "error") { + settleReject( + new Error( + `fresh-exec server error (${resp.code}): ${resp.message}`, + ), + ); + return; + } + + client.off("data", onHandshakeData); + settleResolve({ client, pendingBytes: remainder }); + }; + client.on("data", onHandshakeData); + }, + ); +} + +function bridgeSocketToStdio( + client: net.Socket, + pendingBytes: string, +): Promise { + return new Promise((resolve, reject) => { + // Set raw mode on stdin so keystrokes (including Ctrl+C) go through as-is. + const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false; + if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + + const cleanup = (): void => { + if ( + process.stdin.isTTY && + typeof process.stdin.setRawMode === "function" + ) { + process.stdin.setRawMode(wasRaw); + } + process.stdin.pause(); + process.stdin.removeListener("data", onStdin); + process.removeListener("SIGWINCH", onWinch); + }; + + const writeFrame = (frame: ClientToServerStreamFrame): void => { + try { + client.write(`${JSON.stringify(frame)}\n`); + } catch { + // server may have closed + } + }; + + const onStdin = (chunk: Buffer): void => { + writeFrame({ + type: "stdin", + data: chunk.toString("base64"), + }); + }; + + const onWinch = (): void => { + const { cols, rows } = getPtyDimensions(); + writeFrame({ type: "resize", cols, rows }); + }; + + process.stdin.on("data", onStdin); + process.on("SIGWINCH", onWinch); + + let lastExit: BridgeExitInfo = { code: null, signal: null }; + let sawExitFrame = false; + let buffer = pendingBytes; + + const processBuffer = (): void => { + let idx = buffer.indexOf("\n"); + while (idx !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.trim().length > 0) { + handleFrameLine(line); + } + idx = buffer.indexOf("\n"); + } + }; + + const handleFrameLine = (line: string): void => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + const result = ServerToClientStreamFrameSchema.safeParse(parsed); + if (!result.success) return; + const frame = result.data; + switch (frame.type) { + case "stdout": + process.stdout.write(Buffer.from(frame.data, "base64")); + return; + case "stderr": + process.stderr.write(Buffer.from(frame.data, "base64")); + return; + case "exit": + sawExitFrame = true; + lastExit = { + code: frame.code, + signal: frame.signal, + }; + return; + } + }; + + // Drain any bytes that arrived pipelined with the handshake response. + processBuffer(); + + client.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + processBuffer(); + }); + + client.once("close", () => { + cleanup(); + if (!sawExitFrame) { + // The server closed the connection without sending an `exit` + // frame (crash, abrupt disconnect, SIGKILL on the server + // process). Surface it to the caller — main() would otherwise + // report `exit.code ?? 0` = 0, masking the failure. + reject( + new Error( + "fresh-exec: server closed connection before sending exit frame", + ), + ); + return; + } + resolve(lastExit); + }); + client.once("error", (err) => { + cleanup(); + reject(err); + }); + }); +} + +/** + * Fallback: if fresh-spawn server unreachable, exec the command directly. + * The command runs in the stale context; for non-TLS commands this is fine. + * For TLS-requiring tools, the user gets the same error they would without + * fresh-exec wrapping them (no worse than baseline). + */ +function fallbackDirectExec(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: "inherit", + env: process.env, + cwd: process.cwd(), + }); + child.on("exit", (code, signal) => { + if (signal !== null) { + // bash semantics: 128 + signal number. Node gives signal as a name + // string; we don't have a portable signal->number mapping here, so + // use 128 + 15 (SIGTERM) as the generic fallback. + resolve(128 + 15); + return; + } + resolve(code ?? 0); + }); + child.on("error", reject); + }); +} + +export async function main(argv: string[] = process.argv): Promise { + let parsed: ParsedArgv; + try { + parsed = parseFreshExecArgv(argv); + } catch (err) { + process.stderr.write( + `${err instanceof Error ? err.message : String(err)}\n`, + ); + return 2; + } + + const socketPath = DEFAULT_SOCKET_PATH; + const tokenPath = DEFAULT_TOKEN_PATH; + const serverReachable = + process.platform === "darwin" && + fs.existsSync(socketPath) && + fs.existsSync(tokenPath); + + if (!serverReachable) { + return fallbackDirectExec(parsed.command, parsed.args); + } + + let handshake: { client: net.Socket; pendingBytes: string }; + try { + handshake = await connectAndHandshake( + socketPath, + tokenPath, + parsed.command, + parsed.args, + ); + } catch (err) { + process.stderr.write( + `[fresh-exec] fell back to direct exec (${err instanceof Error ? err.message : String(err)})\n`, + ); + return fallbackDirectExec(parsed.command, parsed.args); + } + + try { + const exit = await bridgeSocketToStdio( + handshake.client, + handshake.pendingBytes, + ); + if (exit.signal !== null) { + const signum = Number.parseInt(exit.signal, 10); + if (Number.isFinite(signum) && signum > 0) { + return 128 + signum; + } + return 128 + 15; // SIGTERM default + } + return exit.code ?? 0; + } catch (err) { + process.stderr.write( + `[fresh-exec] bridge error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + return 1; + } +} + +// Entry point — only runs if invoked directly (not imported for tests). +if (require.main === module) { + main() + .then((code) => { + process.exit(code); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`fresh-exec unexpected: ${msg}\n`); + process.exit(1); + }); +} diff --git a/apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.test.ts b/apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.test.ts new file mode 100644 index 00000000000..14e03d35280 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { type SpawnServer, startSpawnServer } from "../spawn-server"; + +// Integration tests for the fresh-exec handler. These go through the full +// spawn-server stack (schema validation → auth → dispatch → handler). +// +// NOTE on coverage: node-pty's internal tty.ReadStream interacts poorly with +// Bun's test runtime on macOS (EAGAIN on non-blocking read causes the socket +// to close before data is delivered). Because of this, we do NOT assert on +// stdout/stderr framing from the PTY inside bun test — that path is covered +// at runtime under Electron/Node where tty.ReadStream behaves correctly. +// The handler code for onData/onExit mirrors pty-subprocess.ts, which is +// exercised end-to-end in manual/E2E tests. +// +// What we CAN test reliably here: +// 1. The handler accepts the request, spawns a PTY, and writes the +// initial {type:"ok",pid} SpawnResponse. +// 2. When the client disconnects, the PTY is killed (SIGTERM → SIGKILL). +describe("fresh-exec handler (integration)", () => { + let server: SpawnServer | null = null; + let tmpDir = ""; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + tmpDir = ""; + }); + + function setup(): { + socketPath: string; + tokenPath: string; + subprocessScriptPath: string; + } { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-fresh-exec-")); + // subprocessScriptPath is required by startSpawnServer but is not used + // by the fresh-exec code path. Any existing file is fine. + const subprocessScriptPath = path.join(tmpDir, "noop.js"); + fs.writeFileSync(subprocessScriptPath, ""); + return { + socketPath: path.join(tmpDir, "s.sock"), + tokenPath: path.join(tmpDir, "s.token"), + subprocessScriptPath, + }; + } + + /** + * Connect to the UDS server, send a single authenticated NDJSON request, + * and resolve with the first response line. + */ + function sendRequest( + paths: { socketPath: string; tokenPath: string }, + req: Record, + ): Promise<{ firstFrame: Record; client: net.Socket }> { + return new Promise((resolve, reject) => { + const client = net.createConnection(paths.socketPath); + let buffer = ""; + const onError = (err: Error) => { + client.destroy(); + reject(err); + }; + client.once("error", onError); + client.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + const idx = buffer.indexOf("\n"); + if (idx === -1) return; + const line = buffer.slice(0, idx); + client.off("error", onError); + try { + resolve({ + firstFrame: JSON.parse(line) as Record, + client, + }); + } catch (err) { + reject(err); + } + }); + client.once("connect", () => { + const token = fs.readFileSync(paths.tokenPath, "utf8").trim(); + const full = { ...req, token }; + client.write(`${JSON.stringify(full)}\n`); + }); + }); + } + + it("returns ok+pid for a valid fresh-exec request", async () => { + const paths = setup(); + server = await startSpawnServer(paths); + + const { firstFrame, client } = await sendRequest(paths, { + type: "fresh-exec", + command: "/bin/sh", + args: ["-c", "sleep 5"], + cwd: "/tmp", + env: { PATH: "/usr/bin:/bin" }, + ptyCols: 80, + ptyRows: 24, + }); + + expect(firstFrame.type).toBe("ok"); + expect(typeof firstFrame.pid).toBe("number"); + expect((firstFrame.pid as number) > 0).toBe(true); + + // Clean up: disconnecting triggers the handler's SIGTERM → SIGKILL + // path on the spawned PTY. + client.destroy(); + }, 10000); + + it("kills PTY when the client disconnects", async () => { + const paths = setup(); + server = await startSpawnServer(paths); + + const { firstFrame, client } = await sendRequest(paths, { + type: "fresh-exec", + command: "/bin/sh", + args: ["-c", "sleep 30"], + cwd: "/tmp", + env: { PATH: "/usr/bin:/bin" }, + ptyCols: 80, + ptyRows: 24, + }); + + expect(firstFrame.type).toBe("ok"); + const pid = firstFrame.pid as number; + + client.destroy(); + + // Poll up to ~4s (handler SIGTERM grace is 2s before SIGKILL). + const isAlive = (targetPid: number): boolean => { + try { + process.kill(targetPid, 0); + return true; + } catch { + return false; + } + }; + let alive = true; + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isAlive(pid)) { + alive = false; + break; + } + } + expect(alive).toBe(false); + }, 15000); +}); diff --git a/apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.ts b/apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.ts new file mode 100644 index 00000000000..8b2f5baac85 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/handlers/fresh-exec.ts @@ -0,0 +1,281 @@ +import type { Socket } from "node:net"; +import type { IPty } from "node-pty"; +import * as pty from "node-pty"; +import { + type ClientToServerStreamFrame, + ClientToServerStreamFrameSchema, + type ServerToClientStreamFrame, +} from "../types"; + +export interface FreshExecRequest { + command: string; + args: string[]; + cwd: string; + env: Record; + ptyCols: number; + ptyRows: number; +} + +export interface FreshExecHandlerOptions { + /** + * Time in ms to wait for a graceful SIGTERM exit before escalating to + * SIGKILL when the client disconnects. Defaults to 2000ms. + */ + hardKillGraceMs?: number; + /** + * Bytes already read off the socket after the initial request line — e.g. + * pipelined stdin frames. Prepended to the handler's NDJSON parser so they + * are processed as the first incoming frames. + */ + initialBuffer?: string; +} + +export interface FreshExecHandle { + pid: number; + /** + * Register a callback that fires once both the PTY has exited AND the + * socket has closed. If the session has already finished, the callback + * runs immediately. + */ + onClosed(callback: () => void): void; +} + +const DEFAULT_HARD_KILL_GRACE_MS = 2000; + +/** + * Run an arbitrary command inside a fresh PTY and forward its I/O over the + * provided UDS client socket as NDJSON StreamFrames. The handler takes + * ownership of the socket: the caller must not write to it or close it. + * + * Unlike spawn-pty-subprocess (which spawns our internal pty-subprocess.js via + * child_process.spawn without a real tty), this handler uses node-pty to + * allocate a pseudoterminal — which is what interactive commands like + * `gh auth login` need. Resize frames from the client are honored here. + * + * Protocol on the socket after invocation: + * 1. First line: {type:"ok", pid} — the initial SpawnResponse. + * 2. server→client: ServerToClientStreamFrame NDJSON (stdout/exit). + * Note: PTY master merges stdout/stderr into one stream, so everything + * is framed as `stdout`. + * 3. client→server: ClientToServerStreamFrame NDJSON + * (stdin/resize/signal). + * + * Lifecycle: + * - PTY exit → emit {type:"exit"} frame, half-close the socket, finalize. + * - Client disconnect → SIGTERM the PTY; escalate to SIGKILL after + * `hardKillGraceMs` (default 2s) if still alive. + */ +export function handleFreshExec( + request: FreshExecRequest, + client: Socket, + options: FreshExecHandlerOptions = {}, +): FreshExecHandle { + const hardKillGraceMs = options.hardKillGraceMs ?? DEFAULT_HARD_KILL_GRACE_MS; + + let ptyProcess: IPty; + try { + ptyProcess = pty.spawn(request.command, request.args, { + name: "xterm-256color", + cols: request.ptyCols, + rows: request.ptyRows, + cwd: request.cwd, + env: request.env, + }); + } catch (err) { + // Surface spawn failure as a one-shot error response (SpawnResponse + // shape, not a StreamFrame) so the client sees the same error format + // as other handshake-time failures. Then close the socket. + writeHandshakeLine(client, { + type: "error", + message: `fresh-exec spawn failed: ${err instanceof Error ? err.message : String(err)}`, + code: "E_SPAWN", + }); + try { + client.end(); + } catch { + // ignore + } + return { + pid: -1, + onClosed(callback) { + try { + callback(); + } catch { + // ignore + } + }, + }; + } + + const pid = ptyProcess.pid; + const closedCallbacks: Array<() => void> = []; + let ptyExited = false; + let socketClosed = false; + let finalized = false; + + const tryFinalize = (): void => { + if (!ptyExited || !socketClosed || finalized) return; + finalized = true; + for (const cb of closedCallbacks) { + try { + cb(); + } catch { + // ignore callback errors + } + } + }; + + // Initial ok response (SpawnResponse shape, not a StreamFrame). + writeHandshakeLine(client, { type: "ok", pid }); + + // Disable idle timeout — streaming sessions can be long-lived. The server's + // handshake idle timeout only protects against half-open handshakes. + client.setTimeout(0); + + // ===================================================================== + // PTY → client (onData merges stdout+stderr; frame as "stdout") + // ===================================================================== + + ptyProcess.onData((chunk: string) => { + writeFrame(client, { + type: "stdout", + data: Buffer.from(chunk, "utf8").toString("base64"), + }); + }); + + ptyProcess.onExit(({ exitCode, signal }) => { + ptyExited = true; + writeFrame(client, { + type: "exit", + code: typeof exitCode === "number" ? exitCode : null, + // node-pty reports the signal as a number on POSIX; convert to a + // string to satisfy the schema. Undefined (normal exit) → null. + signal: typeof signal === "number" ? String(signal) : null, + }); + try { + client.end(); + } catch { + // socket may already be gone + } + tryFinalize(); + }); + + // ===================================================================== + // Client → PTY + // ===================================================================== + + let buffer = options.initialBuffer ?? ""; + const drainBuffer = (): void => { + let newlineIdx: number; + // biome-ignore lint/suspicious/noAssignInExpressions: NDJSON line extractor + while ((newlineIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIdx); + buffer = buffer.slice(newlineIdx + 1); + handleIncomingFrame(line, ptyProcess); + } + }; + // Drain any pipelined frames first. + drainBuffer(); + + client.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + drainBuffer(); + }); + + client.on("close", () => { + socketClosed = true; + if (!ptyExited) { + try { + ptyProcess.kill("SIGTERM"); + } catch { + // may already be dead + } + setTimeout(() => { + if (!ptyExited) { + try { + ptyProcess.kill("SIGKILL"); + } catch { + // ignore + } + } + }, hardKillGraceMs).unref(); + } + tryFinalize(); + }); + + client.on("error", () => { + // 'close' will still fire; swallow here to avoid unhandled errors. + }); + + return { + pid, + onClosed(callback) { + closedCallbacks.push(callback); + if (finalized) { + try { + callback(); + } catch { + // ignore + } + } + }, + }; +} + +function writeHandshakeLine( + client: Socket, + frame: + | { type: "ok"; pid: number } + | { type: "error"; message: string; code: string }, +): void { + try { + client.write(`${JSON.stringify(frame)}\n`); + } catch { + // socket may be destroyed; ignore + } +} + +function writeFrame(client: Socket, frame: ServerToClientStreamFrame): void { + try { + client.write(`${JSON.stringify(frame)}\n`); + } catch { + // socket may be destroyed; ignore + } +} + +function handleIncomingFrame(line: string, ptyProcess: IPty): void { + if (line.trim().length === 0) return; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + const result = ClientToServerStreamFrameSchema.safeParse(parsed); + if (!result.success) return; + + const frame: ClientToServerStreamFrame = result.data; + switch (frame.type) { + case "stdin": + try { + ptyProcess.write(Buffer.from(frame.data, "base64").toString("utf8")); + } catch { + // ignore — PTY may have closed between check and write + } + return; + case "signal": + try { + ptyProcess.kill(frame.name); + } catch { + // ignore invalid signal name or dead process + } + return; + case "resize": + try { + ptyProcess.resize(frame.cols, frame.rows); + } catch { + // ignore if PTY has exited + } + return; + } +} diff --git a/apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.test.ts b/apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.test.ts new file mode 100644 index 00000000000..f316f86c7da --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { type SpawnServer, startSpawnServer } from "../spawn-server"; + +describe("spawn-pty-subprocess handler", () => { + let server: SpawnServer | null = null; + let tmpDir = ""; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + tmpDir = ""; + }); + + function setupEcho(): { + socketPath: string; + tokenPath: string; + subprocessScriptPath: string; + } { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-handler-")); + const echoScriptPath = path.join(tmpDir, "echo.js"); + // Simple echo: read stdin, write to stdout, exit on EOF. + fs.writeFileSync( + echoScriptPath, + `process.stdin.on("data", (d) => process.stdout.write(d)); +process.stdin.on("end", () => process.exit(0)); +`, + ); + return { + socketPath: path.join(tmpDir, "s.sock"), + tokenPath: path.join(tmpDir, "s.token"), + subprocessScriptPath: echoScriptPath, + }; + } + + it("spawns child and streams stdout frames back", async () => { + const paths = setupEcho(); + server = await startSpawnServer(paths); + const token = fs.readFileSync(paths.tokenPath, "utf8").trim(); + + const client = net.createConnection(paths.socketPath); + await new Promise((resolve, reject) => { + client.once("error", reject); + client.once("connect", () => resolve()); + }); + client.write( + `${JSON.stringify({ + type: "spawn-pty-subprocess", + token, + env: {}, + })}\n`, + ); + + const frames: Array> = []; + await new Promise((resolve) => { + let buffer = ""; + let sentStdin = false; + let sentSignal = false; + client.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let idx: number; + // biome-ignore lint/suspicious/noAssignInExpressions: NDJSON line extractor + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.trim().length === 0) continue; + const frame = JSON.parse(line) as Record; + frames.push(frame); + + // After we see ok, send a stdin frame + if (frame.type === "ok" && !sentStdin) { + sentStdin = true; + client.write( + `${JSON.stringify({ + type: "stdin", + data: Buffer.from("hello\n").toString("base64"), + })}\n`, + ); + } + + // After we see the echoed stdout, send SIGTERM + if (frame.type === "stdout" && !sentSignal) { + sentSignal = true; + client.write( + `${JSON.stringify({ type: "signal", name: "SIGTERM" })}\n`, + ); + } + } + }); + client.once("close", () => resolve()); + }); + + // Expect at minimum: ok, stdout (echo of "hello\n"), exit + expect(frames.length).toBeGreaterThanOrEqual(3); + expect(frames[0]).toMatchObject({ type: "ok" }); + expect(typeof frames[0]?.pid).toBe("number"); + + const stdoutFrame = frames.find((f) => f.type === "stdout") as + | { type: "stdout"; data: string } + | undefined; + expect(stdoutFrame).toBeDefined(); + expect( + Buffer.from(stdoutFrame?.data ?? "", "base64").toString("utf8"), + ).toBe("hello\n"); + + const exitFrame = frames.find((f) => f.type === "exit"); + expect(exitFrame).toBeDefined(); + }, 10000); + + it("kills child on client disconnect", async () => { + const paths = setupEcho(); + server = await startSpawnServer(paths); + const token = fs.readFileSync(paths.tokenPath, "utf8").trim(); + + const client = net.createConnection(paths.socketPath); + await new Promise((resolve, reject) => { + client.once("error", reject); + client.once("connect", () => resolve()); + }); + + // Read initial ok frame to get pid, then disconnect. + const pid = await new Promise((resolve, reject) => { + let buffer = ""; + client.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const idx = buffer.indexOf("\n"); + if (idx !== -1) { + try { + const frame = JSON.parse(buffer.slice(0, idx)) as { + type: string; + pid?: number; + }; + if (frame.type === "ok" && typeof frame.pid === "number") { + resolve(frame.pid); + } else { + reject( + new Error(`expected ok frame, got: ${JSON.stringify(frame)}`), + ); + } + } catch (err) { + reject(err); + } + } + }); + client.write( + `${JSON.stringify({ + type: "spawn-pty-subprocess", + token, + env: {}, + })}\n`, + ); + }); + + // Disconnect without sending a signal — server should SIGTERM the child. + client.destroy(); + + // Poll briefly, then check that the child is gone. + const isAlive = (targetPid: number): boolean => { + try { + process.kill(targetPid, 0); // signal 0 = probe + return true; + } catch { + return false; + } + }; + // Wait up to ~3 seconds for the child to die. + let alive = true; + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isAlive(pid)) { + alive = false; + break; + } + } + expect(alive).toBe(false); + }, 10000); +}); diff --git a/apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.ts b/apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.ts new file mode 100644 index 00000000000..45a0460addc --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/handlers/spawn-pty-subprocess.ts @@ -0,0 +1,341 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import type { Socket } from "node:net"; +import { + type ClientToServerStreamFrame, + ClientToServerStreamFrameSchema, + type ServerToClientStreamFrame, +} from "../types"; + +export interface SpawnPtySubprocessHandlerOptions { + /** Path to the JS file to spawn with Electron-as-Node. Usually pty-subprocess.js. */ + subprocessScriptPath: string; + /** Path to node/electron binary to invoke. Defaults to process.execPath. */ + nodeBinaryPath?: string; + /** + * Time in ms to wait for a graceful SIGTERM exit before escalating to + * SIGKILL when the client disconnects. Defaults to 2000ms. + */ + hardKillGraceMs?: number; + /** + * Bytes that were already read off the socket by the caller after the + * initial request line — e.g. if the client pipelined a stdin frame in + * the same TCP chunk as the auth handshake. These bytes are prepended to + * the handler's internal buffer so they are parsed as the first incoming + * frames. + */ + initialBuffer?: string; +} + +export interface SpawnPtySubprocessRequest { + env: Record; +} + +export interface SpawnPtySubprocessHandle { + pid: number; + /** + * Register a callback to fire once both the child has exited AND the + * socket has closed. If the session has already finished, the callback + * runs immediately. + */ + onClosed(callback: () => void): void; +} + +const DEFAULT_HARD_KILL_GRACE_MS = 2000; + +/** + * Spawn a fresh child (pty-subprocess.js) in Electron main's fresh Mach context, + * and forward I/O bidirectionally over the provided UDS client socket as NDJSON + * StreamFrames. The handler takes ownership of the socket: the caller must not + * write to it or close it after invoking this handler. + * + * Protocol on the socket after this handler is invoked: + * 1. First line: {type:"ok", pid} — the initial SpawnResponse. + * 2. Subsequent lines (server→client): ServerToClientStreamFrame NDJSON + * (stdout/stderr/exit). + * 3. Subsequent lines (client→server): ClientToServerStreamFrame NDJSON + * (stdin/resize/signal). `resize` is ignored for this non-PTY handler; + * Task 13's fresh-exec handler will honor it. + * + * Lifecycle: + * - Child exit → emit {type:"exit"} frame, half-close the socket, finalize. + * - Client disconnect → SIGTERM the child; escalate to SIGKILL after + * `hardKillGraceMs` (default 2s) if the child is still alive. + */ +export function handleSpawnPtySubprocess( + request: SpawnPtySubprocessRequest, + client: Socket, + options: SpawnPtySubprocessHandlerOptions, +): SpawnPtySubprocessHandle { + const binaryPath = options.nodeBinaryPath ?? process.execPath; + const hardKillGraceMs = options.hardKillGraceMs ?? DEFAULT_HARD_KILL_GRACE_MS; + + // Lifecycle state must be declared BEFORE the child is spawned, because + // `child.on("error")` is attached immediately after the spawn call and + // its handler reads/writes these flags. Node may emit ENOENT + // asynchronously via process.nextTick, so installing the listener after + // the synchronous validation below would miss those early errors. + const closedCallbacks: Array<() => void> = []; + let childExited = false; + let socketClosed = false; + let finalized = false; + + const tryFinalize = (): void => { + if (!childExited || !socketClosed || finalized) return; + finalized = true; + for (const cb of closedCallbacks) { + try { + cb(); + } catch { + // ignore callback errors — lifecycle must complete + } + } + }; + + const child: ChildProcess = spawn( + binaryPath, + [options.subprocessScriptPath], + { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...request.env, + ELECTRON_RUN_AS_NODE: "1", + }, + }, + ); + + // Attached FIRST — before the synchronous child.pid/.stdin validation + // below — because Node emits `error` asynchronously (ENOENT on the + // binary path, EACCES on the subprocess script), and without a listener + // the event becomes unhandled and crashes the entire daemon. Translate + // into a synthetic exit frame so the client unblocks cleanly. + child.on("error", (err) => { + console.error( + `[fresh-spawn] spawn-pty-subprocess child error (pid=${child.pid}):`, + err, + ); + if (!childExited) { + childExited = true; + writeFrame(client, { type: "exit", code: null, signal: null }); + try { + client.end(); + } catch { + // socket may already be gone + } + tryFinalize(); + } + }); + + if (!child.stdin || !child.stdout || !child.stderr || child.pid == null) { + child.kill("SIGKILL"); + throw new Error("failed to spawn subprocess"); + } + + const pid = child.pid; + + // Write ok response as the first streamed line. Note: this frame is the + // SpawnResponse schema, NOT a StreamFrame. The client must parse the first + // line as SpawnResponse and all subsequent lines as StreamFrames. + writeRawLine(client, { type: "ok", pid }); + + // Disable idle timeout — streaming sessions can be long-lived. The server's + // handshake timeout was only meant to protect against half-open handshakes, + // not to limit session duration. + client.setTimeout(0); + + // ====================================================================== + // Server -> client direction (child stdout/stderr/exit -> UDS frames) + // ====================================================================== + + child.stdout.on("data", (chunk: Buffer) => { + const flushed = writeFrame(client, { + type: "stdout", + data: chunk.toString("base64"), + }); + if (!flushed && child.stdout) { + child.stdout.pause(); + client.once("drain", () => child.stdout?.resume()); + } + }); + + child.stderr.on("data", (chunk: Buffer) => { + const flushed = writeFrame(client, { + type: "stderr", + data: chunk.toString("base64"), + }); + if (!flushed && child.stderr) { + child.stderr.pause(); + client.once("drain", () => child.stderr?.resume()); + } + }); + + child.once("exit", (code, signal) => { + // If the `error` handler already emitted a synthetic exit frame + // (e.g. ENOENT on the binary), skip — emitting again would send a + // duplicate {type:"exit"} frame downstream. + if (childExited) { + tryFinalize(); + return; + } + childExited = true; + writeFrame(client, { + type: "exit", + code: code ?? null, + signal: signal ?? null, + }); + // Half-close: signal end-of-stream, let client flush its read side. + try { + client.end(); + } catch { + // already gone + } + tryFinalize(); + }); + + // ====================================================================== + // Client -> server direction (UDS frames -> child stdin / signals) + // ====================================================================== + + const MAX_LINE_BYTES = 1 * 1024 * 1024; + let buffer = options.initialBuffer ?? ""; + let bufferOverflow = false; + const drainBuffer = (): void => { + let newlineIdx: number; + // biome-ignore lint/suspicious/noAssignInExpressions: standard NDJSON line extractor + while ((newlineIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIdx); + buffer = buffer.slice(newlineIdx + 1); + handleIncomingFrame(line, child, client); + } + // Mirrors the handshake cap: a client that streams bytes without a + // trailing newline must not be able to grow this buffer unbounded and + // OOM the long-lived daemon hosting every PTY session. + if (!bufferOverflow && buffer.length > MAX_LINE_BYTES) { + bufferOverflow = true; + console.error( + `[fresh-spawn] spawn-pty-subprocess line exceeded ${MAX_LINE_BYTES} bytes; destroying socket (pid=${child.pid})`, + ); + try { + client.destroy(); + } catch { + // already gone + } + } + }; + // Drain any pipelined frames the caller already read off the socket. + drainBuffer(); + client.on("data", (chunk: Buffer) => { + if (bufferOverflow) return; + buffer += chunk.toString("utf8"); + drainBuffer(); + }); + + client.on("close", () => { + socketClosed = true; + if (!childExited) { + try { + child.kill("SIGTERM"); + } catch { + // may already be dead + } + // Hard kill after grace period if still alive. + setTimeout(() => { + if (!childExited) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } + }, hardKillGraceMs).unref(); + } + tryFinalize(); + }); + + client.on("error", () => { + // The 'close' handler will still fire; no action needed here. Swallowing + // the error prevents it from surfacing as an unhandled error event. + }); + + return { + pid, + onClosed(callback) { + closedCallbacks.push(callback); + if (finalized) { + try { + callback(); + } catch { + // ignore + } + } + }, + }; +} + +function writeRawLine( + client: Socket, + frame: { type: "ok"; pid: number }, +): void { + try { + client.write(`${JSON.stringify(frame)}\n`); + } catch { + // socket may be destroyed; ignore + } +} + +function writeFrame( + client: Socket, + frame: ServerToClientStreamFrame, +): boolean { + try { + return client.write(`${JSON.stringify(frame)}\n`); + } catch { + // socket may be destroyed; ignore + return false; + } +} + +function handleIncomingFrame( + line: string, + child: ChildProcess, + client: Socket, +): void { + if (line.trim().length === 0) return; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + const result = ClientToServerStreamFrameSchema.safeParse(parsed); + if (!result.success) return; + + const frame: ClientToServerStreamFrame = result.data; + switch (frame.type) { + case "stdin": + if (child.stdin && !child.stdin.destroyed) { + try { + const flushed = child.stdin.write( + Buffer.from(frame.data, "base64"), + ); + if (!flushed) { + client.pause(); + child.stdin.once("drain", () => client.resume()); + } + } catch { + // ignore — stdin may have closed between the check and write + } + } + return; + case "signal": + try { + child.kill(frame.name as NodeJS.Signals); + } catch { + // ignore invalid signal + } + return; + case "resize": + // No-op for non-PTY subprocess. Task 13's fresh-exec handler uses + // resize to forward SIGWINCH geometry updates to the PTY master. + return; + } +} diff --git a/apps/desktop/src/main/fresh-spawn/lifecycle.test.ts b/apps/desktop/src/main/fresh-spawn/lifecycle.test.ts new file mode 100644 index 00000000000..2fdbda00609 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/lifecycle.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + getFreshSpawnServerInstance, + startFreshSpawnServer, + stopFreshSpawnServer, +} from "./lifecycle"; + +describe("fresh-spawn lifecycle", () => { + afterEach(async () => { + await stopFreshSpawnServer(); + }); + + it("no-op on non-darwin platforms", async () => { + if (process.platform === "darwin") return; // skip on darwin + + // Path is required by the API but never consulted on non-darwin. + await startFreshSpawnServer({ + subprocessScriptPath: "/nonexistent/pty-subprocess.js", + }); + expect(getFreshSpawnServerInstance()).toBeNull(); + }); + + it("idempotent start (warning but no throw on duplicate start)", async () => { + if (process.platform !== "darwin") return; // darwin-only path + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-lifecycle-")); + const subprocessScriptPath = path.join(tmpDir, "noop.js"); + fs.writeFileSync( + subprocessScriptPath, + `process.stdin.on("end", () => process.exit(0));\n`, + ); + + try { + await startFreshSpawnServer({ subprocessScriptPath }); + const first = getFreshSpawnServerInstance(); + expect(first).not.toBeNull(); + + await startFreshSpawnServer({ subprocessScriptPath }); // duplicate call should not throw + expect(getFreshSpawnServerInstance()).toBe(first); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("stop clears instance", async () => { + if (process.platform !== "darwin") return; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-lifecycle-")); + const subprocessScriptPath = path.join(tmpDir, "noop.js"); + fs.writeFileSync( + subprocessScriptPath, + `process.stdin.on("end", () => process.exit(0));\n`, + ); + + try { + await startFreshSpawnServer({ subprocessScriptPath }); + expect(getFreshSpawnServerInstance()).not.toBeNull(); + await stopFreshSpawnServer(); + expect(getFreshSpawnServerInstance()).toBeNull(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/fresh-spawn/lifecycle.ts b/apps/desktop/src/main/fresh-spawn/lifecycle.ts new file mode 100644 index 00000000000..830bef7d1e4 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/lifecycle.ts @@ -0,0 +1,90 @@ +import { type SpawnServer, startSpawnServer } from "./spawn-server"; +import { DEFAULT_SOCKET_PATH, DEFAULT_TOKEN_PATH } from "./types"; + +let instance: SpawnServer | null = null; +let starting: Promise | null = null; + +/** + * Start the fresh-spawn server. Should be called once from app.whenReady(). + * No-op on non-macOS platforms (the stale-context bug is macOS-specific). + * + * The caller must supply `subprocessScriptPath` — the absolute path of the + * built `pty-subprocess.js` to spawn when a client requests a new PTY. + * Path resolution is the caller's responsibility because it depends on + * bundling topology (rollup output directory, asar layout, dev vs packaged) + * which only the main entry point knows with certainty. + * + * Idempotent: a duplicate call while the server is already running logs a + * warning and returns without starting a second instance. + * + * Never throws: any startup error is logged and swallowed so the rest of + * the app lifecycle can continue. Callers degrade via + * `trySpawnViaFreshServer`, which falls back to direct spawn when the + * socket is missing. + */ +export async function startFreshSpawnServer(options: { + subprocessScriptPath: string; +}): Promise { + if (process.platform !== "darwin") { + console.info("[fresh-spawn] non-darwin platform, server not started"); + return; + } + + if (instance) { + console.warn("[fresh-spawn] server already started, ignoring"); + return; + } + + // If a start is already in flight, piggyback on that promise so two + // awaited calls don't each attempt to bind the socket (EADDRINUSE on + // the second) and clobber each other's token file. + if (starting) { + return starting; + } + + starting = (async () => { + try { + instance = await startSpawnServer({ + socketPath: DEFAULT_SOCKET_PATH, + tokenPath: DEFAULT_TOKEN_PATH, + subprocessScriptPath: options.subprocessScriptPath, + }); + console.info( + `[fresh-spawn] server listening on ${DEFAULT_SOCKET_PATH} (spawning ${options.subprocessScriptPath})`, + ); + } catch (err) { + console.error( + `[fresh-spawn] failed to start server: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + starting = null; + } + })(); + return starting; +} + +/** + * Gracefully close the fresh-spawn server. Called from before-quit. + * Never throws; errors are logged and the instance reference is cleared + * so a subsequent start call will succeed. + */ +export async function stopFreshSpawnServer(): Promise { + if (!instance) return; + try { + await instance.close(); + console.info("[fresh-spawn] server stopped"); + } catch (err) { + console.error( + `[fresh-spawn] error stopping server: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + instance = null; + } +} + +/** + * Exposed for tests. Returns the current server instance or null. + */ +export function getFreshSpawnServerInstance(): SpawnServer | null { + return instance; +} diff --git a/apps/desktop/src/main/fresh-spawn/paths.test.ts b/apps/desktop/src/main/fresh-spawn/paths.test.ts new file mode 100644 index 00000000000..8db79e67471 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/paths.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { resolveFreshExecBinaryPath, resolveFreshExecHookPath } from "./paths"; + +const TEST_ROOT = path.join( + tmpdir(), + `superset-fresh-spawn-paths-${process.pid}-${Date.now()}`, +); + +describe("resolveFreshExecBinaryPath", () => { + beforeEach(() => { + mkdirSync(TEST_ROOT, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it("returns the path when fresh-exec.js exists in mainDir", () => { + const mainDir = path.join(TEST_ROOT, "main-bundle"); + mkdirSync(mainDir, { recursive: true }); + const freshExecPath = path.join(mainDir, "fresh-exec.js"); + writeFileSync(freshExecPath, "// stub\n"); + + const result = resolveFreshExecBinaryPath(mainDir); + expect(result).toBe(freshExecPath); + }); + + it("returns null when fresh-exec.js does not exist", () => { + const mainDir = path.join(TEST_ROOT, "empty-main"); + mkdirSync(mainDir, { recursive: true }); + + const result = resolveFreshExecBinaryPath(mainDir); + expect(result).toBeNull(); + }); + + it("returns null when mainDir does not exist", () => { + const mainDir = path.join(TEST_ROOT, "does-not-exist"); + const result = resolveFreshExecBinaryPath(mainDir); + expect(result).toBeNull(); + }); +}); + +describe("resolveFreshExecHookPath", () => { + beforeEach(() => { + mkdirSync(TEST_ROOT, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it("returns the hook path when zsh-fresh-exec.zsh exists under shell-hooks/", () => { + const resourcesDir = path.join(TEST_ROOT, "resources"); + mkdirSync(path.join(resourcesDir, "shell-hooks"), { recursive: true }); + const hookPath = path.join( + resourcesDir, + "shell-hooks", + "zsh-fresh-exec.zsh", + ); + writeFileSync(hookPath, "# stub\n"); + + const result = resolveFreshExecHookPath([resourcesDir]); + expect(result).toBe(hookPath); + }); + + it("returns the first candidate that exists", () => { + const missingDir = path.join(TEST_ROOT, "missing"); + const foundDir = path.join(TEST_ROOT, "found"); + mkdirSync(path.join(foundDir, "shell-hooks"), { recursive: true }); + const hookPath = path.join(foundDir, "shell-hooks", "zsh-fresh-exec.zsh"); + writeFileSync(hookPath, "# stub\n"); + + const result = resolveFreshExecHookPath([missingDir, foundDir]); + expect(result).toBe(hookPath); + }); + + it("skips empty-string candidates without throwing", () => { + const foundDir = path.join(TEST_ROOT, "found-after-empty"); + mkdirSync(path.join(foundDir, "shell-hooks"), { recursive: true }); + const hookPath = path.join(foundDir, "shell-hooks", "zsh-fresh-exec.zsh"); + writeFileSync(hookPath, "# stub\n"); + + const result = resolveFreshExecHookPath(["", foundDir]); + expect(result).toBe(hookPath); + }); + + it("returns null when no candidate contains the hook", () => { + const dir1 = path.join(TEST_ROOT, "dir1"); + const dir2 = path.join(TEST_ROOT, "dir2"); + mkdirSync(dir1, { recursive: true }); + mkdirSync(dir2, { recursive: true }); + + const result = resolveFreshExecHookPath([dir1, dir2]); + expect(result).toBeNull(); + }); + + it("returns null when searchDirs is empty", () => { + const result = resolveFreshExecHookPath([]); + expect(result).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/fresh-spawn/paths.ts b/apps/desktop/src/main/fresh-spawn/paths.ts new file mode 100644 index 00000000000..f9fe6192bdf --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/paths.ts @@ -0,0 +1,86 @@ +/** + * Path resolvers for fresh-exec binary and zsh shell hook. + * + * These are pure functions (no electron import) so they can be unit-tested + * without mocking. Callers pass the directories to search; in the real + * Electron main process those come from __dirname + process.resourcesPath + + * app.getAppPath(). In tests, callers pass tmpdirs. + * + * Both resolvers return null when no candidate exists on disk. That + * matches the gating behaviour in env.ts: if either path is missing, the + * SUPERSET_FRESH_EXEC_* env vars are not set, and the shell hook stays + * inert. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * Resolve the fresh-exec.js binary path. + * + * Rollup emits fresh-exec.js into the same directory as index.js (which + * is where __dirname of any main-process module points at runtime). + * + * Packaging wrinkle: when the main bundle is packaged into app.asar, + * fresh-exec.js is listed under `asarUnpack` so it physically lives at + * `.../app.asar.unpacked/dist/main/fresh-exec.js`. Electron patches + * `fs.existsSync` inside the main process to transparently read through + * app.asar, so the asar-interior path looks valid from here — but the + * zsh hook consumes this string via an **external** process (`[[ -x + * "$SUPERSET_FRESH_EXEC_BIN" ]]`), which sees the real filesystem only + * and fails on the asar-interior path. The feature would silently + * never activate in packaged builds. + * + * Probe order below: prefer the asar.unpacked twin if our candidate + * lives inside an app.asar path, then fall back to the candidate + * itself (dev mode + non-packaged test harnesses). + * + * @param mainDir - Directory containing fresh-exec.js, typically __dirname + * of the caller. Pass something like `path.join(__dirname, "fresh-spawn")` + * when caller lives at `dist/main/...`. + */ +export function resolveFreshExecBinaryPath(mainDir: string): string | null { + const candidate = path.join(mainDir, "fresh-exec.js"); + const asarInside = `${path.sep}app.asar${path.sep}`; + const asarUnpacked = `${path.sep}app.asar.unpacked${path.sep}`; + const probes = candidate.includes(asarInside) + ? [candidate.replace(asarInside, asarUnpacked), candidate] + : [candidate]; + + for (const probe of probes) { + try { + if (fs.existsSync(probe)) return probe; + } catch { + // Probe failures are non-fatal; continue. + } + } + return null; +} + +/** + * Resolve the zsh-fresh-exec.zsh hook path by probing a list of + * candidate directories in priority order. + * + * Packaged app layout: + * process.resourcesPath/resources/shell-hooks/zsh-fresh-exec.zsh + * Dev mode layout: + * /dist/resources/shell-hooks/zsh-fresh-exec.zsh + * /src/resources/shell-hooks/zsh-fresh-exec.zsh + * + * @param searchDirs - Directories to probe. The first existing + * candidate wins. Callers should pass most-specific first. + */ +export function resolveFreshExecHookPath( + searchDirs: readonly string[], +): string | null { + for (const dir of searchDirs) { + if (!dir) continue; + const candidate = path.join(dir, "shell-hooks", "zsh-fresh-exec.zsh"); + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + // Probe failures are non-fatal; continue. + } + } + return null; +} diff --git a/apps/desktop/src/main/fresh-spawn/spawn-client.test.ts b/apps/desktop/src/main/fresh-spawn/spawn-client.test.ts new file mode 100644 index 00000000000..332e84ad979 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/spawn-client.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { sendSpawnRequest } from "./spawn-client"; +import { type SpawnServer, startSpawnServer } from "./spawn-server"; + +describe("sendSpawnRequest", () => { + let server: SpawnServer | null = null; + let tmpDir = ""; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + tmpDir = ""; + }); + + function mkdirs(): { + socketPath: string; + tokenPath: string; + subprocessScriptPath: string; + } { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-client-")); + const subprocessScriptPath = path.join(tmpDir, "noop.js"); + fs.writeFileSync( + subprocessScriptPath, + `process.stdin.on("end", () => process.exit(0));\n`, + ); + return { + socketPath: path.join(tmpDir, "s.sock"), + tokenPath: path.join(tmpDir, "s.token"), + subprocessScriptPath, + }; + } + + it("sends spawn-pty-subprocess, receives ok+pid from streaming handler", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + + const resp = await sendSpawnRequest({ + socketPath: paths.socketPath, + tokenPath: paths.tokenPath, + request: { + type: "spawn-pty-subprocess", + env: { HOME: "/Users/test" }, + }, + }); + + // The first NDJSON line from the server is {type:"ok", pid}. sendSpawnRequest + // settles after that first line, so the client still sees a SpawnResponse + // even though the server-side connection remains open for streaming. + expect(resp.type).toBe("ok"); + if (resp.type === "ok") { + expect(typeof resp.pid).toBe("number"); + expect(resp.pid).toBeGreaterThan(0); + } + }); + + it("sends fresh-exec, receives ok+pid from server", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + + const resp = await sendSpawnRequest({ + socketPath: paths.socketPath, + tokenPath: paths.tokenPath, + request: { + type: "fresh-exec", + // A long-lived command that we immediately tear down by letting + // the test client disconnect at the end — avoids leaking a + // real PTY-rooted process tree past the test. + command: "/bin/sh", + args: ["-c", "sleep 5"], + cwd: "/tmp", + env: { PATH: "/usr/bin:/bin" }, + ptyCols: 80, + ptyRows: 24, + }, + }); + + expect(resp.type).toBe("ok"); + if (resp.type === "ok") { + expect(resp.pid).toBeGreaterThan(0); + } + }); + + it("rejects with timeout when server never responds", async () => { + const paths = mkdirs(); + // Start server so token file and socket exist, but we override the + // server-side idle timeout to be longer than the client timeout so the + // client's timer fires first. + server = await startSpawnServer({ ...paths, idleTimeoutMs: 5000 }); + + // Monkey-patch: delete the token file after token read but server still + // running. Instead, simpler approach: connect to a valid socket, but do + // not let server respond. To ensure client times out, we construct a + // request but the server would respond to it — so instead we use a + // socket that exists (server's) but we set a very short client timeout + // that fires before the server can respond. Since the skeleton server + // responds immediately on receiving the line, we race against that. + // + // More reliable: start our own trivial TCP/UDS listener that accepts + // but never writes. + const silentSockPath = path.join(tmpDir, "silent.sock"); + const silentServer = await new Promise<{ + close: () => Promise; + }>((resolve, reject) => { + // Lazy import to avoid top-level coupling + import("node:net").then((net) => { + const srv = net.createServer((_socket) => { + // Accept connection but never respond. + }); + srv.once("error", reject); + srv.once("listening", () => { + resolve({ + close: () => + new Promise((res) => { + srv.close(() => { + try { + fs.unlinkSync(silentSockPath); + } catch { + // ignore + } + res(); + }); + }), + }); + }); + srv.listen(silentSockPath); + }); + }); + + try { + await expect( + sendSpawnRequest({ + socketPath: silentSockPath, + tokenPath: paths.tokenPath, + request: { + type: "spawn-pty-subprocess", + env: {}, + }, + timeoutMs: 150, + }), + ).rejects.toThrow(/timeout/i); + } finally { + await silentServer.close(); + } + }); + + it("rejects when token file does not exist", async () => { + const paths = mkdirs(); + // Do not start the server — we want the token read to fail first. + const missingToken = path.join(tmpDir, "nope.token"); + + await expect( + sendSpawnRequest({ + socketPath: paths.socketPath, + tokenPath: missingToken, + request: { + type: "spawn-pty-subprocess", + env: {}, + }, + }), + ).rejects.toThrow(/ENOENT/); + }); + + it("rejects when socket path does not exist", async () => { + const paths = mkdirs(); + // Generate a real token file but point at a non-existent socket. + server = await startSpawnServer(paths); + await server.close(); + server = null; + // Socket file should be gone now but token remains. + expect(fs.existsSync(paths.tokenPath)).toBe(true); + expect(fs.existsSync(paths.socketPath)).toBe(false); + + await expect( + sendSpawnRequest({ + socketPath: paths.socketPath, + tokenPath: paths.tokenPath, + request: { + type: "spawn-pty-subprocess", + env: {}, + }, + timeoutMs: 1000, + }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/desktop/src/main/fresh-spawn/spawn-client.ts b/apps/desktop/src/main/fresh-spawn/spawn-client.ts new file mode 100644 index 00000000000..d84f8590e2c --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/spawn-client.ts @@ -0,0 +1,104 @@ +import * as net from "node:net"; +import { readTokenFile } from "./auth"; +import { type SpawnResponse, SpawnResponseSchema } from "./types"; + +export interface SendSpawnRequestOptions { + socketPath: string; + tokenPath: string; + request: + | { + type: "spawn-pty-subprocess"; + env: Record; + } + | { + type: "fresh-exec"; + command: string; + args: string[]; + cwd: string; + env: Record; + ptyCols: number; + ptyRows: number; + }; + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 5000; + +/** + * Send a single spawn request to the fresh-spawn server. + * + * Reads the auth token from disk, connects via UDS, writes one NDJSON line, + * waits for the first newline-terminated response line, validates the schema, + * and returns the parsed response. + * + * Rejects if: + * - Token file cannot be read (e.g. ENOENT) + * - Socket cannot be connected (e.g. ENOENT, ECONNREFUSED) + * - No response arrives within `timeoutMs` (default 5000) + * - Response is not valid JSON or fails schema validation + * + * This function owns exactly one connection per call; the underlying socket is + * always destroyed before the returned promise settles. + */ +export async function sendSpawnRequest( + options: SendSpawnRequestOptions, +): Promise { + // Read the token synchronously so token-file errors (ENOENT, EACCES) reject + // the promise before we open any socket. + const token = readTokenFile(options.tokenPath); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + const client = net.createConnection(options.socketPath); + let buffer = ""; + let settled = false; + + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + client.destroy(); + } catch { + // already destroyed — ignore + } + fn(); + }; + + const timer = setTimeout(() => { + settle(() => + reject(new Error(`spawn request timeout after ${timeoutMs}ms`)), + ); + }, timeoutMs); + + client.once("error", (err) => { + settle(() => reject(err)); + }); + + client.once("connect", () => { + const req = { ...options.request, token }; + client.write(`${JSON.stringify(req)}\n`); + }); + + client.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const newlineIdx = buffer.indexOf("\n"); + if (newlineIdx === -1) return; + + const line = buffer.slice(0, newlineIdx); + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (err) { + settle(() => reject(err)); + return; + } + const result = SpawnResponseSchema.safeParse(parsed); + if (!result.success) { + settle(() => reject(new Error(`invalid response schema: ${line}`))); + return; + } + settle(() => resolve(result.data)); + }); + }); +} diff --git a/apps/desktop/src/main/fresh-spawn/spawn-server.test.ts b/apps/desktop/src/main/fresh-spawn/spawn-server.test.ts new file mode 100644 index 00000000000..08c67e686f2 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/spawn-server.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { readTokenFile } from "./auth"; +import { type SpawnServer, startSpawnServer } from "./spawn-server"; + +describe("SpawnServer", () => { + let server: SpawnServer | null = null; + let tmpDir = ""; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + tmpDir = ""; + }); + + function mkdirs(): { + socketPath: string; + tokenPath: string; + subprocessScriptPath: string; + } { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-server-")); + // A minimal echo script. These tests don't exercise the streaming + // handler; a valid path is enough to satisfy the required option. + const subprocessScriptPath = path.join(tmpDir, "noop.js"); + fs.writeFileSync( + subprocessScriptPath, + `process.stdin.on("end", () => process.exit(0));\n`, + ); + return { + socketPath: path.join(tmpDir, "s.sock"), + tokenPath: path.join(tmpDir, "s.token"), + subprocessScriptPath, + }; + } + + /** + * Connect to the UDS server, send a single NDJSON line, + * and resolve with the first response line trimmed of its trailing newline. + */ + function roundTrip(sockPath: string, line: string): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection(sockPath); + let received = ""; + const onError = (err: Error) => { + client.destroy(); + reject(err); + }; + client.once("error", onError); + client.once("connect", () => { + client.write(`${line}\n`); + }); + client.on("data", (data) => { + received += data.toString("utf8"); + const nl = received.indexOf("\n"); + if (nl !== -1) { + client.off("error", onError); + client.destroy(); + resolve(received.slice(0, nl)); + } + }); + client.once("end", () => { + if (!received.includes("\n")) { + reject(new Error("server closed connection without responding")); + } + }); + }); + } + + it("starts on given socket path (file exists after startup)", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + expect(fs.existsSync(paths.socketPath)).toBe(true); + }); + + it("creates token file with 0600 mode", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + expect(fs.existsSync(paths.tokenPath)).toBe(true); + const mode = fs.statSync(paths.tokenPath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("responds with E_PARSE on invalid JSON", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + + const resp = await roundTrip(paths.socketPath, "not-valid-json{"); + const parsed = JSON.parse(resp); + expect(parsed.type).toBe("error"); + expect(parsed.code).toBe("E_PARSE"); + }); + + it("responds with E_SCHEMA on schema-invalid request", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + + // Missing required `env` field and unknown `type` + const resp = await roundTrip( + paths.socketPath, + JSON.stringify({ type: "bogus-type", token: "whatever" }), + ); + const parsed = JSON.parse(resp); + expect(parsed.type).toBe("error"); + expect(parsed.code).toBe("E_SCHEMA"); + }); + + it("responds with E_AUTH on bad token", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + + const resp = await roundTrip( + paths.socketPath, + JSON.stringify({ + type: "spawn-pty-subprocess", + token: "WRONG_TOKEN_OF_DIFFERENT_LENGTH", + env: {}, + }), + ); + const parsed = JSON.parse(resp); + expect(parsed.type).toBe("error"); + expect(parsed.code).toBe("E_AUTH"); + }); + + it("responds with ok+pid on valid authenticated spawn-pty-subprocess request", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + const token = readTokenFile(paths.tokenPath); + + const resp = await roundTrip( + paths.socketPath, + JSON.stringify({ + type: "spawn-pty-subprocess", + token, + env: { HOME: "/Users/test" }, + }), + ); + const parsed = JSON.parse(resp); + expect(parsed.type).toBe("ok"); + expect(typeof parsed.pid).toBe("number"); + expect(parsed.pid).toBeGreaterThan(0); + }); + + it("responds with ok+pid on valid authenticated fresh-exec request", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + const token = readTokenFile(paths.tokenPath); + + const resp = await roundTrip( + paths.socketPath, + JSON.stringify({ + type: "fresh-exec", + token, + // Use a command that's guaranteed to exist and exits quickly so + // the PTY shuts down on its own without leaking processes. + command: "/bin/echo", + args: ["fresh-exec-smoke"], + cwd: "/tmp", + env: { PATH: "/usr/bin:/bin" }, + ptyCols: 80, + ptyRows: 24, + }), + ); + const parsed = JSON.parse(resp); + expect(parsed.type).toBe("ok"); + expect(typeof parsed.pid).toBe("number"); + expect(parsed.pid).toBeGreaterThan(0); + }); + + it("closes idle connections after timeout", async () => { + const paths = mkdirs(); + // Use a short timeout to keep the test fast and stable on CI. + server = await startSpawnServer({ ...paths, idleTimeoutMs: 200 }); + + const start = Date.now(); + await new Promise((resolve, reject) => { + const client = net.createConnection(paths.socketPath); + client.once("error", reject); + client.once("close", () => resolve()); + // Intentionally never send anything — the server should time out + // and destroy the connection after idleTimeoutMs. + }); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(200); + expect(elapsed).toBeLessThan(2000); + }); + + it("close() removes the socket file", async () => { + const paths = mkdirs(); + server = await startSpawnServer(paths); + expect(fs.existsSync(paths.socketPath)).toBe(true); + + await server.close(); + server = null; + + expect(fs.existsSync(paths.socketPath)).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/fresh-spawn/spawn-server.ts b/apps/desktop/src/main/fresh-spawn/spawn-server.ts new file mode 100644 index 00000000000..6ce70c7643d --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/spawn-server.ts @@ -0,0 +1,238 @@ +import * as fs from "node:fs"; +import * as net from "node:net"; +import { generateTokenFile, verifyToken } from "./auth"; +import { handleFreshExec } from "./handlers/fresh-exec"; +import { handleSpawnPtySubprocess } from "./handlers/spawn-pty-subprocess"; +import { SpawnRequestSchema, type SpawnResponse } from "./types"; + +export interface SpawnServerOptions { + socketPath: string; + tokenPath: string; + /** + * Idle timeout in milliseconds. Connections that send no data within this + * window are destroyed to prevent resource leaks from half-open clients. + * The timer is reset by any incoming data activity (Node stdlib behavior). + * Defaults to 5000ms. + */ + idleTimeoutMs?: number; + /** + * Path to the pty-subprocess.js script (or a test echo script). This is a + * server-side config — NOT part of the RPC payload — so that authenticated + * clients cannot spawn arbitrary scripts through this server. + */ + subprocessScriptPath: string; +} + +const DEFAULT_IDLE_TIMEOUT_MS = 5000; +const MAX_HANDSHAKE_LINE_BYTES = 1024 * 1024; // 1 MiB — same as spawn-session client cap + +export interface SpawnServer { + close(): Promise; +} + +/** + * Start the fresh-spawn UDS server. + * + * Protocol: each client connection sends a single NDJSON request. One-shot + * error paths (validation, schema, auth) write a single NDJSON response and + * close the connection. The streaming handlers (`spawn-pty-subprocess` and + * `fresh-exec`) take ownership of the socket: they write the initial + * `{type:"ok",pid}` SpawnResponse line and then stream NDJSON StreamFrames + * (stdout/stderr/exit server→client; stdin/resize/signal client→server) until + * the child exits or the peer disconnects. + * + * `spawn-pty-subprocess` uses child_process.spawn (no real tty); `fresh-exec` + * uses node-pty (pseudoterminal) so interactive commands like `gh auth login` + * work. For `fresh-exec` the command/args/env/cwd come from the request. + */ +export async function startSpawnServer( + options: SpawnServerOptions, +): Promise { + // Remove stale socket if a previous process crashed without cleanup. + // ENOENT is fine and any other error will surface from listen() below. + try { + fs.unlinkSync(options.socketPath); + } catch { + // ignore + } + + const token = generateTokenFile(options.tokenPath); + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + + const server = net.createServer((client) => { + let buffer = ""; + let handled = false; + + // Destroy clients that connect but never send a complete request. + // setTimeout fires when the socket is idle (no read activity) for the + // given duration; Node automatically resets the timer on each data event. + client.setTimeout(idleTimeoutMs); + client.once("timeout", () => { + client.destroy(); + }); + + client.on("data", (chunk) => { + if (handled) return; + buffer += chunk.toString("utf8"); + if (buffer.length > MAX_HANDSHAKE_LINE_BYTES) { + // Peer is streaming bytes without ever sending a newline. Cap + // the accumulation to bound memory, matching the client-side + // guard in spawn-session.ts. + handled = true; + writeResponse(client, { + type: "error", + message: `handshake exceeded ${MAX_HANDSHAKE_LINE_BYTES} bytes without newline`, + code: "E_TOO_LARGE", + }); + client.end(); + return; + } + const newlineIdx = buffer.indexOf("\n"); + if (newlineIdx === -1) return; + + handled = true; + const line = buffer.slice(0, newlineIdx); + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + writeResponse(client, { + type: "error", + message: "invalid JSON", + code: "E_PARSE", + }); + client.end(); + return; + } + + const result = SpawnRequestSchema.safeParse(parsed); + if (!result.success) { + writeResponse(client, { + type: "error", + message: "invalid request schema", + code: "E_SCHEMA", + }); + client.end(); + return; + } + + if (!verifyToken(result.data.token, token)) { + writeResponse(client, { + type: "error", + message: "bad token", + code: "E_AUTH", + }); + client.end(); + return; + } + + // Bytes that arrived in the same TCP chunk after the request line + // (e.g. pipelined stdin frames) — forward to the handler so they + // are parsed as its first incoming frames. + const residual = buffer.slice(newlineIdx + 1); + + if (result.data.type === "spawn-pty-subprocess") { + // Handler takes ownership of the socket: it writes the initial + // {type:"ok",pid} line and then streams NDJSON frames until the + // child exits or the client disconnects. Do not write to or + // close the socket here after a successful dispatch. + try { + handleSpawnPtySubprocess({ env: result.data.env }, client, { + subprocessScriptPath: options.subprocessScriptPath, + initialBuffer: residual, + }); + } catch (err) { + writeResponse(client, { + type: "error", + message: err instanceof Error ? err.message : String(err), + code: "E_SPAWN", + }); + client.end(); + } + return; + } + + if (result.data.type === "fresh-exec") { + // Same ownership contract as spawn-pty-subprocess, but uses + // node-pty so interactive commands get a real tty. + try { + handleFreshExec( + { + command: result.data.command, + args: result.data.args, + cwd: result.data.cwd, + env: result.data.env, + ptyCols: result.data.ptyCols, + ptyRows: result.data.ptyRows, + }, + client, + { initialBuffer: residual }, + ); + } catch (err) { + writeResponse(client, { + type: "error", + message: err instanceof Error ? err.message : String(err), + code: "E_FRESH_EXEC", + }); + client.end(); + } + return; + } + }); + + client.on("error", () => { + // Swallow client socket errors; they happen on abrupt peer close. + }); + }); + + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + server.off("listening", onListening); + reject(err); + }; + const onListening = () => { + server.off("error", onError); + // Keep a permanent error listener so post-startup server errors + // (e.g. EMFILE from a runaway connect loop, filesystem permission + // changes on the socket path) don't propagate as unhandled and + // kill the entire daemon. + server.on("error", (err) => { + console.error("[fresh-spawn] server error after startup:", err); + }); + // Defense-in-depth: macOS may not enforce mode bits on AF_UNIX + // sockets, but set 0o700 anyway so any filesystem that does honor + // them keeps the socket owner-only. + try { + fs.chmodSync(options.socketPath, 0o700); + } catch (err) { + server.close(); + reject(err); + return; + } + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(options.socketPath); + }); + + return { + close: () => + new Promise((resolve) => { + server.close(() => { + // Node does not auto-unlink Unix domain socket files. + try { + fs.unlinkSync(options.socketPath); + } catch { + // already gone + } + resolve(); + }); + }), + }; +} + +function writeResponse(client: net.Socket, resp: SpawnResponse): void { + client.write(`${JSON.stringify(resp)}\n`); +} diff --git a/apps/desktop/src/main/fresh-spawn/spawn-session.test.ts b/apps/desktop/src/main/fresh-spawn/spawn-session.test.ts new file mode 100644 index 00000000000..8b1492dd2e9 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/spawn-session.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { type SpawnServer, startSpawnServer } from "./spawn-server"; +import { openSpawnSession } from "./spawn-session"; + +describe("openSpawnSession", () => { + let server: SpawnServer | null = null; + let tmpDir = ""; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + tmpDir = ""; + }); + + function setupEcho(): { + socketPath: string; + tokenPath: string; + subprocessScriptPath: string; + } { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-session-")); + const echoPath = path.join(tmpDir, "echo.js"); + fs.writeFileSync( + echoPath, + `process.stdin.on("data", (d) => process.stdout.write(d)); +process.stdin.on("end", () => process.exit(0)); +`, + ); + return { + socketPath: path.join(tmpDir, "s.sock"), + tokenPath: path.join(tmpDir, "s.token"), + subprocessScriptPath: echoPath, + }; + } + + it("establishes session and streams stdin→stdout round-trip", async () => { + const paths = setupEcho(); + server = await startSpawnServer(paths); + const session = await openSpawnSession({ + socketPath: paths.socketPath, + tokenPath: paths.tokenPath, + env: {}, + }); + + expect(session.pid).toBeGreaterThan(0); + + const received: Buffer[] = []; + session.stdout.on("data", (chunk: Buffer) => received.push(chunk)); + + session.stdin.write("hello\n"); + + // Wait for echo + await new Promise((r) => setTimeout(r, 200)); + + const got = Buffer.concat(received).toString("utf8"); + expect(got).toBe("hello\n"); + + // Cleanly kill + const exitPromise = new Promise<{ + code: number | null; + signal: string | null; + }>((resolve) => { + session.once("exit", (code: number | null, signal: string | null) => + resolve({ code, signal }), + ); + }); + session.kill("SIGTERM"); + const exit = await exitPromise; + expect(exit.signal).toBe("SIGTERM"); + }, 10000); + + it("rejects with E_AUTH on bad token", async () => { + const paths = setupEcho(); + server = await startSpawnServer(paths); + // Overwrite token file with a wrong token so the server returns E_AUTH. + fs.writeFileSync(paths.tokenPath, "WRONG_TOKEN"); + + await expect( + openSpawnSession({ + socketPath: paths.socketPath, + tokenPath: paths.tokenPath, + env: {}, + }), + ).rejects.toThrow(/E_AUTH|bad token/); + }, 10000); + + it("rejects when server is not listening", async () => { + const tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), "fs-nosvr-")); + const socketPath = path.join(tmpDir2, "missing.sock"); + const tokenPath = path.join(tmpDir2, "token"); + fs.writeFileSync(tokenPath, "doesnt-matter"); + + await expect( + openSpawnSession({ socketPath, tokenPath, env: {} }), + ).rejects.toThrow(); + + fs.rmSync(tmpDir2, { recursive: true, force: true }); + }); + + /** + * Start a bare-bones UDS server that accepts exactly one connection, + * performs the handshake (writes {type:"ok",pid}), and then lets the caller + * decide when/how to disconnect. Used to simulate server crashes without + * the full spawn-server/subprocess pipeline getting in the way. + */ + async function startFakeHandshakeServer(socketPath: string): Promise<{ + close: () => Promise; + /** Resolves with the accepted socket once a client connects. */ + accepted: Promise; + }> { + let resolveAccepted: (s: net.Socket) => void = () => {}; + const accepted = new Promise((r) => { + resolveAccepted = r; + }); + const srv = net.createServer((sock) => { + let buf = ""; + sock.on("data", (chunk) => { + buf += chunk.toString("utf8"); + const nl = buf.indexOf("\n"); + if (nl === -1) return; + // Acknowledge handshake with fixed pid so openSpawnSession resolves. + sock.write(`${JSON.stringify({ type: "ok", pid: 99999 })}\n`); + resolveAccepted(sock); + }); + sock.on("error", () => { + // Swallow; the tests may destroy the socket deliberately. + }); + }); + await new Promise((res, rej) => { + srv.once("error", rej); + srv.listen(socketPath, () => res()); + }); + return { + accepted, + close: () => + new Promise((res) => { + srv.close(() => { + try { + fs.unlinkSync(socketPath); + } catch { + // already gone + } + res(); + }); + }), + }; + } + + it("emits synthetic exit when server disconnects without sending exit frame", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-synth-exit-")); + const socketPath = path.join(tmpDir, "s.sock"); + const tokenPath = path.join(tmpDir, "s.token"); + fs.writeFileSync(tokenPath, "any-token"); + + const fake = await startFakeHandshakeServer(socketPath); + + const session = await openSpawnSession({ + socketPath, + tokenPath, + env: {}, + }); + + const exitPromise = new Promise<{ + code: number | null; + signal: string | null; + }>((resolve) => { + session.once("exit", (code: number | null, signal: string | null) => + resolve({ code, signal }), + ); + }); + // Swallow any error emitted by the abrupt disconnect so Node doesn't + // escalate to an uncaughtException. + session.on("error", () => {}); + + // Destroy the server's side of the accepted client socket — no exit + // frame is written, mimicking a crashed server. + const sock = await fake.accepted; + sock.destroy(); + + const exit = await Promise.race([ + exitPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("timed out waiting for synthetic exit")), + 2000, + ), + ), + ]); + + // Synthetic exit should be null/null + expect(exit.code).toBeNull(); + expect(exit.signal).toBeNull(); + + await fake.close(); + }, 10000); + + it("does not double-emit exit when real exit frame precedes close", async () => { + const paths = setupEcho(); + server = await startSpawnServer(paths); + const session = await openSpawnSession({ + socketPath: paths.socketPath, + tokenPath: paths.tokenPath, + env: {}, + }); + + let exitCount = 0; + session.on("exit", () => { + exitCount += 1; + }); + session.on("error", () => {}); + + session.kill("SIGTERM"); + // Wait long enough for both the exit frame AND the close event to fire. + await new Promise((r) => setTimeout(r, 1000)); + + expect(exitCount).toBe(1); + }, 10000); + + it("does not crash when stream error has no listener attached", async () => { + // Verify the safeEmitError path: if the server disconnects and no + // `error` listener is attached, Node must not throw an unhandled error. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-no-err-listener-")); + const socketPath = path.join(tmpDir, "s.sock"); + const tokenPath = path.join(tmpDir, "s.token"); + fs.writeFileSync(tokenPath, "any-token"); + + const fake = await startFakeHandshakeServer(socketPath); + + const session = await openSpawnSession({ + socketPath, + tokenPath, + env: {}, + }); + // Deliberately do NOT attach an error listener on `session`. + // Disconnect the server-side socket abruptly. + const sock = await fake.accepted; + sock.destroy(); + + // Wait for the teardown to settle; if safeEmitError misbehaves it will + // surface as an uncaughtException and crash the test worker. + await new Promise((r) => setTimeout(r, 300)); + expect(session.pid).toBeGreaterThan(0); + + await fake.close(); + }, 10000); +}); diff --git a/apps/desktop/src/main/fresh-spawn/spawn-session.ts b/apps/desktop/src/main/fresh-spawn/spawn-session.ts new file mode 100644 index 00000000000..e5ac87cb23a --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/spawn-session.ts @@ -0,0 +1,326 @@ +import { EventEmitter } from "node:events"; +import * as net from "node:net"; +import { Readable, Writable } from "node:stream"; +import { readTokenFile } from "./auth"; +import { + type ClientToServerStreamFrame, + ServerToClientStreamFrameSchema, + SpawnResponseSchema, +} from "./types"; + +export interface OpenSpawnSessionOptions { + socketPath: string; + tokenPath: string; + env: Record; + /** Connection establishment + handshake timeout. Default 5000ms. */ + handshakeTimeoutMs?: number; +} + +const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5000; +const MAX_LINE_BYTES = 1024 * 1024; // 1 MiB cap on in-flight line buffer + +function safeEmitError(emitter: EventEmitter, err: Error): void { + if (emitter.listenerCount("error") > 0) { + emitter.emit("error", err); + } + // else: silent drop, matches ChildProcess behavior when no handler +} + +/** + * Drop-in replacement for node:child_process ChildProcess that actually wraps + * a UDS-backed stream to the fresh-spawn server. Consumers of terminal-host + * session.ts can treat this like a spawned child. + */ +export interface SpawnSession extends EventEmitter { + readonly pid: number; + readonly stdin: Writable; + readonly stdout: Readable; + readonly stderr: Readable; + kill(signal?: NodeJS.Signals | string): boolean; + /** + * Resize hint forwarded to server — no-op for non-PTY spawn but supported + * by fresh-exec. + */ + resize(cols: number, rows: number): void; +} + +/** + * Opens a streaming spawn session against the fresh-spawn server. + * + * Resolves once the server has responded with `{type:"ok",pid}` and the + * session is ready to stream I/O. Rejects on handshake errors (timeout, + * schema mismatch, E_* error response). + * + * After resolution, the underlying UDS connection stays open for + * bidirectional NDJSON streaming: + * - stdin writes are encoded as `stdin` frames. + * - stdout/stderr frames from the server push into the session's Readable + * streams. + * - `kill` writes a `signal` frame; `resize` writes a `resize` frame. + * - The `exit` event fires on the first `exit` frame from the server. + */ +export async function openSpawnSession( + options: OpenSpawnSessionOptions, +): Promise { + const token = readTokenFile(options.tokenPath); + const handshakeTimeoutMs = + options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + const client = net.createConnection(options.socketPath); + let handshakeDone = false; + let buffer = ""; + + const timer = setTimeout(() => { + if (!handshakeDone) { + try { + client.destroy(); + } catch { + // ignore + } + reject(new Error(`handshake timeout after ${handshakeTimeoutMs}ms`)); + } + }, handshakeTimeoutMs); + + client.once("error", (err) => { + if (!handshakeDone) { + clearTimeout(timer); + reject(err); + } + }); + + client.once("connect", () => { + const req = { + type: "spawn-pty-subprocess" as const, + token, + env: options.env, + }; + client.write(`${JSON.stringify(req)}\n`); + }); + + // Stage 1: wait for initial SpawnResponse line. + const onHandshakeData = (chunk: Buffer): void => { + buffer += chunk.toString("utf8"); + const newlineIdx = buffer.indexOf("\n"); + if (newlineIdx === -1) { + if (buffer.length > MAX_LINE_BYTES) { + clearTimeout(timer); + client.destroy(); + reject( + new Error( + `handshake buffer exceeded ${MAX_LINE_BYTES} bytes without newline`, + ), + ); + } + return; + } + + const line = buffer.slice(0, newlineIdx); + const remainder = buffer.slice(newlineIdx + 1); + buffer = ""; + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (err) { + clearTimeout(timer); + client.destroy(); + reject( + new Error( + `invalid handshake response JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + return; + } + + const parseResult = SpawnResponseSchema.safeParse(parsed); + if (!parseResult.success) { + clearTimeout(timer); + client.destroy(); + reject(new Error(`invalid SpawnResponse schema: ${line}`)); + return; + } + + const resp = parseResult.data; + if (resp.type === "error") { + clearTimeout(timer); + client.destroy(); + reject(new Error(`spawn error (${resp.code}): ${resp.message}`)); + return; + } + + // Success — transition to streaming phase. + clearTimeout(timer); + handshakeDone = true; + client.off("data", onHandshakeData); + const session = createSession(client, resp.pid, remainder); + resolve(session); + }; + client.on("data", onHandshakeData); + }); +} + +function createSession( + client: net.Socket, + pid: number, + initialBuffer: string, +): SpawnSession { + const emitter = new EventEmitter() as SpawnSession; + Object.defineProperty(emitter, "pid", { value: pid, writable: false }); + + // Track whether an `exit` event has been emitted so we can synthesize one + // if the socket closes without a prior `exit` frame from the server. + let exitEmitted = false; + emitter.on("exit", () => { + exitEmitted = true; + }); + + // Readable streams for stdout and stderr — data is pushed manually as + // frames arrive from the server. + const stdout = new Readable({ + read() { + // passive; we push manually + }, + }); + const stderr = new Readable({ + read() { + // passive; we push manually + }, + }); + + // Writable stream for stdin — forwards to UDS as {type:"stdin"} frames. + const stdin = new Writable({ + write(chunk: Buffer | string, _encoding, callback) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string); + writeClientFrame(client, { + type: "stdin", + data: buf.toString("base64"), + }); + callback(); + }, + final(callback) { + // End of stdin: just stop writing. UDS stream stays open for + // further frames (signal, resize, incoming stdout). + callback(); + }, + }); + + Object.defineProperty(emitter, "stdin", { value: stdin, writable: false }); + Object.defineProperty(emitter, "stdout", { value: stdout, writable: false }); + Object.defineProperty(emitter, "stderr", { value: stderr, writable: false }); + + emitter.kill = (signal: NodeJS.Signals | string = "SIGTERM"): boolean => { + writeClientFrame(client, { + type: "signal", + name: String(signal), + }); + return true; + }; + + emitter.resize = (cols: number, rows: number): void => { + writeClientFrame(client, { + type: "resize", + cols, + rows, + }); + }; + + // Stream parser. + let buffer = initialBuffer; + const processBuffer = (): void => { + let newlineIdx: number; + // biome-ignore lint/suspicious/noAssignInExpressions: standard NDJSON line extractor + while ((newlineIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIdx); + buffer = buffer.slice(newlineIdx + 1); + if (line.trim().length === 0) continue; + handleStreamLine(line, stdout, stderr, emitter); + } + }; + + // Process any pipelined bytes that arrived in the same TCP chunk as the + // handshake response. + processBuffer(); + + client.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + // Drain complete lines FIRST, then apply the cap to the residual tail. + // Otherwise a burst of many newline-terminated frames whose aggregate + // size exceeds MAX_LINE_BYTES would be incorrectly rejected, even + // though no single frame is oversized. + processBuffer(); + if (buffer.length > MAX_LINE_BYTES) { + // Malformed stream — no newline in sight after draining. Destroy + // the socket, notify listeners, and synthesize an exit so + // consumers don't hang. + const err = new Error( + `stream buffer exceeded ${MAX_LINE_BYTES} bytes without newline`, + ); + buffer = ""; + try { + client.destroy(); + } catch { + // ignore + } + safeEmitError(emitter, err); + } + }); + + client.once("close", () => { + stdout.push(null); + stderr.push(null); + if (!exitEmitted) { + // Server closed without an `exit` frame — synthesize one so + // consumers awaiting `exit` don't hang. + emitter.emit("exit", null, null); + } + }); + + client.on("error", (err) => { + safeEmitError(emitter, err); + }); + + return emitter; +} + +function handleStreamLine( + line: string, + stdout: Readable, + stderr: Readable, + emitter: SpawnSession, +): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + const result = ServerToClientStreamFrameSchema.safeParse(parsed); + if (!result.success) return; + + const frame = result.data; + switch (frame.type) { + case "stdout": + stdout.push(Buffer.from(frame.data, "base64")); + return; + case "stderr": + stderr.push(Buffer.from(frame.data, "base64")); + return; + case "exit": + emitter.emit("exit", frame.code, frame.signal); + return; + } +} + +function writeClientFrame( + client: net.Socket, + frame: ClientToServerStreamFrame, +): void { + try { + client.write(`${JSON.stringify(frame)}\n`); + } catch { + // socket may be destroyed during teardown + } +} diff --git a/apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.test.ts b/apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.test.ts new file mode 100644 index 00000000000..b9d8e25fe87 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { recvFd, sendFd } from "./fd-passing-spike"; + +/** + * This spike was written to validate SCM_RIGHTS FD passing between two + * Node.js processes using the `node-unix-socket` npm package. The package + * turned out to not expose any FD passing surface (its Seqpacket/Dgram + * classes have no sendFd/recvFd equivalent) and its SOCK_SEQPACKET + * transport is not available on macOS at all. + * + * The test below codifies that finding so the spike does not silently rot: + * both `sendFd` and `recvFd` are expected to throw until a native N-API + * addon replaces them (see Task 3.6 in the fresh-spawn plan). + * + * The commented-out block is the originally intended round-trip test. Bring + * it back the moment a working `sendFd`/`recvFd` pair exists — whether + * through a different npm package or a native addon. + */ +describe("fd-passing spike", () => { + it("documents that node-unix-socket cannot move FDs via SCM_RIGHTS", async () => { + expect(() => sendFd("/tmp/ignored.sock", 0, () => {})).toThrow( + /does not support SCM_RIGHTS/i, + ); + await expect(recvFd("/tmp/ignored.sock")).rejects.toThrow( + /does not support SCM_RIGHTS/i, + ); + }); + + // Intended round-trip test. Re-enable when a working FD passing + // implementation lands. + it.skip("transfers a writable FD between two processes via UDS", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fd-spike-")); + const sockPath = path.join(tmpDir, "spike.sock"); + const outFile = path.join(tmpDir, "received.txt"); + + try { + const fd = fs.openSync(outFile, "w"); + + const senderReady = new Promise((resolve) => { + sendFd(sockPath, fd, resolve); + }); + + await senderReady; + + const received = await recvFd(sockPath); + fs.writeSync(received, "hello"); + fs.closeSync(received); + + const content = fs.readFileSync(outFile, "utf8"); + expect(content).toBe("hello"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.ts b/apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.ts new file mode 100644 index 00000000000..809490f5bf3 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/spike/fd-passing-spike.ts @@ -0,0 +1,52 @@ +/** + * FD passing spike (Task 3 of fresh-mach-context-spawn plan). + * + * Goal: validate round-trip FD transfer over a Unix Domain Socket using + * SCM_RIGHTS so future tasks (Task 8/13) can ship stdin/stdout/stderr FDs + * from Electron's main process to the terminal-host daemon. + * + * Result: `node-unix-socket@0.2.7` does NOT expose SCM_RIGHTS FD passing + * and its SOCK_SEQPACKET transport is not available on macOS (the addon + * throws "Protocol not supported" on darwin-arm64). The wrapper below + * imports the package for bookkeeping and throws a clear error explaining + * the gap. A native N-API addon (Task 3.6 in the plan) is required to + * actually move FDs between unrelated processes via SCM_RIGHTS. + */ + +// We intentionally import from the package so the spike still fails loudly +// (rather than silently) if someone assumes node-unix-socket gained FD +// passing in a later version. The imports double as a smoke test that the +// dependency is installed and resolvable. +import { SeqpacketServer, SeqpacketSocket } from "node-unix-socket"; + +const UNSUPPORTED_MESSAGE = + "node-unix-socket does not support SCM_RIGHTS FD passing. " + + "Its Seqpacket/Dgram surface has no sendFd/recvFd API, and on macOS " + + "SeqpacketServer/SeqpacketSocket fail with 'Protocol not supported'. " + + "A native N-API addon (sendmsg/recvmsg with SCM_RIGHTS) is required. " + + "See apps/desktop/plans/20260417-1500-fresh-mach-context-spawn.md Step 3.6."; + +/** + * Hypothetical signature from the design doc. The implementation cannot be + * realised with node-unix-socket; this function exists so the test and + * downstream callers fail with an actionable error until a native addon + * lands. + */ +export function sendFd( + _socketPath: string, + _fd: number, + _onConnected: () => void, +): void { + // Reference the imports so bundlers/lint don't strip them; also prove + // the module loads without throwing at require-time. + void SeqpacketServer; + void SeqpacketSocket; + throw new Error(UNSUPPORTED_MESSAGE); +} + +/** + * Hypothetical signature from the design doc. See `sendFd` above. + */ +export function recvFd(_socketPath: string): Promise { + return Promise.reject(new Error(UNSUPPORTED_MESSAGE)); +} diff --git a/apps/desktop/src/main/fresh-spawn/types.test.ts b/apps/desktop/src/main/fresh-spawn/types.test.ts new file mode 100644 index 00000000000..ef73c5af93a --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/types.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "bun:test"; +import { + ClientToServerStreamFrameSchema, + ServerToClientStreamFrameSchema, + type SpawnRequest, + SpawnRequestSchema, + type SpawnResponse, + SpawnResponseSchema, +} from "./types"; + +describe("fresh-spawn protocol types", () => { + describe("SpawnRequestSchema", () => { + it("parses a valid spawn-pty-subprocess request", () => { + const request: SpawnRequest = { + type: "spawn-pty-subprocess", + token: "super-secret-token", + env: { HOME: "/Users/test", PATH: "/usr/bin" }, + }; + + expect(() => SpawnRequestSchema.parse(request)).not.toThrow(); + }); + + it("parses a valid fresh-exec request", () => { + const request: SpawnRequest = { + type: "fresh-exec", + token: "super-secret-token", + command: "gh", + args: ["auth", "status"], + cwd: "/Users/test/project", + env: { HOME: "/Users/test", PATH: "/usr/bin" }, + ptyCols: 120, + ptyRows: 40, + }; + + expect(() => SpawnRequestSchema.parse(request)).not.toThrow(); + }); + + it("throws when the token field is missing", () => { + const request = { + type: "spawn-pty-subprocess", + env: { HOME: "/Users/test" }, + }; + + expect(() => SpawnRequestSchema.parse(request)).toThrow(); + }); + + it("throws when fresh-exec request is missing ptyCols", () => { + const request = { + type: "fresh-exec", + token: "super-secret-token", + command: "gh", + args: ["auth", "status"], + cwd: "/Users/test/project", + env: { HOME: "/Users/test" }, + ptyRows: 40, + }; + + expect(() => SpawnRequestSchema.parse(request)).toThrow(); + }); + }); + + describe("SpawnResponseSchema", () => { + it("parses a valid ok response", () => { + const response: SpawnResponse = { + type: "ok", + pid: 12345, + }; + + expect(() => SpawnResponseSchema.parse(response)).not.toThrow(); + }); + + it("parses a valid error response", () => { + const response: SpawnResponse = { + type: "error", + message: "spawn failed: ENOENT", + code: "E_SPAWN_FAILED", + }; + + expect(() => SpawnResponseSchema.parse(response)).not.toThrow(); + }); + + it("throws when ok response has a non-positive pid", () => { + const response = { + type: "ok", + pid: 0, + }; + + expect(() => SpawnResponseSchema.parse(response)).toThrow(); + }); + }); + + describe("streaming frames", () => { + describe("server→client", () => { + it("validates stdout frame with base64 data", () => { + expect(() => + ServerToClientStreamFrameSchema.parse({ + type: "stdout", + data: "aGVsbG8=", + }), + ).not.toThrow(); + }); + + it("validates stderr frame", () => { + expect(() => + ServerToClientStreamFrameSchema.parse({ + type: "stderr", + data: "", + }), + ).not.toThrow(); + }); + + it("validates exit frame with code and signal", () => { + expect(() => + ServerToClientStreamFrameSchema.parse({ + type: "exit", + code: 0, + signal: null, + }), + ).not.toThrow(); + }); + + it("validates exit frame with signal and null code", () => { + expect(() => + ServerToClientStreamFrameSchema.parse({ + type: "exit", + code: null, + signal: "SIGTERM", + }), + ).not.toThrow(); + }); + + it("rejects unknown type", () => { + expect(() => + ServerToClientStreamFrameSchema.parse({ + type: "unknown", + }), + ).toThrow(); + }); + }); + + describe("client→server", () => { + it("validates stdin frame", () => { + expect(() => + ClientToServerStreamFrameSchema.parse({ + type: "stdin", + data: "cHdkCg==", + }), + ).not.toThrow(); + }); + + it("validates resize frame", () => { + expect(() => + ClientToServerStreamFrameSchema.parse({ + type: "resize", + cols: 120, + rows: 40, + }), + ).not.toThrow(); + }); + + it("rejects resize with zero dims", () => { + expect(() => + ClientToServerStreamFrameSchema.parse({ + type: "resize", + cols: 0, + rows: 40, + }), + ).toThrow(); + }); + + it("validates signal frame", () => { + expect(() => + ClientToServerStreamFrameSchema.parse({ + type: "signal", + name: "SIGINT", + }), + ).not.toThrow(); + }); + + it("rejects signal with empty name", () => { + expect(() => + ClientToServerStreamFrameSchema.parse({ + type: "signal", + name: "", + }), + ).toThrow(); + }); + }); + }); +}); diff --git a/apps/desktop/src/main/fresh-spawn/types.ts b/apps/desktop/src/main/fresh-spawn/types.ts new file mode 100644 index 00000000000..22e0aefa1e7 --- /dev/null +++ b/apps/desktop/src/main/fresh-spawn/types.ts @@ -0,0 +1,104 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { z } from "zod"; + +export const SpawnRequestSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("spawn-pty-subprocess"), + token: z.string().min(1), + env: z.record(z.string(), z.string()), + }), + z.object({ + type: z.literal("fresh-exec"), + token: z.string().min(1), + command: z.string().min(1), + args: z.array(z.string()), + cwd: z.string().min(1), + env: z.record(z.string(), z.string()), + ptyCols: z.number().int().positive(), + ptyRows: z.number().int().positive(), + }), +]); + +export type SpawnRequest = z.infer; + +export const SpawnResponseSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("ok"), + pid: z.number().int().positive(), + }), + z.object({ + type: z.literal("error"), + message: z.string(), + code: z.string(), + }), +]); + +export type SpawnResponse = z.infer; + +// ========================================================================= +// Streaming frames (sent after successful spawn response) +// ========================================================================= + +/** + * Frames flowing from the fresh-spawn server to the client. + * Each frame is one NDJSON line on the same UDS connection. + */ +export const ServerToClientStreamFrameSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("stdout"), + /** Base64-encoded UTF-8 bytes. */ + data: z.string(), + }), + z.object({ + type: z.literal("stderr"), + data: z.string(), + }), + z.object({ + type: z.literal("exit"), + code: z.number().int().nullable(), + signal: z.string().nullable(), + }), +]); + +export type ServerToClientStreamFrame = z.infer< + typeof ServerToClientStreamFrameSchema +>; + +/** + * Frames flowing from the client to the fresh-spawn server. + */ +export const ClientToServerStreamFrameSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("stdin"), + /** Base64-encoded UTF-8 bytes. */ + data: z.string(), + }), + z.object({ + type: z.literal("resize"), + cols: z.number().int().positive(), + rows: z.number().int().positive(), + }), + z.object({ + type: z.literal("signal"), + /** Signal name (e.g. "SIGINT", "SIGTERM"). */ + name: z.string().min(1), + }), +]); + +export type ClientToServerStreamFrame = z.infer< + typeof ClientToServerStreamFrameSchema +>; + +const FRESH_SPAWN_DIR = ".superset"; + +export const DEFAULT_SOCKET_PATH = path.join( + os.homedir(), + FRESH_SPAWN_DIR, + "fresh-spawn.sock", +); +export const DEFAULT_TOKEN_PATH = path.join( + os.homedir(), + FRESH_SPAWN_DIR, + "fresh-spawn.token", +); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3c0eabe04c6..03ee07fa278 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -211,6 +211,9 @@ app.on("before-quit", async (event) => { } catch (error) { console.error("[main] Cleanup during quit failed:", error); } + // The fresh-spawn UDS server now lives inside the terminal-host daemon + // (which is detached + unref'd), so it survives Electron app quit along + // with the PTY subprocesses it hosts. Nothing to clean up here. app.exit(0); }); @@ -296,6 +299,11 @@ if (!gotTheLock) { requestAppleEventsAccess(); requestLocalNetworkAccess(); + // The fresh-spawn UDS server now runs inside the terminal-host + // daemon (see apps/desktop/src/main/terminal-host/index.ts). Hosting + // it there means PTYs spawned through it become daemon grandchildren + // and survive Electron app quit along with the daemon itself. + // Must register on both default session and the app's custom partition const iconProtocolHandler = (request: Request) => { const url = new URL(request.url); diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts index 88cd78a3951..8aa6809a461 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts @@ -106,6 +106,14 @@ describe("shell-wrappers", () => { ); expect(zshrc).toContain("_superset_ensure_path()"); + // fresh-exec hook should be sourced (env-gated — inert unless + // SUPERSET_FRESH_EXEC_* env vars are all set by the main process). + expect(zshrc).toContain('-n "$SUPERSET_FRESH_EXEC_BIN"'); + expect(zshrc).toContain('-n "$SUPERSET_FRESH_EXEC_COMMANDS"'); + expect(zshrc).toContain('-n "$SUPERSET_FRESH_EXEC_HOOK_PATH"'); + expect(zshrc).toContain('-r "$SUPERSET_FRESH_EXEC_HOOK_PATH"'); + expect(zshrc).toContain('source "$SUPERSET_FRESH_EXEC_HOOK_PATH"'); + expect(zlogin).toContain("if [[ -o interactive ]]; then"); expect(zlogin).toContain('export ZDOTDIR="$_superset_home"'); expect(zlogin).toContain('source "$_superset_home/.zlogin"'); @@ -818,6 +826,110 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" }); }); + describe("fresh-exec hook sourcing", () => { + it("does not source the fresh-exec hook when env vars are unset", () => { + if (!isZshAvailable()) return; + + const integrationRoot = path.join(TEST_ROOT, "fresh-exec-unset"); + const integrationBinDir = path.join(integrationRoot, "superset-bin"); + const integrationZshDir = path.join(integrationRoot, "zsh"); + const integrationBashDir = path.join(integrationRoot, "bash"); + const homeDir = path.join(integrationRoot, "home"); + + mkdirSync(integrationBinDir, { recursive: true }); + mkdirSync(integrationZshDir, { recursive: true }); + mkdirSync(integrationBashDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + + // Hook file that sets a sentinel var; if we accidentally source + // it, the subsequent echo will reveal the sentinel. + const hookPath = path.join(integrationRoot, "zsh-fresh-exec.zsh"); + writeFileSync(hookPath, "export _SUPERSET_FRESH_EXEC_SOURCED=1\n"); + + createZshWrapper({ + BIN_DIR: integrationBinDir, + ZSH_DIR: integrationZshDir, + BASH_DIR: integrationBashDir, + }); + + const output = execFileSync( + "zsh", + // biome-ignore lint/suspicious/noTemplateCurlyInString: zsh parameter expansion, not a JS template literal + ["-lic", 'echo "[${_SUPERSET_FRESH_EXEC_SOURCED:-unset}]"'], + { + encoding: "utf-8", + env: { + HOME: homeDir, + PATH: "/usr/bin:/bin", + SUPERSET_ORIG_ZDOTDIR: homeDir, + ZDOTDIR: integrationZshDir, + // Intentionally do NOT set SUPERSET_FRESH_EXEC_* env vars. + }, + }, + ).trim(); + + const lines = output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + expect(lines[lines.length - 1]).toBe("[unset]"); + }); + + it("sources the fresh-exec hook when all env vars are set and hook is readable", () => { + if (!isZshAvailable()) return; + + const integrationRoot = path.join(TEST_ROOT, "fresh-exec-set"); + const integrationBinDir = path.join(integrationRoot, "superset-bin"); + const integrationZshDir = path.join(integrationRoot, "zsh"); + const integrationBashDir = path.join(integrationRoot, "bash"); + const homeDir = path.join(integrationRoot, "home"); + + mkdirSync(integrationBinDir, { recursive: true }); + mkdirSync(integrationZshDir, { recursive: true }); + mkdirSync(integrationBashDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + + // A stub fresh-exec binary (just needs to exist + be executable). + const freshExecBin = path.join(integrationBinDir, "fresh-exec"); + writeFileSync(freshExecBin, "#!/usr/bin/env bash\necho fresh-exec\n"); + chmodSync(freshExecBin, 0o755); + + // Hook file that sets a sentinel so we can verify it ran. + const hookPath = path.join(integrationRoot, "zsh-fresh-exec.zsh"); + writeFileSync(hookPath, "export _SUPERSET_FRESH_EXEC_SOURCED=1\n"); + + createZshWrapper({ + BIN_DIR: integrationBinDir, + ZSH_DIR: integrationZshDir, + BASH_DIR: integrationBashDir, + }); + + const output = execFileSync( + "zsh", + // biome-ignore lint/suspicious/noTemplateCurlyInString: zsh parameter expansion, not a JS template literal + ["-lic", 'echo "[${_SUPERSET_FRESH_EXEC_SOURCED:-unset}]"'], + { + encoding: "utf-8", + env: { + HOME: homeDir, + PATH: "/usr/bin:/bin", + SUPERSET_ORIG_ZDOTDIR: homeDir, + ZDOTDIR: integrationZshDir, + SUPERSET_FRESH_EXEC_BIN: freshExecBin, + SUPERSET_FRESH_EXEC_COMMANDS: "gh kubectl", + SUPERSET_FRESH_EXEC_HOOK_PATH: hookPath, + }, + }, + ).trim(); + + const lines = output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + expect(lines[lines.length - 1]).toBe("[1]"); + }); + }); + describe("fish shell", () => { it("uses fish-compatible managed command prelude for non-interactive commands", () => { const args = getCommandShellArgs( diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts index 3c8ae59f329..aaa4dacc37e 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -197,6 +197,16 @@ ${SUPERSET_ENV_RESTORE} ${buildPathPrependFunction(paths.BIN_DIR)} ${buildZshPrecmdHook(paths.BIN_DIR)} rehash 2>/dev/null || true +# Superset: route whitelisted commands through fresh-exec for Mach +# context isolation. Env-gated: hook is a no-op if +# SUPERSET_FRESH_EXEC_BIN / COMMANDS / HOOK_PATH are unset (non-darwin, +# dev mode without mirrored resources, or an older app bundle). +if [[ -n "$SUPERSET_FRESH_EXEC_BIN" \\ + && -n "$SUPERSET_FRESH_EXEC_COMMANDS" \\ + && -n "$SUPERSET_FRESH_EXEC_HOOK_PATH" \\ + && -r "$SUPERSET_FRESH_EXEC_HOOK_PATH" ]]; then + source "$SUPERSET_FRESH_EXEC_HOOK_PATH" +fi # Restore ZDOTDIR so our .zlogin runs after user's .zlogin export ZDOTDIR=${quotedZshDir} `; diff --git a/apps/desktop/src/main/lib/terminal/env.test.ts b/apps/desktop/src/main/lib/terminal/env.test.ts index a0c661abe2c..f910d2c084f 100644 --- a/apps/desktop/src/main/lib/terminal/env.test.ts +++ b/apps/desktop/src/main/lib/terminal/env.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { FRESH_EXEC_WHITELIST } from "shared/fresh-spawn-whitelist"; import { buildSafeEnv, buildTerminalEnv, @@ -9,6 +10,7 @@ import { resetTerminalEnvCachesForTests, SHELL_CRASH_THRESHOLD_MS, sanitizeEnv, + setFreshExecPathsForTests, } from "./env"; describe("env", () => { @@ -743,6 +745,64 @@ describe("env", () => { }); }); + describe("fresh-exec shell hook env vars", () => { + afterEach(() => { + setFreshExecPathsForTests(null); + }); + + it("exports SUPERSET_FRESH_EXEC_* when binary and hook both resolve (darwin)", () => { + if (process.platform !== "darwin") return; + setFreshExecPathsForTests({ + bin: "/tmp/fresh-exec.js", + hook: "/tmp/zsh-fresh-exec.zsh", + }); + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_FRESH_EXEC_BIN).toBe("/tmp/fresh-exec.js"); + expect(result.SUPERSET_FRESH_EXEC_HOOK_PATH).toBe( + "/tmp/zsh-fresh-exec.zsh", + ); + expect(result.SUPERSET_FRESH_EXEC_COMMANDS).toBe( + FRESH_EXEC_WHITELIST.join(" "), + ); + }); + + it("omits SUPERSET_FRESH_EXEC_* when binary is missing", () => { + if (process.platform !== "darwin") return; + setFreshExecPathsForTests({ + bin: null, + hook: "/tmp/zsh-fresh-exec.zsh", + }); + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_FRESH_EXEC_BIN).toBeUndefined(); + expect(result.SUPERSET_FRESH_EXEC_HOOK_PATH).toBeUndefined(); + expect(result.SUPERSET_FRESH_EXEC_COMMANDS).toBeUndefined(); + }); + + it("omits SUPERSET_FRESH_EXEC_* when hook is missing", () => { + if (process.platform !== "darwin") return; + setFreshExecPathsForTests({ + bin: "/tmp/fresh-exec.js", + hook: null, + }); + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_FRESH_EXEC_BIN).toBeUndefined(); + expect(result.SUPERSET_FRESH_EXEC_HOOK_PATH).toBeUndefined(); + expect(result.SUPERSET_FRESH_EXEC_COMMANDS).toBeUndefined(); + }); + + it("omits SUPERSET_FRESH_EXEC_* on non-darwin even when resolvers would return paths", () => { + if (process.platform === "darwin") return; + setFreshExecPathsForTests({ + bin: "/tmp/fresh-exec.js", + hook: "/tmp/zsh-fresh-exec.zsh", + }); + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_FRESH_EXEC_BIN).toBeUndefined(); + expect(result.SUPERSET_FRESH_EXEC_HOOK_PATH).toBeUndefined(); + expect(result.SUPERSET_FRESH_EXEC_COMMANDS).toBeUndefined(); + }); + }); + describe("COLORFGBG for light mode detection", () => { it("should set COLORFGBG to dark mode by default", () => { const result = buildTerminalEnv(baseParams); diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 883582ba935..afedc04ed22 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -1,8 +1,14 @@ import { exec } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import defaultShell from "default-shell"; import { env } from "shared/env.shared"; +import { FRESH_EXEC_WHITELIST } from "shared/fresh-spawn-whitelist"; +import { + resolveFreshExecBinaryPath, + resolveFreshExecHookPath, +} from "../../fresh-spawn/paths"; import { getShellEnv } from "../agent-setup/shell-wrappers"; const MACOS_SYSTEM_CERT_FILE = "/etc/ssl/cert.pem"; @@ -169,11 +175,71 @@ function hasMacosSystemCertBundle(): boolean { return cachedMacosSystemCertAvailable; } +let cachedFreshExecPaths: { + bin: string | null; + hook: string | null; +} | null = null; + export function resetTerminalEnvCachesForTests(): void { cachedProcessEnvSnapshot = null; cachedMacosSystemCertAvailable = null; cachedUtf8Locale = null; localeProbeInFlight = false; + cachedFreshExecPaths = null; +} + +/** + * Optional injection point for tests. In production code paths we resolve + * fresh-exec binary + hook from __dirname / process.resourcesPath; tests + * can stub the resolution to avoid depending on bundle layout. + */ +export interface FreshExecPaths { + bin: string | null; + hook: string | null; +} + +export function setFreshExecPathsForTests(paths: FreshExecPaths | null): void { + cachedFreshExecPaths = paths; +} + +function getFreshExecPaths(): FreshExecPaths { + if (cachedFreshExecPaths) return cachedFreshExecPaths; + + // After bundling, __dirname resolves to dist/main. fresh-exec.js is + // emitted into the same directory by the main build. + const mainDir = __dirname; + const bin = resolveFreshExecBinaryPath(mainDir); + + // Hook location candidates (first match wins): + // 1. Packaged app: process.resourcesPath/resources/shell-hooks/... + // 2. Preview/dev after bundle: ../resources relative to __dirname + // 3. Dev source tree: ../../src/resources/shell-hooks/... (fallback + // for the rare case where dev mode does not mirror resources) + // For each candidate directory, also probe the asar.unpacked twin — + // the hook is consumed by zsh as an external process, which sees the + // real filesystem only and cannot traverse Electron's asar-patched + // `fs`. Without this, `__dirname + '../resources'` resolves to the + // asar-interior path which `fs.existsSync` accepts inside main but + // which zsh then cannot `source` (production-only silent failure). + const expandAsarUnpacked = (dir: string): string[] => { + const asarInside = `${path.sep}app.asar${path.sep}`; + const asarUnpacked = `${path.sep}app.asar.unpacked${path.sep}`; + return dir.includes(asarInside) + ? [dir.replace(asarInside, asarUnpacked), dir] + : [dir]; + }; + const rawSearchDirs: string[] = []; + if (process.resourcesPath) { + rawSearchDirs.push(path.join(process.resourcesPath, "resources")); + } + rawSearchDirs.push(path.join(mainDir, "..", "resources")); + rawSearchDirs.push(path.join(mainDir, "..", "..", "src", "resources")); + + const searchDirs = rawSearchDirs.flatMap(expandAsarUnpacked); + + const hook = resolveFreshExecHookPath(searchDirs); + cachedFreshExecPaths = { bin, hook }; + return cachedFreshExecPaths; } /** @@ -494,5 +560,19 @@ export function buildTerminalEnv(params: { terminalEnv.SSL_CERT_FILE = MACOS_SYSTEM_CERT_FILE; } + // fresh-exec shell hook integration (macOS only). When both the + // helper binary and the zsh hook exist on disk, export the env vars + // the hook needs so our managed zsh sessions intercept whitelisted + // commands. On missing assets (e.g. dev mode before bundle mirroring + // runs), the hook stays inert because its env-gate returns 0. + if (os.platform() === "darwin") { + const freshExec = getFreshExecPaths(); + if (freshExec.bin && freshExec.hook) { + terminalEnv.SUPERSET_FRESH_EXEC_BIN = freshExec.bin; + terminalEnv.SUPERSET_FRESH_EXEC_COMMANDS = FRESH_EXEC_WHITELIST.join(" "); + terminalEnv.SUPERSET_FRESH_EXEC_HOOK_PATH = freshExec.hook; + } + } + return terminalEnv; } diff --git a/apps/desktop/src/main/terminal-host/fresh-spawn-integration.test.ts b/apps/desktop/src/main/terminal-host/fresh-spawn-integration.test.ts new file mode 100644 index 00000000000..700eb0d95d1 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/fresh-spawn-integration.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + type SpawnServer, + startSpawnServer, +} from "../fresh-spawn/spawn-server"; +import { trySpawnViaFreshServer } from "./fresh-spawn-integration"; + +describe("trySpawnViaFreshServer", () => { + let server: SpawnServer | null = null; + let tmpDir = ""; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true }); + tmpDir = ""; + } + }); + + it("returns null when socket path does not exist", async () => { + const result = await trySpawnViaFreshServer({ + socketPath: "/nonexistent/path.sock", + tokenPath: "/nonexistent/path.token", + env: {}, + }); + expect(result).toBeNull(); + }); + + it("returns null when token path does not exist", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-int-tokenmissing-")); + const sockPath = path.join(tmpDir, "s.sock"); + // Create a placeholder "socket" (empty file) so the socket existence + // check passes but the token existence check fails. + fs.writeFileSync(sockPath, ""); + + const result = await trySpawnViaFreshServer({ + socketPath: sockPath, + tokenPath: path.join(tmpDir, "nonexistent.token"), + env: {}, + }); + expect(result).toBeNull(); + }); + + it("returns null when platform is not darwin", async () => { + if (process.platform !== "darwin") { + const result = await trySpawnViaFreshServer({ + socketPath: "/tmp/whatever.sock", + tokenPath: "/tmp/whatever.token", + env: {}, + }); + expect(result).toBeNull(); + } else { + // skip on macOS + expect(true).toBe(true); + } + }); + + it("returns SpawnSession when server is reachable", async () => { + if (process.platform !== "darwin") return; // skip non-darwin + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-int-")); + const echoPath = path.join(tmpDir, "echo.js"); + fs.writeFileSync( + echoPath, + `process.stdin.on("data", (d) => process.stdout.write(d)); +process.stdin.on("end", () => process.exit(0)); +`, + ); + const sockPath = path.join(tmpDir, "s.sock"); + const tokenPath = path.join(tmpDir, "s.token"); + + server = await startSpawnServer({ + socketPath: sockPath, + tokenPath, + subprocessScriptPath: echoPath, + }); + + const session = await trySpawnViaFreshServer({ + socketPath: sockPath, + tokenPath, + env: {}, + }); + + expect(session).not.toBeNull(); + expect(session?.pid).toBeGreaterThan(0); + + // Clean up: kill it so child doesn't linger + session?.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 200)); + }, 10000); +}); diff --git a/apps/desktop/src/main/terminal-host/fresh-spawn-integration.ts b/apps/desktop/src/main/terminal-host/fresh-spawn-integration.ts new file mode 100644 index 00000000000..824c9e321b1 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/fresh-spawn-integration.ts @@ -0,0 +1,51 @@ +import * as fs from "node:fs"; +import { + openSpawnSession, + type SpawnSession, +} from "../fresh-spawn/spawn-session"; +import { DEFAULT_SOCKET_PATH, DEFAULT_TOKEN_PATH } from "../fresh-spawn/types"; + +export interface TrySpawnViaFreshServerOptions { + socketPath?: string; + tokenPath?: string; + env: Record; + /** Connect + handshake timeout. Default 2000ms (fast fallback). */ + handshakeTimeoutMs?: number; +} + +/** + * Attempts to spawn a pty-subprocess via the fresh-spawn server hosted + * inside the terminal-host daemon. Returns a SpawnSession + * (ChildProcess-compatible) on success, or null if the server is + * unavailable (caller should fall back to direct spawn). + * + * Never throws; any failure (non-macOS, socket missing, connect error, + * handshake timeout) returns null with a console warning so the caller can + * silently degrade. + */ +export async function trySpawnViaFreshServer( + options: TrySpawnViaFreshServerOptions, +): Promise { + if (process.platform !== "darwin") return null; + + const socketPath = options.socketPath ?? DEFAULT_SOCKET_PATH; + const tokenPath = options.tokenPath ?? DEFAULT_TOKEN_PATH; + + if (!fs.existsSync(socketPath)) return null; + if (!fs.existsSync(tokenPath)) return null; + + try { + return await openSpawnSession({ + socketPath, + tokenPath, + env: options.env, + handshakeTimeoutMs: options.handshakeTimeoutMs ?? 2000, + }); + } catch (err) { + console.warn( + "[fresh-spawn] falling back to direct spawn:", + err instanceof Error ? err.message : String(err), + ); + return null; + } +} diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index ac5b1583bc2..98435e80935 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -25,6 +25,10 @@ import { createServer, type Server, Socket } from "node:net"; import { homedir } from "node:os"; import { join } from "node:path"; import { SUPERSET_DIR_NAME } from "shared/constants"; +import { + startFreshSpawnServer, + stopFreshSpawnServer, +} from "../fresh-spawn/lifecycle"; import { type CancelCreateOrAttachRequest, type ClearScrollbackRequest, @@ -779,9 +783,34 @@ async function startServer(): Promise { resolve(); }); }); + + // Start the fresh-spawn UDS server inside the daemon. + // + // Hosting the server here (instead of in Electron main) means PTYs + // spawned through it live under the detached + unref'd daemon process, + // so they survive Electron app quit — satisfying the "terminal survives + // app restart" promise. Fresh Mach bootstrap context is still achieved: + // the daemon was itself spawned by Electron main at launch (fresh + // context at that moment), so its children inherit a clean context. + // + // The pty-subprocess.js bundle is emitted by electron-vite into the + // same `dist/main/` directory as this entry file, so resolving it + // relative to `__dirname` works in both dev and packaged builds. + // + // Errors are swallowed inside startFreshSpawnServer (it only logs); a + // failed start degrades cleanly because client code falls back to + // direct spawn when the socket is missing. + await startFreshSpawnServer({ + subprocessScriptPath: join(__dirname, "pty-subprocess.js"), + }); } async function stopServer(): Promise { + // Stop the fresh-spawn UDS server first so no new PTY spawns are + // accepted while we're tearing down. `stopFreshSpawnServer` never + // throws — it logs and swallows errors internally. + await stopFreshSpawnServer(); + if (terminalHost) { await terminalHost.dispose(); log("info", "Terminal host disposed"); diff --git a/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts b/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts index 6d5876eb8fe..ea2e62e4d70 100644 --- a/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts +++ b/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts @@ -108,11 +108,16 @@ function createTestSession(shell: string): { } /** Spawn a session and make it ready for writes. */ -function spawnAndReady( +async function spawnAndReady( session: InstanceType, proc: FakeChildProcess, -): void { - session.spawn({ cwd: "/tmp", cols: 80, rows: 24, env: { PATH: "/usr/bin" } }); +): Promise { + await session.spawn({ + cwd: "/tmp", + cols: 80, + rows: 24, + env: { PATH: "/usr/bin" }, + }); sendReady(proc); sendSpawned(proc); } @@ -122,9 +127,9 @@ function spawnAndReady( // ============================================================================= describe("Session shell-ready: write pass-through", () => { - it("passes writes through immediately while shell is pending (#3478)", () => { + it("passes writes through immediately while shell is pending (#3478)", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); // User keystrokes answering a shell-init prompt (e.g. fnm's // "install missing Node version?") must reach the PTY without @@ -139,9 +144,9 @@ describe("Session shell-ready: write pass-through", () => { expect(getWrittenData(proc)).toEqual(["y\n", "echo ready\n"]); }); - it("passes writes through immediately for unsupported shells (sh)", () => { + it("passes writes through immediately for unsupported shells (sh)", async () => { const { session, proc } = createTestSession("/bin/sh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); session.write("echo hello\n"); @@ -149,18 +154,18 @@ describe("Session shell-ready: write pass-through", () => { expect(writes).toEqual(["echo hello\n"]); }); - it("passes writes through immediately for unsupported shells (ksh)", () => { + it("passes writes through immediately for unsupported shells (ksh)", async () => { const { session, proc } = createTestSession("/bin/ksh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); session.write("ls\n"); expect(getWrittenData(proc)).toEqual(["ls\n"]); }); - it("drops terminal protocol responses (DA) during pending state", () => { + it("drops terminal protocol responses (DA) during pending state", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); // Simulate DA response from renderer xterm arriving during init session.write("\x1b[?62;4;9;22c"); @@ -178,9 +183,9 @@ describe("Session shell-ready: write pass-through", () => { expect(getWrittenData(proc)).toEqual(["claude\n"]); }); - it("forwards escape sequences once shell is ready", () => { + it("forwards escape sequences once shell is ready", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); sendData(proc, SHELL_READY_MARKER); @@ -192,9 +197,9 @@ describe("Session shell-ready: write pass-through", () => { }); describe("Session shell-ready: marker detection", () => { - it("strips marker from single data frame", () => { + it("strips marker from single data frame", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); // Send data with marker embedded sendData(proc, `before${SHELL_READY_MARKER}after`); @@ -204,9 +209,9 @@ describe("Session shell-ready: marker detection", () => { expect(getWrittenData(proc)).toEqual(["test\n"]); }); - it("detects marker split across two PTY data frames", () => { + it("detects marker split across two PTY data frames", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); // Split the marker roughly in half const half = Math.floor(SHELL_READY_MARKER.length / 2); @@ -228,9 +233,9 @@ describe("Session shell-ready: marker detection", () => { expect(getWrittenData(proc)).toEqual(["first\n", "second\n"]); }); - it("handles marker at start of data frame", () => { + it("handles marker at start of data frame", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); sendData(proc, `${SHELL_READY_MARKER}prompt$ `); @@ -238,9 +243,9 @@ describe("Session shell-ready: marker detection", () => { expect(getWrittenData(proc)).toEqual(["test\n"]); }); - it("handles marker at end of data frame", () => { + it("handles marker at end of data frame", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); sendData(proc, `direnv: loading .envrc\n${SHELL_READY_MARKER}`); @@ -248,9 +253,9 @@ describe("Session shell-ready: marker detection", () => { expect(getWrittenData(proc)).toEqual(["test\n"]); }); - it("handles data that looks like marker start but isn't", () => { + it("handles data that looks like marker start but isn't", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); // Send a partial marker prefix followed by different content const partialMarker = SHELL_READY_MARKER.slice(0, 5); @@ -272,9 +277,9 @@ describe("Session shell-ready: marker detection", () => { // against a future wrapper regression that swaps the order (which would // leave 133;A in the pre-777 slice and still work) or drops 133;A // entirely (which would regress readiness on the current scanner). - it("resolves readiness when wrapper emits both 777 and 133;A markers together", () => { + it("resolves readiness when wrapper emits both 777 and 133;A markers together", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); const COMBINED_MARKER = "\x1b]777;superset-shell-ready\x07\x1b]133;A\x07"; sendData(proc, `direnv output...${COMBINED_MARKER}prompt$ `); @@ -287,9 +292,9 @@ describe("Session shell-ready: marker detection", () => { }); describe("Session shell-ready: kill/exit before readiness", () => { - it("accepts writes when subprocess exits before marker", () => { + it("accepts writes when subprocess exits before marker", async () => { const { session, proc } = createTestSession("/bin/bash"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); // Writes pass through even during pending. session.write("echo pending\n"); @@ -303,9 +308,9 @@ describe("Session shell-ready: kill/exit before readiness", () => { expect(getWrittenData(proc)).toEqual(["echo pending\n"]); }); - it("accepts writes when session is killed before marker", () => { + it("accepts writes when session is killed before marker", async () => { const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); session.write("echo pending\n"); expect(getWrittenData(proc)).toEqual(["echo pending\n"]); @@ -327,9 +332,9 @@ describe("Session shell-ready: supported shells", () => { "/bin/bash", "/usr/local/bin/fish", ]) { - it(`passes writes through while pending for supported shell: ${shell}`, () => { + it(`passes writes through while pending for supported shell: ${shell}`, async () => { const { session, proc } = createTestSession(shell); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); session.write("test\n"); expect(getWrittenData(proc)).toEqual(["test\n"]); @@ -340,9 +345,9 @@ describe("Session shell-ready: supported shells", () => { } for (const shell of ["/bin/sh", "/bin/ksh", "/usr/bin/dash"]) { - it(`passes writes through for unsupported shell: ${shell}`, () => { + it(`passes writes through for unsupported shell: ${shell}`, async () => { const { session, proc } = createTestSession(shell); - spawnAndReady(session, proc); + await spawnAndReady(session, proc); session.write("test\n"); expect(getWrittenData(proc)).toEqual(["test\n"]); diff --git a/apps/desktop/src/main/terminal-host/session.test.ts b/apps/desktop/src/main/terminal-host/session.test.ts index 8163a4cb657..dabde1f8ceb 100644 --- a/apps/desktop/src/main/terminal-host/session.test.ts +++ b/apps/desktop/src/main/terminal-host/session.test.ts @@ -87,8 +87,10 @@ function getSpawnPayload(fakeChild: FakeChildProcess) { }; } -function spawnAndReadySession(session: InstanceType): void { - session.spawn({ +async function spawnAndReadySession( + session: InstanceType, +): Promise { + await session.spawn({ cwd: "/tmp", cols: 80, rows: 24, @@ -104,7 +106,7 @@ describe("Terminal Host Session shell args", () => { spawnCalls = []; }); - it("sends bash --rcfile args in spawn payload", () => { + it("sends bash --rcfile args in spawn payload", async () => { const session = new Session({ sessionId: "session-bash-args", workspaceId: "workspace-1", @@ -120,7 +122,7 @@ describe("Terminal Host Session shell args", () => { }, }); - session.spawn({ + await session.spawn({ cwd: "/tmp", cols: 80, rows: 24, @@ -137,7 +139,7 @@ describe("Terminal Host Session shell args", () => { ); }); - it("uses -lc command args when command is provided", () => { + it("uses -lc command args when command is provided", async () => { const session = new Session({ sessionId: "session-command-args", workspaceId: "workspace-1", @@ -154,7 +156,7 @@ describe("Terminal Host Session shell args", () => { }, }); - session.spawn({ + await session.spawn({ cwd: "/tmp", cols: 80, rows: 24, @@ -188,7 +190,7 @@ describe("Terminal Host Session shell args", () => { }, }); - session.spawn({ + await session.spawn({ cwd: "/tmp", cols: 80, rows: 24, @@ -270,7 +272,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { spawnCalls = []; }); - it("pauses subprocess stdout when emulator backlog exceeds the watermark without attached clients", () => { + it("pauses subprocess stdout when emulator backlog exceeds the watermark without attached clients", async () => { const session = new Session({ sessionId: "session-emulator-backpressure", workspaceId: "workspace-1", @@ -283,7 +285,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { spawnProcess: () => fakeChildProcess as unknown as ChildProcess, }); - spawnAndReadySession(session); + await spawnAndReadySession(session); ( session as unknown as { @@ -295,7 +297,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { expect(fakeChildProcess.stdout.resumeCalls).toBe(0); }); - it("resumes subprocess stdout once emulator backlog drains below the low watermark", () => { + it("resumes subprocess stdout once emulator backlog drains below the low watermark", async () => { const session = new Session({ sessionId: "session-emulator-resume", workspaceId: "workspace-1", @@ -308,7 +310,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { spawnProcess: () => fakeChildProcess as unknown as ChildProcess, }); - spawnAndReadySession(session); + await spawnAndReadySession(session); const internals = session as unknown as { enqueueEmulatorWrite: (data: string) => void; @@ -325,7 +327,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { expect(fakeChildProcess.stdout.resumeCalls).toBe(1); }); - it("keeps queued byte accounting exact when chunking across a surrogate pair boundary", () => { + it("keeps queued byte accounting exact when chunking across a surrogate pair boundary", async () => { const session = new Session({ sessionId: "session-surrogate-pair-backpressure", workspaceId: "workspace-1", @@ -338,7 +340,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { spawnProcess: () => fakeChildProcess as unknown as ChildProcess, }); - spawnAndReadySession(session); + await spawnAndReadySession(session); const internals = session as unknown as { enqueueEmulatorWrite: (data: string) => void; @@ -354,7 +356,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { expect(internals.emulatorWriteQueuedBytes).toBe(0); }); - it("keeps subprocess stdout paused until client drain clears too", () => { + it("keeps subprocess stdout paused until client drain clears too", async () => { const session = new Session({ sessionId: "session-combined-backpressure", workspaceId: "workspace-1", @@ -367,7 +369,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { spawnProcess: () => fakeChildProcess as unknown as ChildProcess, }); - spawnAndReadySession(session); + await spawnAndReadySession(session); const socket = new EventEmitter() as import("node:net").Socket; const internals = session as unknown as { @@ -390,7 +392,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { expect(fakeChildProcess.stdout.resumeCalls).toBe(1); }); - it("resumes subprocess stdout when a backpressured client disconnects before drain", () => { + it("resumes subprocess stdout when a backpressured client disconnects before drain", async () => { for (const eventName of ["close", "error"] as const) { fakeChildProcess = new FakeChildProcess(); spawnCalls = []; @@ -407,7 +409,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { spawnProcess: () => fakeChildProcess as unknown as ChildProcess, }); - spawnAndReadySession(session); + await spawnAndReadySession(session); const socket = new EventEmitter() as import("node:net").Socket; const internals = session as unknown as { @@ -438,7 +440,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { } }); - it("resumes subprocess stdout when the last backpressured client throws during broadcast", () => { + it("resumes subprocess stdout when the last backpressured client throws during broadcast", async () => { const session = new Session({ sessionId: "session-dead-socket-backpressure", workspaceId: "workspace-1", @@ -451,7 +453,7 @@ describe("Terminal Host Session emulator backlog backpressure", () => { spawnProcess: () => fakeChildProcess as unknown as ChildProcess, }); - spawnAndReadySession(session); + await spawnAndReadySession(session); const badSocket = new EventEmitter() as import("node:net").Socket & { write: (_message: string) => boolean; diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 67148e927f8..a306596b946 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -36,6 +36,7 @@ import type { TerminalSnapshot, } from "../lib/terminal-host/types"; import { treeKillAsync } from "../lib/tree-kill"; +import { trySpawnViaFreshServer } from "./fresh-spawn-integration"; import { createFrameHeader, PtySubprocessFrameDecoder, @@ -239,14 +240,19 @@ export class Session { } /** - * Spawn the PTY process via subprocess + * Spawn the PTY process via subprocess. + * + * Tries fresh-spawn first (macOS, when Electron main's spawn server is + * reachable) to get a subprocess with a fresh Mach bootstrap context. + * Falls back to direct child_process.spawn in all other cases + * (non-macOS, daemon running standalone, server unreachable). */ - spawn(options: { + async spawn(options: { cwd: string; cols: number; rows: number; env?: Record; - }): void { + }): Promise { if (this.subprocess) { throw new Error("PTY already spawned"); } @@ -264,13 +270,26 @@ export class Session { : getShellArgs(this.shell); const subprocessPath = path.join(__dirname, "pty-subprocess.js"); - // Spawn subprocess with filtered env to prevent leaking NODE_ENV etc. - const electronPath = process.execPath; - this.subprocess = this.spawnProcess(electronPath, [subprocessPath], { - stdio: ["pipe", "pipe", "inherit"], - env: { ...processEnv, ELECTRON_RUN_AS_NODE: "1" }, + // Try fresh-spawn first (macOS + server running); fall back to direct spawn. + // SpawnSession implements a ChildProcess-compatible surface (stdin/stdout/ + // stderr streams, pid, kill, "exit"/"error" events), so the downstream + // wiring below is identical for both paths. + const subprocessEnv = { ...processEnv, ELECTRON_RUN_AS_NODE: "1" }; + const freshSession = await trySpawnViaFreshServer({ + env: subprocessEnv, }); + if (freshSession) { + this.subprocess = freshSession as unknown as ChildProcess; + } else { + // Spawn subprocess with filtered env to prevent leaking NODE_ENV etc. + const electronPath = process.execPath; + this.subprocess = this.spawnProcess(electronPath, [subprocessPath], { + stdio: ["pipe", "pipe", "inherit"], + env: subprocessEnv, + }); + } + // Read framed messages from subprocess stdout if (this.subprocess.stdout) { this.subprocessDecoder = new PtySubprocessFrameDecoder(); @@ -290,9 +309,9 @@ export class Session { } // Handle subprocess exit - this.subprocess.on("exit", (code) => { + this.subprocess.on("exit", (code, signal) => { console.log( - `[Session ${this.sessionId}] Subprocess exited with code ${code}`, + `[Session ${this.sessionId}] Subprocess exited with code=${code} signal=${signal}`, ); this.handleSubprocessExit(code ?? -1); }); diff --git a/apps/desktop/src/main/terminal-host/signal-handlers.ts b/apps/desktop/src/main/terminal-host/signal-handlers.ts index 1bb63f84db9..88d486f856e 100644 --- a/apps/desktop/src/main/terminal-host/signal-handlers.ts +++ b/apps/desktop/src/main/terminal-host/signal-handlers.ts @@ -111,6 +111,7 @@ export function setupTerminalHostSignalHandlers({ }); }; + // SIGINT: honor (Ctrl+C in debugger / interactive session). process.on("SIGINT", () => { shutdownOnce({ exitCode: 0, @@ -119,21 +120,27 @@ export function setupTerminalHostSignalHandlers({ timeoutMessage: "Forced exit after SIGINT shutdown timeout", }); }); + // SIGTERM + SIGHUP: intentionally ignored (full nohup semantics). + // + // The daemon is spawned `detached: true` + `child.unref()` so it can + // outlive Electron's exit — the marketing blog "Terminal That (Almost) + // Never Dies" promise. On macOS, `setsid()` only isolates the Unix SID; + // the daemon still shares the Mach bootstrap / login Security Session + // with its parent. When Electron tears down that session, macOS may + // propagate BOTH SIGHUP and SIGTERM to the daemon depending on how quit + // is initiated (Cmd+Q, NSApplication terminate:, launchd reap, force + // kill). Ignoring both prevents the daemon dying on routine app quit. + // + // Intentional daemon shutdown goes through the explicit `shutdown` RPC + // (terminal-host/index.ts), which calls stopServer() directly, bypassing + // signal handlers. `killDaemonFromPidFile()` in client.ts uses SIGKILL + // which this handler cannot intercept, providing a reliable kill path + // when needed (stale auth token, dev-mode rebuild). process.on("SIGTERM", () => { - shutdownOnce({ - exitCode: 0, - message: "Received SIGTERM, shutting down...", - stopServerErrorMessage: "Error during stopServer in SIGTERM shutdown", - timeoutMessage: "Forced exit after SIGTERM shutdown timeout", - }); + log("info", "Received SIGTERM; ignoring (daemon survival semantics)"); }); process.on("SIGHUP", () => { - shutdownOnce({ - exitCode: 0, - message: "Received SIGHUP, shutting down...", - stopServerErrorMessage: "Error during stopServer in SIGHUP shutdown", - timeoutMessage: "Forced exit after SIGHUP shutdown timeout", - }); + log("info", "Received SIGHUP; ignoring (daemon survival semantics)"); }); process.on("uncaughtException", (error) => { diff --git a/apps/desktop/src/main/terminal-host/terminal-host.test.ts b/apps/desktop/src/main/terminal-host/terminal-host.test.ts index 776f593e0f7..66956685d27 100644 --- a/apps/desktop/src/main/terminal-host/terminal-host.test.ts +++ b/apps/desktop/src/main/terminal-host/terminal-host.test.ts @@ -98,7 +98,7 @@ describe("TerminalHost — PTY spawn failure handling", () => { spawnProcess: () => fakeChild as unknown as ChildProcess, }); - session.spawn({ + await session.spawn({ cwd: "/tmp", cols: 80, rows: 24, @@ -130,7 +130,7 @@ describe("TerminalHost — PTY spawn failure handling", () => { spawnProcess: () => fakeChild as unknown as ChildProcess, }); - session.spawn({ + await session.spawn({ cwd: "/tmp", cols: 80, rows: 24, @@ -162,7 +162,7 @@ describe("TerminalHost — PTY spawn failure handling", () => { spawnProcess: () => fakeChild as unknown as ChildProcess, }); - session.spawn({ + await session.spawn({ cwd: "/tmp", cols: 80, rows: 24, @@ -200,7 +200,7 @@ describe("TerminalHost — PTY spawn failure handling", () => { spawnProcess: () => fakeChild as unknown as ChildProcess, }); - session.spawn({ + await session.spawn({ cwd: "/tmp", cols: 80, rows: 24, diff --git a/apps/desktop/src/main/terminal-host/terminal-host.ts b/apps/desktop/src/main/terminal-host/terminal-host.ts index b84bdcd0fe8..3a0d2652f99 100644 --- a/apps/desktop/src/main/terminal-host/terminal-host.ts +++ b/apps/desktop/src/main/terminal-host/terminal-host.ts @@ -130,7 +130,7 @@ export class TerminalHost { this.handleSessionExit(id, exitCode, signal); }); - session.spawn({ + await session.spawn({ cwd: request.cwd || process.env.HOME || "/", cols: request.cols, rows: request.rows, diff --git a/apps/desktop/src/resources/shell-hooks/zsh-fresh-exec.zsh b/apps/desktop/src/resources/shell-hooks/zsh-fresh-exec.zsh new file mode 100644 index 00000000000..be37a489772 --- /dev/null +++ b/apps/desktop/src/resources/shell-hooks/zsh-fresh-exec.zsh @@ -0,0 +1,56 @@ +# fresh-exec shell hook for Superset terminals +# ---------------------------------------------- +# Shadows a whitelist of Go-based CLIs with zsh functions that re-invoke +# them through the `fresh-exec` helper. Purpose: run selected commands +# through the daemon-owned fresh-spawn UDS server so they inherit the +# terminal-host daemon's clean macOS security context (rather than the +# stale bootstrap context of the shell they were typed in). +# +# Environment variables: +# SUPERSET_FRESH_EXEC_COMMANDS Space-separated whitelist. Superset +# sets this when sourcing the hook; if +# unset, the hook is a no-op. +# SUPERSET_FRESH_EXEC_BIN Path to the fresh-exec binary. Superset +# sets this to the packaged location; +# if unset or non-executable, the hook +# leaves commands untouched. +# SUPERSET_FRESH_EXEC_ACTIVE Set by fresh-exec itself to mark the +# subprocess environment; the hook skips +# when this is set to avoid recursion. +# +# Usage in plain zsh (not via Superset): don't source it; nothing +# interesting happens without the env vars above set. +# +# Bypass: `command ` bypasses the function override and runs the +# real binary directly (stale context; TLS commands will fail). Useful +# for debugging. Note that `\` does NOT bypass — backslash +# quoting suppresses alias expansion in zsh, but functions are still +# resolved after quote removal. + +# Skip when essential env vars are absent +if [[ -z "$SUPERSET_FRESH_EXEC_COMMANDS" ]] \ + || [[ -z "$SUPERSET_FRESH_EXEC_BIN" ]] \ + || [[ ! -x "$SUPERSET_FRESH_EXEC_BIN" ]] \ + || [[ -n "$SUPERSET_FRESH_EXEC_ACTIVE" ]]; then + return 0 +fi + +for _superset_cmd in ${(z)SUPERSET_FRESH_EXEC_COMMANDS}; do + # Validate before `eval` — SUPERSET_FRESH_EXEC_COMMANDS is set by the + # Superset process, but the env is inherited by user shell init (.zshrc, + # direnv, asdf, etc.) that can rewrite arbitrary env vars. An entry like + # "a;rm -rf ~" would otherwise produce shell injection through eval. + [[ $_superset_cmd =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || continue + # Define a shell function with the same name, shadowing the binary. + eval " + function ${_superset_cmd}() { + if [[ -x \"\$SUPERSET_FRESH_EXEC_BIN\" ]]; then + SUPERSET_FRESH_EXEC_ACTIVE=1 \"\$SUPERSET_FRESH_EXEC_BIN\" ${_superset_cmd} \"\$@\" + else + command ${_superset_cmd} \"\$@\" + fi + } + " +done + +unset _superset_cmd diff --git a/apps/desktop/src/shared/fresh-spawn-whitelist.test.ts b/apps/desktop/src/shared/fresh-spawn-whitelist.test.ts new file mode 100644 index 00000000000..86b89926810 --- /dev/null +++ b/apps/desktop/src/shared/fresh-spawn-whitelist.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { FRESH_EXEC_WHITELIST } from "./fresh-spawn-whitelist"; + +describe("FRESH_EXEC_WHITELIST", () => { + it("is non-empty", () => { + expect(FRESH_EXEC_WHITELIST.length).toBeGreaterThan(0); + }); + + it("is sorted alphabetically", () => { + const sorted = [...FRESH_EXEC_WHITELIST].sort(); + expect([...FRESH_EXEC_WHITELIST]).toEqual(sorted); + }); + + it("contains gh", () => { + expect(FRESH_EXEC_WHITELIST).toContain("gh"); + }); + + it("has no duplicates", () => { + const set = new Set(FRESH_EXEC_WHITELIST); + expect(set.size).toBe(FRESH_EXEC_WHITELIST.length); + }); + + it("contains only short, lowercase, simple command names", () => { + for (const cmd of FRESH_EXEC_WHITELIST) { + expect(cmd).toMatch(/^[a-z][a-z0-9_-]*$/); + expect(cmd.length).toBeLessThan(40); + } + }); +}); diff --git a/apps/desktop/src/shared/fresh-spawn-whitelist.ts b/apps/desktop/src/shared/fresh-spawn-whitelist.ts new file mode 100644 index 00000000000..778b53e8bdb --- /dev/null +++ b/apps/desktop/src/shared/fresh-spawn-whitelist.ts @@ -0,0 +1,20 @@ +/** + * Commands routed through fresh-exec when typed in a stale terminal. + * + * These are Go-based CLIs whose macOS TLS path goes through trustd/Security.framework + * and therefore fails with OSStatus -26276 when inherited from a stale + * Mach bootstrap context. Wrapping them in fresh-exec reruns them in the + * Electron main process's fresh context. + * + * Keep sorted. Do NOT add interactive TUIs (vim, less, top) — those run + * fine in stale context and wrapping them adds a pointless UDS hop. + */ +export const FRESH_EXEC_WHITELIST = [ + "gh", + "kubectl", + "terraform", + "terragrunt", + "tofu", +] as const; + +export type FreshExecWhitelistCommand = (typeof FRESH_EXEC_WHITELIST)[number]; diff --git a/apps/desktop/vite/helpers.ts b/apps/desktop/vite/helpers.ts index b2552e2dba6..ae0621266eb 100644 --- a/apps/desktop/vite/helpers.ts +++ b/apps/desktop/vite/helpers.ts @@ -36,6 +36,14 @@ const RESOURCES_TO_COPY = [ src: resolve(__dirname, "..", resources, "browser-extension"), dest: resolve(__dirname, "..", devPath, "resources/browser-extension"), }, + { + // Shell hooks sourced by managed zsh/bash sessions (fresh-exec + // route). Copied into dist/resources so dev and preview modes + // resolve the hook via the same __dirname-relative path the + // packaged app uses. + src: resolve(__dirname, "..", resources, "shell-hooks"), + dest: resolve(__dirname, "..", devPath, "resources/shell-hooks"), + }, { src: resolve(__dirname, "../../../packages/local-db/drizzle"), dest: resolve(__dirname, "..", devPath, "resources/migrations"), diff --git a/bun.lock b/bun.lock index ed538233849..e70dfd8ff0b 100644 --- a/bun.lock +++ b/bun.lock @@ -267,6 +267,7 @@ "nanoid": "^5.1.6", "node-addon-api": "^7.1.0", "node-pty": "1.1.0", + "node-unix-socket": "^0.2.7", "os-locale": "^6.0.2", "pidtree": "^0.6.0", "pidusage": "^4.0.1", @@ -4864,6 +4865,22 @@ "node-simctl": ["node-simctl@7.7.5", "", { "dependencies": { "@appium/logger": "^1.3.0", "asyncbox": "^3.0.0", "bluebird": "^3.5.1", "lodash": "^4.2.1", "rimraf": "^5.0.0", "semver": "^7.0.0", "source-map-support": "^0.x", "teen_process": "^2.2.0", "uuid": "^11.0.1", "which": "^5.0.0" } }, "sha512-lWflzDW9xLuOOvR6mTJ9efbDtO/iSCH6rEGjxFxTV0vGgz5XjoZlW2BkNCCZib0B6Y23tCOiYhYJaMQYB8FKIQ=="], + "node-unix-socket": ["node-unix-socket@0.2.7", "", { "optionalDependencies": { "node-unix-socket-darwin-arm64": "0.2.7", "node-unix-socket-darwin-x64": "0.2.7", "node-unix-socket-linux-arm-gnueabihf": "0.2.7", "node-unix-socket-linux-arm64-gnu": "0.2.7", "node-unix-socket-linux-arm64-musl": "0.2.7", "node-unix-socket-linux-x64-gnu": "0.2.7", "node-unix-socket-linux-x64-musl": "0.2.7" } }, "sha512-Gzdi/wcz0Dd0IPkzl8OfSGmOm3uMMOvOl2yWVyE3E5hdl3tI+0lUVwYc92UnZWt5rWhrkqLBoUivzLVipCoO2w=="], + + "node-unix-socket-darwin-arm64": ["node-unix-socket-darwin-arm64@0.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6wSB386fFnWADWVpAlDq87lZI/0jzLEA7BsRc6QwmMwHonZ/ZbejwEI79iRKnHqFB6wh3TZdHHiKu4csMH1c3w=="], + + "node-unix-socket-darwin-x64": ["node-unix-socket-darwin-x64@0.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-eO8pVbchCy7TOvbc8DlIytsSeX6MWPmDVLnSQ8dvAUmsHRGKgJdmO74gr2NwjNmh1h974iW8IpAJfovL+DffHA=="], + + "node-unix-socket-linux-arm-gnueabihf": ["node-unix-socket-linux-arm-gnueabihf@0.2.7", "", { "os": "linux", "cpu": "arm" }, "sha512-BcC2tGf+Mfs94khO6PWK2a4dJ2wX7HBOuVKxTVqfXZxcrJXplZa0NYn2H9+il4fgIJMb6EbeUo2L3iXpTDJk7w=="], + + "node-unix-socket-linux-arm64-gnu": ["node-unix-socket-linux-arm64-gnu@0.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-HB4mOFic2u/6KjGHlMJY2q2eAz6btWxzJ0tMYOll+K2WOIuMwT5moZRToc3rjOI6h8bSBMf8ZMwvCz5On9fdoQ=="], + + "node-unix-socket-linux-arm64-musl": ["node-unix-socket-linux-arm64-musl@0.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-sLuUyCBRWEqBA+EHbhMYgKhEg6zNhiD7nDIJt0zJhWoF7bIrJaRAgBWsdSK/EK8d0a6FYpWIMVVe9CcsApb+lA=="], + + "node-unix-socket-linux-x64-gnu": ["node-unix-socket-linux-x64-gnu@0.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-AB3m2YIqUXLSnbezWraa37QNSaViPB/8wYuVsiiXowZzmSBB+o840fgJYV8qcmMlYutwI7Snwy6viPQNNBi8IA=="], + + "node-unix-socket-linux-x64-musl": ["node-unix-socket-linux-x64-musl@0.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-zVaULR65kXiDh9VLS4fP8bY+jZafByvljMrdyGJc/5zXt//NQK5NqEql/o3c4dPkA+VfIY4GHnjDJWOcxQA+wA=="], + "nofilter": ["nofilter@3.1.0", "", {}, "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],