diff --git a/.dockerignore b/.dockerignore index 6a371b9c9..98ba49353 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ # Build artifacts (rebuilt in Docker — never copy from host) +.worktrees/ node_modules/ dist/ target/ @@ -41,6 +42,85 @@ port/oracle/* !port/oracle/baselines/ port/oracle/baselines/* !port/oracle/baselines/mode-preamble/ +!port/laptop-bootstrap/ +port/laptop-bootstrap/* +!port/laptop-bootstrap/1-install-wsl.cmd +!port/laptop-bootstrap/2-bootstrap-wsl.sh +!port/vm-bridge/ +port/vm-bridge/* +!port/vm-bridge/agent-console-vm.ps1 +!port/vm-bridge/agent-console-wsl.sh + +# The Cloud Run default Vitest lane reads these checked-in runtime and +# distribution surfaces. Keep the exact manifest-owned inputs in the image +# even though their surrounding directories are not application runtime data. +!.github/ +.github/* +!.github/workflows/ +.github/workflows/* +!.github/workflows/docs-pages-deploy.yml +!.github/workflows/electron-build.yml +!.github/workflows/electron-release.yml +!.github/workflows/port-contract.yml +!.github/workflows/rust-clippy.yml +!.github/workflows/typecheck-client.yml + +!electron/ +electron/* +!electron/port-check.ts + +!examples/ +examples/* +!examples/docker/ +examples/docker/* +!examples/docker/Dockerfile +!examples/extensions/ +examples/extensions/* +!examples/extensions/live-counter/ +examples/extensions/live-counter/* +!examples/extensions/live-counter/server.js +!examples/extensions/status-dashboard/ +examples/extensions/status-dashboard/* +!examples/extensions/status-dashboard/server.js + +!installers/ +installers/* +!installers/systemd/ +installers/systemd/* +!installers/systemd/freshell-rust.service + +# Distribution guard fixtures are checked in and needed by the default +# Cloud Run lane. Re-include only the six controls; sibling dist/node_modules +# files remain excluded. +!test/fixtures/distribution/rust-only/dist/ +test/fixtures/distribution/rust-only/dist/* +!test/fixtures/distribution/rust-only/dist/client/ +test/fixtures/distribution/rust-only/dist/client/* +!test/fixtures/distribution/rust-only/dist/client/index.html +!test/fixtures/distribution/rust-only/dist/tools/ +test/fixtures/distribution/rust-only/dist/tools/* +!test/fixtures/distribution/rust-only/dist/tools/freshell-mcp/ +test/fixtures/distribution/rust-only/dist/tools/freshell-mcp/* +!test/fixtures/distribution/rust-only/dist/tools/freshell-mcp/server.js +!test/fixtures/distribution/node-server/dist/ +test/fixtures/distribution/node-server/dist/* +!test/fixtures/distribution/node-server/dist/client/ +test/fixtures/distribution/node-server/dist/client/* +!test/fixtures/distribution/node-server/dist/client/index.html +!test/fixtures/distribution/node-server/dist/server/ +test/fixtures/distribution/node-server/dist/server/* +!test/fixtures/distribution/node-server/dist/server/index.js +!test/fixtures/distribution/node-server/dist/tools/ +test/fixtures/distribution/node-server/dist/tools/* +!test/fixtures/distribution/node-server/dist/tools/freshell-mcp/ +test/fixtures/distribution/node-server/dist/tools/freshell-mcp/* +!test/fixtures/distribution/node-server/dist/tools/freshell-mcp/server.js +!test/fixtures/distribution/node-server/node_modules/ +test/fixtures/distribution/node-server/node_modules/* +!test/fixtures/distribution/node-server/node_modules/node-pty/ +test/fixtures/distribution/node-server/node_modules/node-pty/* +!test/fixtures/distribution/node-server/node_modules/node-pty/index.js + assets/ # Editor / IDE diff --git a/.env.example b/.env.example index 24972483c..7e1a3259e 100644 --- a/.env.example +++ b/.env.example @@ -12,21 +12,17 @@ AUTH_TOKEN=replace-with-a-long-random-token # Server # ----------------------------------------------------------------------------- -# Port for the Express server (backend API + production static files) +# Port for the Rust freshell-server (HTTP, WebSocket, and static client files) PORT=3001 -# Set to true to hide AUTH_TOKEN from the startup URL printed to the console. -# Useful when logs are aggregated or terminals are shared/recorded. -# HIDE_STARTUP_TOKEN=true +# Rust log filter (standard `tracing` syntax; default: info). +# RUST_LOG=info -# Log level: fatal, error, warn, info, debug, trace -# LOG_LEVEL=debug +# Optional explicit bind host for the Rust service. Without this, the server +# uses persisted network settings and platform defaults. +# FRESHELL_BIND_HOST=127.0.0.1 -# Trust proxy setting for Express (e.g., "1", "loopback", or a CIDR range). -# Set this when running behind a reverse proxy (nginx, Caddy, Cloudflare). -# FRESHELL_TRUST_PROXY=1 - -# ALLOWED_ORIGINS is auto-managed by NetworkManager based on bind host and LAN IPs. +# ALLOWED_ORIGINS is auto-managed from the active bind host and LAN IPs. # Do not edit manually — use EXTRA_ALLOWED_ORIGINS for custom additions. # ALLOWED_ORIGINS=http://localhost:3001,http://127.0.0.1:3001 @@ -34,15 +30,6 @@ PORT=3001 # These are preserved across NetworkManager reconfigurations. # EXTRA_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3002,https://mysite.com,http://192.168.1.50:8080 -# Maximum concurrent WebSocket connections (default: 50) -# MAX_CONNECTIONS=50 - -# Maximum concurrent terminals (default: 50) -# MAX_TERMINALS=50 - -# Scrollback buffer size in characters per terminal (default: 65536 = 64KB) -# MAX_SCROLLBACK_CHARS=65536 - # ----------------------------------------------------------------------------- # Vite Dev Server (only used during `npm run dev`) # ----------------------------------------------------------------------------- @@ -75,21 +62,26 @@ PORT=3001 # Override the Claude CLI command (default: claude) # CLAUDE_CMD=claude +# Rust fresh-agent Claude panes use this isolated Node SDK sidecar. These are +# normally set by Electron or the development launcher; set them explicitly +# only when running the Rust service with a custom sidecar installation. +# FRESHELL_CLAUDE_NODE=/path/to/node +# FRESHELL_CLAUDE_SIDECAR=/path/to/crates/freshell-claude-sidecar/index.mjs + # Override path to Codex's home directory (default: ~/.codex) # CODEX_HOME=/path/to/.codex # Override the Codex CLI command (default: codex) # CODEX_CMD=codex +# Standalone MCP client overrides. Set both values together when running the +# MCP client outside a built checkout or packaged Electron application. +# FRESHELL_MCP_NODE=/path/to/node +# FRESHELL_MCP_ENTRY=/path/to/dist/tools/freshell-mcp/server.js + # Override Claude autocompact threshold percentage # CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80 -# Max events to load per coding CLI session (default: 10000) -# FRESHELL_MAX_SESSION_EVENTS=10000 - -# How long to keep completed session data in memory, ms (default: 1800000 = 30 min) -# FRESHELL_COMPLETED_SESSION_RETENTION_MS=1800000 - # ----------------------------------------------------------------------------- # Windows / WSL (only needed when running on Windows or under WSL) # ----------------------------------------------------------------------------- @@ -119,11 +111,8 @@ PORT=3001 # WebSocket ping interval in ms (default: 30000) # PING_INTERVAL_MS=30000 -# Max WebSocket buffered bytes before backpressure (default: 2097152 = 2MB) -# MAX_WS_BUFFERED_AMOUNT=2097152 - -# Max bytes per WebSocket output chunk (default: 512000 = 500KB) -# MAX_WS_CHUNK_BYTES=512000 +# Maximum inbound WebSocket frame size in bytes (default: 16777216 = 16MB) +# WS_MAX_PAYLOAD_BYTES=16777216 # Terminal stream replay ring bytes per terminal (default: 262144 = 256KB) # TERMINAL_REPLAY_RING_MAX_BYTES=262144 @@ -143,18 +132,3 @@ PORT=3001 # Terminal creation rate limit: max creates per window (default: 10 per 10s) # TERMINAL_CREATE_RATE_LIMIT=10 # TERMINAL_CREATE_RATE_WINDOW_MS=10000 - -# Max exited terminals to keep in memory (default: 200) -# MAX_EXITED_TERMINALS=200 - -# Sessions sync coalesce interval in ms (default: 150) -# SESSIONS_SYNC_COALESCE_MS=150 - -# Claude indexer debounce interval in ms (default: 250) -# CLAUDE_INDEXER_DEBOUNCE_MS=250 - -# Max seen session IDs to cache (default: 10000) -# CLAUDE_SEEN_SESSION_MAX=10000 - -# How long to remember seen session IDs, ms (default: 604800000 = 7 days) -# CLAUDE_SEEN_SESSION_RETENTION_MS=604800000 diff --git a/.gcloudignore b/.gcloudignore index 91c7f305d..4921e427f 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -42,6 +42,85 @@ port/oracle/* !port/oracle/baselines/ port/oracle/baselines/* !port/oracle/baselines/mode-preamble/ +!port/laptop-bootstrap/ +port/laptop-bootstrap/* +!port/laptop-bootstrap/1-install-wsl.cmd +!port/laptop-bootstrap/2-bootstrap-wsl.sh +!port/vm-bridge/ +port/vm-bridge/* +!port/vm-bridge/agent-console-vm.ps1 +!port/vm-bridge/agent-console-wsl.sh + +# The Cloud Run default Vitest lane reads these checked-in runtime and +# distribution surfaces. Keep the exact manifest-owned inputs in the image +# even though their surrounding directories are not application runtime data. +!.github/ +.github/* +!.github/workflows/ +.github/workflows/* +!.github/workflows/docs-pages-deploy.yml +!.github/workflows/electron-build.yml +!.github/workflows/electron-release.yml +!.github/workflows/port-contract.yml +!.github/workflows/rust-clippy.yml +!.github/workflows/typecheck-client.yml + +!electron/ +electron/* +!electron/port-check.ts + +!examples/ +examples/* +!examples/docker/ +examples/docker/* +!examples/docker/Dockerfile +!examples/extensions/ +examples/extensions/* +!examples/extensions/live-counter/ +examples/extensions/live-counter/* +!examples/extensions/live-counter/server.js +!examples/extensions/status-dashboard/ +examples/extensions/status-dashboard/* +!examples/extensions/status-dashboard/server.js + +!installers/ +installers/* +!installers/systemd/ +installers/systemd/* +!installers/systemd/freshell-rust.service + +# Distribution guard fixtures are checked in and needed by the default +# Cloud Run lane. Re-include only the six controls; sibling dist/node_modules +# files remain excluded. +!test/fixtures/distribution/rust-only/dist/ +test/fixtures/distribution/rust-only/dist/* +!test/fixtures/distribution/rust-only/dist/client/ +test/fixtures/distribution/rust-only/dist/client/* +!test/fixtures/distribution/rust-only/dist/client/index.html +!test/fixtures/distribution/rust-only/dist/tools/ +test/fixtures/distribution/rust-only/dist/tools/* +!test/fixtures/distribution/rust-only/dist/tools/freshell-mcp/ +test/fixtures/distribution/rust-only/dist/tools/freshell-mcp/* +!test/fixtures/distribution/rust-only/dist/tools/freshell-mcp/server.js +!test/fixtures/distribution/node-server/dist/ +test/fixtures/distribution/node-server/dist/* +!test/fixtures/distribution/node-server/dist/client/ +test/fixtures/distribution/node-server/dist/client/* +!test/fixtures/distribution/node-server/dist/client/index.html +!test/fixtures/distribution/node-server/dist/server/ +test/fixtures/distribution/node-server/dist/server/* +!test/fixtures/distribution/node-server/dist/server/index.js +!test/fixtures/distribution/node-server/dist/tools/ +test/fixtures/distribution/node-server/dist/tools/* +!test/fixtures/distribution/node-server/dist/tools/freshell-mcp/ +test/fixtures/distribution/node-server/dist/tools/freshell-mcp/* +!test/fixtures/distribution/node-server/dist/tools/freshell-mcp/server.js +!test/fixtures/distribution/node-server/node_modules/ +test/fixtures/distribution/node-server/node_modules/* +!test/fixtures/distribution/node-server/node_modules/node-pty/ +test/fixtures/distribution/node-server/node_modules/node-pty/* +!test/fixtures/distribution/node-server/node_modules/node-pty/index.js + assets/ # Editor / IDE diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 3d17fdca9..a0985d712 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -5,42 +5,106 @@ on: tags: ['v*'] pull_request: paths: + - 'src/**' + - 'shared/**' - 'electron/**' + - 'tools/**' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'assets/electron/**' - 'config/electron-builder.yml' - - 'scripts/prepare-bundled-node.ts' + - 'config/vite/**' + - 'config/vitest/vitest.electron-runtime.config.ts' + - 'scripts/prepare-electron-runtime.ts' + - 'scripts/ensure-claude-sidecar.ts' - 'scripts/bundled-node-version.json' + - 'scripts/verify-electron-artifact.ts' + - 'scripts/assert-native-windows-build.ts' + - 'package.json' + - 'package-lock.json' + +permissions: + contents: read jobs: build: strategy: fail-fast: false matrix: - os: [macos-15-intel, macos-latest, ubuntu-latest, windows-2022] + include: + - os: macos-15-intel + installer: release/*.dmg + - os: macos-latest + installer: release/*.dmg + - os: ubuntu-latest + installer: |- + release/*.AppImage + release/*.deb + - os: windows-2022 + installer: release/*.exe runs-on: ${{ matrix.os }} + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - cache: 'npm' + cache: npm - uses: actions/setup-python@v5 with: python-version: '3.11' + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.96.0 + components: rustfmt + + - uses: Swatinem/rust-cache@v2 + - name: Install dependencies run: npm ci - - name: Run Electron tests + - name: Run Electron unit tests run: npm run test:electron - - name: Build Electron app + # Build the host-native server before staging. The Windows job is always + # native Windows, so its executable has the correct PE format. + - name: Build native Rust server + run: cargo build --release -p freshell-server --locked + + - name: Build and verify Electron installer (Unix) + if: matrix.os != 'windows-2022' run: npm run electron:build - - name: Upload artifacts + - name: Build and verify Electron installer (Windows) + if: matrix.os == 'windows-2022' + run: npm run electron:build:win + + # The package scripts verify as part of the build; repeat the explicit + # verifier as the CI receipt immediately before checkout-free testing. + - name: Verify unpacked native artifact + run: npm run verify:electron-artifact + + - name: Checkout-free native runtime acceptance + run: npm run test:electron:runtime + + - name: Upload verified installer (Unix) + if: matrix.os != 'windows-2022' + uses: actions/upload-artifact@v4 + with: + name: electron-${{ matrix.os }} + path: ${{ matrix.installer }} + if-no-files-found: error + retention-days: 14 + + - name: Upload verified installer (Windows) + if: matrix.os == 'windows-2022' uses: actions/upload-artifact@v4 with: name: electron-${{ matrix.os }} - path: release/* + path: ${{ matrix.installer }} + if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 4705f32a4..0426b87bf 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -4,33 +4,87 @@ on: push: tags: ['v*'] +permissions: + contents: write + jobs: release: strategy: fail-fast: false matrix: - os: [macos-15-intel, macos-latest, ubuntu-latest, windows-2022] + include: + - os: macos-15-intel + installer: release/*.dmg + - os: macos-latest + installer: release/*.dmg + - os: ubuntu-latest + installer: |- + release/*.AppImage + release/*.deb + - os: windows-2022 + installer: release/*.exe runs-on: ${{ matrix.os }} - permissions: - contents: write + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - cache: 'npm' + cache: npm - uses: actions/setup-python@v5 with: python-version: '3.11' + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.96.0 + components: rustfmt + + - uses: Swatinem/rust-cache@v2 + - name: Install dependencies run: npm ci - - name: Build Electron app + - name: Run Electron unit tests + run: npm run test:electron + + - name: Build native Rust server + run: cargo build --release -p freshell-server --locked + + - name: Build and verify Electron installer (Unix) + if: matrix.os != 'windows-2022' run: npm run electron:build + - name: Build and verify Electron installer (Windows) + if: matrix.os == 'windows-2022' + run: npm run electron:build:win + + - name: Verify unpacked native artifact + run: npm run verify:electron-artifact + + - name: Checkout-free native runtime acceptance + run: npm run test:electron:runtime + + - name: Upload verified installer (Unix) + if: matrix.os != 'windows-2022' + uses: actions/upload-artifact@v4 + with: + name: electron-release-${{ matrix.os }} + path: ${{ matrix.installer }} + if-no-files-found: error + retention-days: 14 + + - name: Upload verified installer (Windows) + if: matrix.os == 'windows-2022' + uses: actions/upload-artifact@v4 + with: + name: electron-release-${{ matrix.os }} + path: ${{ matrix.installer }} + if-no-files-found: error + retention-days: 14 + - name: Upload installers to GitHub Release shell: bash run: npx tsx scripts/upload-electron-release-assets.ts "$GITHUB_REF_NAME" release diff --git a/.github/workflows/rust-clippy.yml b/.github/workflows/rust-clippy.yml index bb5b1b6b1..641b42f58 100644 --- a/.github/workflows/rust-clippy.yml +++ b/.github/workflows/rust-clippy.yml @@ -16,10 +16,18 @@ concurrency: jobs: clippy: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install JavaScript tooling + run: npm ci + # Pinned toolchain (NOT @stable): keeps green-local <=> green-CI deterministic. # Current stable (1.97.x) already adds default-warn lints this branch was not # validated against. Bump this pin deliberately: update the version, re-run the @@ -39,17 +47,40 @@ jobs: sudo apt-get install -y --no-install-recommends \ libwebkit2gtk-4.1-dev libgtk-3-dev libsoup-3.0-dev \ libjavascriptcoregtk-4.1-dev librsvg2-dev \ - libayatana-appindicator3-dev pkg-config build-essential + libayatana-appindicator3-dev libdbus-1-dev pkg-config build-essential - name: cargo fmt run: cargo fmt --all --check - name: cargo clippy (workspace) - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --locked -- -D warnings # --all-targets does not imply --all-features: the real-transport # backends are default-off and would otherwise go unlinted. - name: cargo clippy (feature-gated backends) run: | - cargo clippy -p freshell-codex --features real-transport --all-targets -- -D warnings - cargo clippy -p freshell-opencode --features real-transport --all-targets -- -D warnings + cargo clippy -p freshell-codex --features real-transport --all-targets --locked -- -D warnings + cargo clippy -p freshell-opencode --features real-transport --all-targets --locked -- -D warnings + + # The Tauri shell must launch and reap the real Rust server binary. Build + # it explicitly so a missing-artifact test can never pass by omission. + - name: Build Rust server for Tauri smoke + run: cargo build -p freshell-server --locked + + - name: Rust workspace tests + env: + FRESHELL_SERVER_BIN: ${{ github.workspace }}/target/debug/freshell-server + run: cargo test --workspace --locked + + - name: Source-runtime smoke + run: npm run test:source-runtime + + # Execute the browser-selection contract so a stale project/filter can + # never make the supported Rust application lane silently select zero. + - name: Browser selection non-vacuity + run: npm run test:e2e:helpers -- helpers/selection-nonvacuity.test.ts + + - name: Tauri app-bound server spawn smoke + env: + FRESHELL_SERVER_BIN: ${{ github.workspace }}/target/debug/freshell-server + run: cargo test -p freshell-tauri --locked --test server_spawn_smoke app_bound_spawn_health_reap_end_to_end -- --exact --nocapture diff --git a/.github/workflows/typecheck-client.yml b/.github/workflows/typecheck-client.yml index 00b31b468..4771e3730 100644 --- a/.github/workflows/typecheck-client.yml +++ b/.github/workflows/typecheck-client.yml @@ -16,7 +16,7 @@ concurrency: jobs: typecheck-client: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -30,3 +30,9 @@ jobs: - name: Run client typecheck run: npm run typecheck:client + + # Keep the default Vitest lane nonempty while its config excludes tests + # that own Rust/artifact setup. Those prerequisites belong to their + # dedicated jobs rather than this fast client check. + - name: Run default Vitest lane + run: npm run test:vitest -- run --config config/vitest/vitest.config.ts diff --git a/.gitignore b/.gitignore index 4796b841b..6d8ee3bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,7 @@ test-results/ playwright-report/ blob-report/ # Electron build artifacts -bundled-node/ -server-node-modules/ -server-node-modules-staging/ +electron-runtime/ release/ dist/wizard/ artifacts/perf/ @@ -68,3 +66,9 @@ port/vm-bridge/outbound/ # GATE-01 per-slice Playwright JSON reports (working state; committed artifact is gate01-baseline.json) test/e2e-browser/gate01-reports/ + +# Distribution fixtures intentionally contain nested dist/node_modules trees. +# Keep their tracked files visible to Git and repository tooling. +!test/fixtures/distribution/ +!test/fixtures/distribution/**/ +!test/fixtures/distribution/**/* diff --git a/AGENTS.md b/AGENTS.md index 546347515..8c18ebc95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,8 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o - Merge PRs once their required checks pass, then bring `origin/main` down to local `main`. Self-merging your own PRs is the norm. The only exception is a PR the user has said needs someone else to approve it first — leave those unmerged. - Many agents may be working in the worktree at the same time. If you see activity from other agents (for example test runs or file changes), respect it. - Specific user instructions override ALL other instructions, including the above, and including superpowers or skills -- Server uses NodeNext/ESM; relative imports must include `.js` extensions -- Always consider checking logs for debugging; server logs (including client console logs) are in the server process stdout/stderr (e.g., `npm run dev`/`npm start`). +- TypeScript tooling and Electron use NodeNext/ESM; relative imports must include `.js` extensions. +- Always consider checking logs for debugging; Rust server logs and client/Electron logs are in the owning process stdout/stderr (for example, `npm run dev` or `npm start`). The standalone Rust launcher also writes JSONL logs under `~/.freshell/logs/`. - Debug logging toggle (UI Settings → Debugging → Debug logging) enables debug-level logs and perf logging; keep OFF outside perf investigations. - When adding new user-facing features or making significant UI changes, update `docs/index.html` to reflect them. It's a nonfunctional mock of the default experience, so only major changes need to be added. @@ -32,7 +32,7 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o - Set `FRESHELL_TEST_SUMMARY` when you want holder/status output to show a human-meaningful reason for a broad run. - Use `npm run test:status` to inspect the current holder, recent results, and any advisory reusable baseline. - Use `npm run test:vitest -- ...` for a repo-owned direct Vitest path. Raw `npx vitest` is not a coordinated workflow. -- `test:unit` is the exact default-config `test/unit` workload, `test:integration` is the exact server-config `test/server` workload, and `test:server` stays watch-capable unless you pass an explicit broad `--run`. +- `test:unit` is the exact default-config `test/unit` workload, `test:integration` runs Rust workspace integration tests, and `test:server` is the Cargo-backed Rust `freshell-server` lane. Zero-argument and explicit broad `--run` server/integration invocations are coordinated; narrowed Cargo selectors are delegated. ## Destructive Test Sandbox - Process-kill, config-corruption, and restart-storm suites run inside a disposable Docker sandbox, never directly on host: `scripts/sandbox-test.sh ""` (or `npm run test:sandbox -- ""`). @@ -57,20 +57,19 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o ## Process Safety (CRITICAL) -- Never use broad kill patterns (for example `pkill -f "tsx watch server/index.ts"`, `pkill -f vite`, `pkill node`). +- Never use broad kill patterns (for example `pkill -f vite` or `pkill node`). - Start manual worktree servers on a unique port and record their PID, then stop only that PID. -- Dev mode example (full hot reload — Vite client HMR + tsx-watch server): `NODE_ENV=development PORT=3344 npm run dev > /tmp/freshell-3344.log 2>&1 & echo $! > /tmp/freshell-3344.pid`, then open `http://localhost:5173/?token=` (Vite proxies `/api` + `/ws` to the server port). - - `NODE_ENV=development` is required: a lingering `NODE_ENV=production` in the shell (e.g. left by `npm start`) makes `isDev` false, so the server skips the Vite path and `/` 404s on `client/index.html`. - - Server-only hot reload (no client UI): `PORT=3344 npm run dev:server ...` instead. -- Production mode example (built dist): `PORT=3344 npm start > /tmp/freshell-3344.log 2>&1 & echo $! > /tmp/freshell-3344.pid` - - **NEVER run `node dist/server/index.js` directly** — use `npm start` which sets `NODE_ENV=production`; without it the server prints the Vite port (5173) in the startup URL even though Vite isn't running +- Dev mode example (Vite client HMR plus the Rust server): `PORT=3344 VITE_PORT=5174 npm run dev > /tmp/freshell-3344.log 2>&1 & echo $! > /tmp/freshell-3344.pid`, then open `http://localhost:5174/?token=` (Vite proxies `/api` and `/ws` to the Rust server on port 3344). + - Server-only development (without the Vite UI): `PORT=3344 npm run dev:server > /tmp/freshell-3344.log 2>&1 & echo $! > /tmp/freshell-3344.pid`. +- Production mode example (built Rust binary): `PORT=3344 npm start > /tmp/freshell-3344.log 2>&1 & echo $! > /tmp/freshell-3344.pid`. +- The Rust binary is `target/release/freshell-server` (or `.exe` on Windows). It is the only Freshell backend executable. - Example stop: `kill "$(cat /tmp/freshell-3344.pid)" && rm -f /tmp/freshell-3344.pid` - Before stopping any process, verify it belongs to the worktree (`ps -fp ` and confirm cwd/path includes `.worktrees/...`). -- **The self-hosted Freshell server must never be restarted without explicit user approval (the word "APPROVED").** Building is fine; deploying (stop + start) is not. The user's current Freshell session depends on it, and an unapproved restart will disconnect them mid-operation. As of July 2026 the live self-hosted server is the RUST server on port 3001 (see below), not the Node server. +- **The self-hosted Freshell server must never be restarted without explicit user approval (the word "APPROVED").** Building is fine; deploying (stop + start) is not. The user's current Freshell session depends on it, and an unapproved restart will disconnect them mid-operation. The live self-hosted server is the Rust server on port 3001 (see below). ## Rust Server (Self-Hosted Production) -The production self-hosted Freshell is the Rust server (`target/release/freshell-server`, workspace crate `freshell-server`), running on **port 3001** from the main checkout (`.env` sets `PORT=3001`; the launcher script's built-in default is 3002, so always confirm the live port via `ls ~/.freshell/rust-server-*.pid` or `ss -tlnp`). The Node server (`npm start`) still exists but is not what the user runs day-to-day. +The production self-hosted Freshell is the Rust server (`target/release/freshell-server`, workspace crate `freshell-server`), running on **port 3001** from the main checkout (`.env` sets `PORT=3001`; the launcher script's built-in default is 3002, so always confirm the live port via `ls ~/.freshell/rust-server-*.pid` or `ss -tlnp`). **Canonical launcher: `scripts/launch-rust.sh`** — use this instead of hand-rolled build/launch commands: @@ -117,31 +116,36 @@ Key facts: ### Development ```bash -npm run dev # Run client + server concurrently with hot reload +npm run dev # Run Vite + the Rust server with hot reload npm run dev:client # Vite dev server only (port 5173) -npm run dev:server # Node with tsx watch for server auto-reload +npm run dev:server # Rust server only ``` ### Building ```bash -npm run build # Full build (client + server) +npm run build # Full build (client + tools + Rust server) npm run build:client # Vite build → dist/client -npm run build:server # TypeScript compile → dist/server -npm run serve # Build and run production server +npm run build:rust # Release freshell-server binary +npm run serve # Build and run the Rust server # `npm run serve` prompts before serving from a non-main branch; use # `FRESHELL_ALLOW_NON_MAIN_SERVE=1 npm run serve` only when intentional. -# Note: `npm run build` is guarded — it will refuse to overwrite dist/ -# if a production server is detected on the configured PORT. Use -# `npm run check` for safe verification, or build from a worktree. +# Note: `npm test` (through its source-runtime phase), `npm run build`, +# `npm run verify`, `npm run check`, and `npm run electron:dev` are guarded — +# on the main checkout they fail closed +# before writing artifacts if a production server is detected on the configured +# PORT. Use `npm run typecheck:client` for a no-write check, or run +# source-runtime/build verification from a linked worktree +# (`cd .worktrees/`). `npm run dev` and `npm run dev:server` bootstrap +# a first-run `.env` token and the locked Claude sidecar before starting Rust. ``` -**On WSL machines, "the desktop app" means the Windows app.** Always build, install, and launch the Windows Electron app (`npm run electron:build:win` + the NSIS installer) — never a Linux AppImage/deb under WSLg. The Windows build must run as a native Windows process (WSL cannot compile `node-pty` for win32); drive it from WSL by rsyncing to a Windows-local dir and running Windows npm via `cmd.exe` — see [docs/development/windows-electron-build.md](docs/development/windows-electron-build.md). +**On WSL machines, "the desktop app" means the Windows app.** Always build, install, and launch the Windows Electron app (`npm run electron:build:win` + the NSIS installer) — never a Linux AppImage/deb under WSLg. The Windows build must run as a native Windows process so Cargo produces a native `freshell-server.exe`; drive it from WSL by rsyncing to a Windows-local dir and running Windows npm via `cmd.exe` — see [docs/development/windows-electron-build.md](docs/development/windows-electron-build.md). ### Testing Backend fallback policy: never silently fall back from the configured cloud test backend to local — if the cloud path fails, fix it; a local-backend run may substitute only when the cloud path cannot be fixed AND the user explicitly approves. ```bash -npm test # Coordinated full suite (default + server configs) +npm test # Coordinated client, Rust, and Electron suite npm run check # Typecheck, then coordinated full suite npm run verify # Build, then coordinated full suite npm run test:coverage # Coordinated default-config coverage run @@ -149,20 +153,20 @@ npm run test:status # Show active holder, latest results, and advisory b npm run test:vitest -- ... # Repo-owned direct Vitest path for focused passthrough work ``` -External provider contract tests (`test/integration/real/`) spawn real `claude`, `codex`, and `opencode` binaries to verify external provider behavior, not Freshell code. They are opt-in and skipped by default to avoid blocking the coordinated suite on environment-dependent flakiness: +External provider contract tests (`test/integration/real/`) exercise the real Amplifier CLI, not Freshell code. When the documented opt-in enables this tree, the version smoke runs when `amplifier` is available; tests that adopt a session or make a model call additionally require provider setup. The tree is excluded from the default suite to avoid blocking the coordinated run on environment-dependent external-tool behavior: ```bash FRESHELL_RUN_REAL_PROVIDER_CONTRACTS=1 npm run test:vitest -- \ - run test/integration/real/ --config config/vitest/vitest.server.config.ts + run test/integration/real/ --config config/vitest/vitest.config.ts ``` ### Vitest Test Backend (Cloud Run Jobs) -Vitest unit/server test suites can run locally or on Google Cloud Run Jobs. The `FRESHELL_VITEST_BACKEND` environment variable controls the default: +Vitest client/tooling suites can run locally or on Google Cloud Run Jobs. The `FRESHELL_VITEST_BACKEND` environment variable controls the default: - **Unset or `"local"`**: run locally (the safe default for new clones) - **`"cloud"`**: run on Cloud Run Jobs (4 shards, ~2-3 min wall time vs ~5 min local, ~$0.02/run) ```bash -npm run test:cloud # Run vitest on Cloud Run Jobs (client + server suites) +npm run test:cloud # Run Vitest on Cloud Run Jobs (client/tooling suites) npm run test:cloud:build # Build and push the Docker image to Artifact Registry ``` @@ -206,21 +210,35 @@ live in [docs/development/gcloud-robot.md](docs/development/gcloud-robot.md). ### Tech Stack - **Frontend:** React 18, Redux Toolkit, Vite, Tailwind CSS, shadcn/ui, xterm.js, Zod -- **Backend:** Node.js/Express, node-pty, WebSocket (ws), Chokidar, Vercel AI SDK + Google Generative AI -- **Testing:** Vitest, Testing Library, supertest, superwstest +- **Backend:** Rust (`freshell-server`, Axum, Tokio, portable-pty, SQLite), with a React/Vite client +- **Testing:** Vitest, Testing Library, Playwright, Cargo tests ### Directory Structure - `src/` - React frontend application - `components/` - UI components (TabBar, Sidebar, TerminalView, HistoryView, etc.) - `store/` - Redux slices (tabs, connection, sessions, settings, claude) - `lib/` - Utilities (api.ts, claude-types.ts) -- `server/` - Node.js/Express backend - - `index.ts` - HTTP/REST routes and server entry - - `ws-handler.ts` - WebSocket protocol handler - - `terminal-registry.ts` - PTY lifecycle management - - `claude-session.ts` - Claude session discovery & indexing - - `claude-indexer.ts` - File watcher for ~/.claude directory -- `test/` - Test suites organized by unit/integration and client/server +- `crates/freshell-server/` - Rust HTTP/WebSocket server entrypoint and routes +- `crates/freshell-ws/` - WebSocket protocol and terminal/session coordination +- `crates/freshell-terminal/` - PTY lifecycle, replay, and output framing +- `crates/freshell-sessions/` - Claude, Codex, OpenCode, and Amplifier session discovery +- `tools/` - Standalone Node CLI and stdio MCP client; these call the Rust server and never host it +- `crates/freshell-claude-sidecar/` - Isolated Node stdio sidecar for the Claude SDK, used only by Rust fresh-agent Claude sessions +- `test/` - Client/tooling, Rust integration, browser, and Electron test suites + +### Standalone clients and Claude sidecar + +- Build the CLI and MCP client with `npm run build:tools`. The CLI entrypoint is + `dist/tools/freshell-cli/index.js`; its `freshell` package bin sends requests + to the Rust server configured by `FRESHELL_URL` and `FRESHELL_TOKEN`. +- The MCP entrypoint is `dist/tools/freshell-mcp/server.js`. It is a stdio + client, not a server for Freshell's HTTP/WebSocket API; configure + `FRESHELL_URL` and `FRESHELL_TOKEN` explicitly when launching it outside a + Freshell terminal. +- Claude fresh-agent panes use the isolated + `crates/freshell-claude-sidecar` package. It is a newline-JSON stdio child + launched by Rust and owns the Claude SDK dependency. The sidecar does not + listen on a port and is not the Freshell backend. ### Key Architectural Patterns @@ -238,13 +256,13 @@ live in [docs/development/gcloud-robot.md](docs/development/gcloud-robot.md). **Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; green/needs-attention and the idle sound flow through `recordTurnComplete` and `useTurnCompletionNotifications`. Turn-complete (green/sound) is server-authoritative everywhere: terminal CLIs via `terminal.turn.complete`, and fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) via a discrete `freshAgent.turn.complete` edge emitted only on a positive completion — freshclaude/kilroy on the SDK `result` with `subtype === 'success'`, freshopencode on the success-only `emitStatus(idle)` path, and freshcodex on `turn/completed` only when `params.turn.status === 'completed'` (the notification also fires on interrupt). The client folds it in via `applyFreshAgentCompletion` using the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart). The waiting-for-approval edge is ALSO server-authoritative: the Claude/kilroy `SdkBridge` emits a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition (only Claude/kilroy raise approvals/questions), and the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all green/sound edges are now server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, emitting `sdk.status:'exited'` to clear BLUE (no chime — a crash is not a positive completion). `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). -**Fresh-Agent Orchestration:** The REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and the MCP `freshell` tool accept `agent`/`model`/`effort` parameters to create and drive fresh-agent panes (e.g. `agent=opencode`). The orchestration layer dispatches to the registered `FreshAgentRuntimeManager`, so the same external surface works for any fresh-agent provider. On MCP `new-tab`, resume sugar (`resume`/`resumeSessionId`) is honored for `agent: "opencode"` (Rust server). Agent-resume via `sessionRef` is NOT supported for claude/codex/kilroy agents (the Rust server rejects it with a 400; the Node server silently ignores it) — use an explicit `sessionRef` on MODE panes where that path supports it. +**Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and the standalone Node MCP client accept `agent`/`model`/`effort` parameters where the Rust contract supports them. The Rust orchestration layer dispatches to the registered fresh-agent runtimes. On MCP `new-tab`, resume sugar (`resume`/`resumeSessionId`) is honored for `agent: "opencode"`; agent resume for Claude/Codex/Kilroy uses an explicit supported `sessionRef` or the appropriate mode-pane flow. Unsupported legacy actions return a deterministic unavailable result instead of contacting a removed backend route. ### Data Flow -1. Browser loads → fetches settings from `/api/settings` and sessions from `/api/sessions` +1. Browser loads → fetches settings from the Rust server's `/api/settings` route and sessions from `/api/sessions` 2. WebSocket connects → client sends `hello` with auth token → server sends `ready` -3. Terminal creation → Pane content has `createRequestId` → UI sends `terminal.create` WS message with that ID → server spawns PTY → sends back `terminal.created` with `terminalId` → pane content updated +3. Terminal creation → Pane content has `createRequestId` → UI sends `terminal.create` WS message with that ID → Rust server spawns PTY → sends back `terminal.created` with `terminalId` → pane content updated 4. Terminal I/O → `terminal.input` WS messages write to PTY stdin → stdout/stderr streams to attached clients ## Accessibility (A11y) Requirements diff --git a/Cargo.lock b/Cargo.lock index 1c12ef0d6..8d4d2a884 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1405,6 +1405,7 @@ name = "freshell-sessions" version = "0.1.0" dependencies = [ "chrono", + "freshell-platform", "libc", "notify", "regex", diff --git a/README.md b/README.md index 6b62da4d6..506ad5a2d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Node.js Version + Node.js tools version Platform Support License

@@ -25,7 +25,7 @@ - **Speak with the dead** — Resume any Claude, Codex, or OpenCode session from any device (even if you weren't using freshell to run it) - **Fancy tabs** — Auto-name from terminal content, drag-and-drop reorder, and per-pane type icons so you know what's in each tab - **Freshclaude** — An interactive alternative to Claude CLI that works with your Anthropic subscription. Rich chat UI with collapsible tool strips, token budget display, and full session persistence. -- **Extension system** — Add new pane types, CLI integrations, and server-side services via manifest-based extensions. Enable and disable from the Extensions management page. +- **Extension system** — Add CLI integrations via manifest-based extensions. Client and server-hosted extension panes are not supported by the Rust server. - **Self-configuring workspace** — Just ask Claude or Codex to open a browser in a pane, or create a tab with four subagents. Built-in tmux-like API and skill makes it simple. - **Live pane headers** — See your active directory, git branch, and context usage in every pane title bar, updating live as you work. Fresh-agent panes carry their context meter in their status strip instead of the header. - **Activity notifications** — Configurable attention indicators (highlight, pulse, darken) on tabs and pane headers when a coding CLI finishes its turn, with click or type dismiss modes @@ -44,27 +44,43 @@ cd freshell # Install dependencies npm install -# Build and run +# Build the client, tools, and Rust server, then run it npm run serve ``` -On first run, freshell auto-generates a `.env` file with a secure random `AUTH_TOKEN`. The token is printed to the console at startup — open the URL shown to connect. +On first run, `npm run serve`, `npm run dev`, `npm run dev:server`, and the +Rust launcher create a private `.env` file with a secure random `AUTH_TOKEN` if +one is not already supplied. Existing environment variables and `.env` values +are preserved. The Rust server prints the URL at startup — open it to connect. + +For a development checkout, use `npm run dev` for Vite plus the Rust server, +or `PORT=3499 npm run dev:server` for the Rust server without Vite. For a +previously built checkout, `scripts/launch-rust.sh --port 3499` builds and +starts an isolated Rust instance; use a port other than the live self-hosted +port when testing a worktree. ## Prerequisites -Node.js 18+ (20+ recommended) and platform build tools for native modules (`windows-build-tools` on Windows, Xcode CLI Tools on macOS, `build-essential python3` on Linux). +Node.js 22.5+ and Rust stable are required. Node is used for the client, +standalone CLI/MCP tools, and Electron build; the Rust toolchain builds the +`freshell-server` binary and owns PTY support. Platform-specific build tools +are documented in [Building the Windows Electron App](docs/development/windows-electron-build.md). > **Note:** On native Windows, terminals default to WSL. Set `WINDOWS_SHELL=cmd` or `WINDOWS_SHELL=powershell` to use a native Windows shell instead. ## Usage ```bash -npm run dev # Development with hot reload -npm run serve # Production build and run +npm run dev # Vite + Rust server with hot reload +npm run serve # Build and run the Rust server ``` `npm run serve` is intended for `main`. If you run it from another branch, Freshell asks for confirmation in an interactive terminal and refuses in non-interactive shells unless `FRESHELL_ALLOW_NON_MAIN_SERVE=1` is set. +For unattended operation, build `freshell-server` and install the optional +user service in [`installers/systemd/freshell-rust.service`](installers/systemd/freshell-rust.service). +The service is standalone and independent of Electron. + ## Stream Deck Freshell can drive an Elgato Stream Deck straight from the browser. Each key shows a tab — by default the **Status icons** style: title on top, centered repo icons, and a status background (green for tabs that want attention), with keys sorted so attention-seeking tabs come first. Press a key to focus that tab; long-press (500 ms) to open an action layer with BACK / APPROVE / STOP keys (it closes itself after 10 s). When you have more tabs than keys, the last key pages through them (wrapping around). On a Stream Deck +, the dials cycle tabs and flip pages and the touch strip shows the active tab plus busy/waiting counts (waiting = tabs that finished a turn or are waiting for approval). The deck dims after a configurable idle timeout and wakes on activity. @@ -121,9 +137,12 @@ Then unplug and replug the deck. Without the rule, the connection status shows " | Variable | Required | Description | |----------|----------|-------------| | `AUTH_TOKEN` | Auto | Authentication token (auto-generated on first run, min 16 chars) | -| `PORT` | No | Server port (default: 3001) | +| `PORT` | No | Rust server port (default: 3001) | +| `FRESHELL_BIND_HOST` | No | Explicit Rust server bind host, such as `127.0.0.1` or `0.0.0.0` | +| `FRESHELL_HOME` | No | Freshell state/config home (default: the user's home directory) | | `ALLOWED_ORIGINS` | No | Auto-managed CORS origins for the active server bind host and LAN IPs | | `EXTRA_ALLOWED_ORIGINS` | No | Comma-separated custom CORS origins preserved across runtime origin rebuilds | +| `RUST_LOG` | No | Rust structured-log filter (default: `info`) | | `CLAUDE_HOME` | No | Path to Claude config directory (default: `~/.claude`) | | `CODEX_HOME` | No | Path to Codex config directory (default: `~/.codex`) | | `WINDOWS_SHELL` | No | Windows shell: `wsl` (default), `cmd`, or `powershell` | @@ -135,6 +154,10 @@ Then unplug and replug the deck. Without the rule, the connection status shows " | `KIMI_CMD` | No | Kimi CLI command override | | `AMPLIFIER_CMD` | No | Amplifier CLI command override | | `GOOGLE_GENERATIVE_AI_API_KEY` | No | Gemini API key for AI-powered terminal summaries | +| `FRESHELL_CLAUDE_NODE` | No | Node executable for the isolated Claude SDK sidecar (normally set by Electron) | +| `FRESHELL_CLAUDE_SIDECAR` | No | Claude sidecar entrypoint override for Rust development/service runs | +| `FRESHELL_MCP_NODE` | No | Node executable for the standalone MCP client | +| `FRESHELL_MCP_ENTRY` | No | Standalone MCP client entrypoint override | ### Coding CLI Providers @@ -156,23 +179,62 @@ OpenCode permissions are controlled by the OpenCode configuration for the OS use Amplifier loads the freshell MCP only if its bundle mounts `tool-mcp` (the default `anchors` bundle does not). Add `tool-mcp` to your Amplifier bundle to enable orchestration. +### Standalone CLI and MCP client + +The Rust server is the only Freshell HTTP/WebSocket backend. The Node programs +under `tools/` are clients: they connect to an already-running Rust server and +do not start one. + +```bash +npm run build:tools +FRESHELL_URL=http://localhost:3001 FRESHELL_TOKEN= \ + node dist/tools/freshell-cli/index.js list-tabs +FRESHELL_URL=http://localhost:3001 FRESHELL_TOKEN= \ + node dist/tools/freshell-mcp/server.js +``` + +When Freshell starts a terminal, it supplies the MCP client endpoint through +`FRESHELL_URL` and `FRESHELL_TOKEN`. In the packaged desktop app, the native +Rust server is under `resources/bin/`; the packaged Node runtime and MCP client +are separate resources. Claude fresh-agent panes use the isolated +`crates/freshell-claude-sidecar` package, which wraps the Claude SDK over +newline-delimited JSON on stdin/stdout. The sidecar is not a network service. + +### Rust server scope + +The Rust server supports the browser UI, terminal and session workflows, the +supported agent pane flows, and the retained CLI/MCP actions. A small set of +legacy Node-only operations is intentionally unavailable: server-managed +extension processes/assets, external-editor reveal, the old command-running and +direct fresh-agent-send APIs, legacy coding-client WebSocket messages, paged +fresh-agent transcript/viewport APIs, and remote browser forwarding. Use a +terminal pane or the supported Rust REST/WS/MCP operations instead. The session +repair/backfill and remaining parity work are tracked in the project parity +checklist and existing issues; they are not silently presented as supported. + ## Tech Stack - **Frontend**: React 18, Redux Toolkit, Tailwind CSS, xterm.js, Monaco Editor, Zod, lucide-react -- **Backend**: Express, WebSocket (ws), node-pty, Pino, Chokidar, Zod +- **Backend**: Rust `freshell-server`, Axum, Tokio, portable-pty, SQLite, and structured JSONL logging +- **Client tooling**: Node.js standalone CLI and stdio MCP client +- **Claude integration**: isolated Node Claude SDK sidecar, launched by the Rust fresh-agent runtime - **Build**: Vite, TypeScript -- **Testing**: Vitest, Testing Library, supertest, superwstest -- **AI**: Vercel AI SDK with Google Gemini +- **Testing**: Vitest, Testing Library, Playwright, and Cargo tests +- **AI**: Google Gemini integration in the Rust server ## Extensions -Freshell supports custom pane types via extensions. Three categories are available: +Freshell discovers extension manifests and supports CLI extensions in terminal +panes. The Rust server does not render extension iframe panes: -- **Client** — Static HTML/JS served by freshell (no server needed) -- **Server** — Your own HTTP server, managed by freshell with automatic port allocation - **CLI** — Any terminal tool wrapped as a pane +- **Client** — Not available as a Freshell pane +- **Server-hosted** — Not available as a Freshell pane; run the service + separately and open it as a supported browser pane when appropriate -Drop a directory with a `freshell.json` manifest into `~/.freshell/extensions/` and restart freshell. See [`examples/extensions/`](examples/extensions/) for working examples of each type. +Drop a directory with a `freshell.json` manifest into `~/.freshell/extensions/` +and restart Freshell. See [`examples/extensions/`](examples/extensions/) for +CLI examples and historical client/server manifests. ## Contributing diff --git a/config/electron-builder.yml b/config/electron-builder.yml index 68b0a9d83..537f707b2 100644 --- a/config/electron-builder.yml +++ b/config/electron-builder.yml @@ -13,50 +13,57 @@ directories: # - dist/electron/** (main process code) # - dist/wizard/** (wizard renderer bundle) # -# Everything the standalone bundled Node.js binary needs is placed in +# Everything the standalone Node.js client runtimes need is placed in # extraResources, which lives on the REAL filesystem. A vanilla Node.js # process cannot read from ASAR archives -- it would get ENOENT/MODULE_NOT_FOUND. # This includes: -# - dist/server/** (the Freshell server code) -# - dist/client/** (static web assets served by Express) -# - server-node-modules/** (pruned runtime dependencies for the server) -# - bundled-node/bin/** (the standalone Node.js binary) -# - bundled-node/native-modules/** (recompiled node-pty) +# - electron-runtime/bin/** (the native Rust server) +# - electron-runtime/client/** (static web assets served by Rust) +# - electron-runtime/node/** (the sanctioned standalone Node runtime) +# - electron-runtime/claude-sidecar/** (the Claude SDK client) +# - electron-runtime/mcp/** (the checkout-free stdio MCP client) +# - electron-runtime/node-client-runtime/** (the MCP client support modules) files: - dist/electron/** - dist/wizard/** - package.json +# Electron's app-bound process is Rust; there are no native Node addons to +# rebuild for Electron's ABI. +npmRebuild: false + extraResources: - # The standalone Node.js binary - - from: bundled-node/${os}/${arch} - to: bundled-node/bin + # The app-bound backend is always the host-native Rust executable. + - from: electron-runtime/bin + to: bin filter: - "**/*" - # Recompiled native modules (node-pty against bundled Node ABI) - - from: bundled-node/native-modules - to: bundled-node/native-modules + # Static client assets served by the Rust backend. + - from: electron-runtime/client + to: client filter: - "**/*" - # The Freshell server (runs under bundled Node, NOT Electron) - - from: dist/server - to: server + # Node is present only for the sanctioned Claude and MCP clients. + - from: electron-runtime/node + to: node filter: - "**/*" - # Static client assets (served by Express in production) - - from: dist/client - to: client + - from: electron-runtime/claude-sidecar + to: claude-sidecar filter: - "**/*" - # Launch chooser assets (loaded from the real filesystem before connecting) - - from: dist/launch-chooser - to: launch-chooser + - from: electron-runtime/mcp + to: mcp filter: - "**/*" - # Pruned server runtime dependencies (see prepare-bundled-node.ts Step 4) - - from: server-node-modules - to: server-node-modules + - from: electron-runtime/node-client-runtime + to: node-client-runtime + filter: + - "**/*" + # Launch chooser assets (loaded from the real filesystem before connecting). + - from: dist/launch-chooser + to: launch-chooser filter: - "**/*" # Tray icons (needed at runtime for system tray) @@ -64,11 +71,6 @@ extraResources: to: assets filter: - "tray-icon*" - # Installer templates (daemon service definitions for launchd/systemd/Windows Task Scheduler) - - from: installers - to: installers - filter: - - "**/*" mac: category: public.app-category.developer-tools diff --git a/config/vite/get-network-host.ts b/config/vite/get-network-host.ts new file mode 100644 index 000000000..e767c450a --- /dev/null +++ b/config/vite/get-network-host.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import type { FreshellEnvironment } from '../../shared/freshell-home.js' + +export type NetworkHostOptions = { + env: FreshellEnvironment + configDir: string + isWsl: boolean +} + +/** Return whether this process is running inside WSL. */ +export function isWSL(): boolean { + try { + return readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft') + } catch { + return false + } +} + +/** + * Resolve the host Vite should bind to. The function is deliberately pure + * with respect to process state: callers provide environment, config path, + * and WSL detection so Vite and tests can use the same policy without taking + * a dependency on the legacy Node server. + */ +export function getNetworkHost({ env, configDir, isWsl }: NetworkHostOptions): string { + const bindOverride = env.FRESHELL_BIND_HOST + if (bindOverride === '0.0.0.0' || bindOverride === '127.0.0.1') { + return bindOverride + } + + // WSL must bind all interfaces so the Windows host can reach the dev server. + if (isWsl) return '0.0.0.0' + + try { + const configPath = join(configDir, 'config.json') + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + settings?: { network?: { host?: unknown; configured?: unknown } } + } + const network = config.settings?.network + const host = network?.host === '0.0.0.0' || network?.host === '127.0.0.1' + ? network.host + : '127.0.0.1' + const configured = network?.configured ?? false + if (!configured && (env.HOST === '0.0.0.0' || env.HOST === '127.0.0.1')) { + return env.HOST + } + return host + } catch { + if (env.HOST === '0.0.0.0' || env.HOST === '127.0.0.1') return env.HOST + return '127.0.0.1' + } +} diff --git a/config/vite/vite.config.ts b/config/vite/vite.config.ts index b1a564c81..ec83f6639 100644 --- a/config/vite/vite.config.ts +++ b/config/vite/vite.config.ts @@ -4,7 +4,8 @@ import react from '@vitejs/plugin-react' import path from 'path' import { fileURLToPath } from 'url' import { execFileSync } from 'node:child_process' -import { getNetworkHost } from '../../server/get-network-host.js' +import { getFreshellConfigDir } from '../../shared/freshell-home.js' +import { getNetworkHost, isWSL } from './get-network-host.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -64,7 +65,8 @@ function silenceStartupErrors(proxy: HttpProxy.Server) { } export default defineConfig(({ mode }) => { - const env = loadEnv(mode, projectRoot, '') + // Vite reads .env into `env`; process.env remains the explicit override. + const env = { ...loadEnv(mode, projectRoot, ''), ...process.env } const backendPort = process.env.PORT || env.PORT || '3001' const backendHost = process.env.VITE_BACKEND_HOST || process.env.BACKEND_HOST || env.VITE_BACKEND_HOST || env.BACKEND_HOST || '127.0.0.1' const backendUrl = `http://${backendHost}:${backendPort}` @@ -93,7 +95,11 @@ export default defineConfig(({ mode }) => { chunkSizeWarningLimit: 1400, }, server: { - host: getNetworkHost(), + host: getNetworkHost({ + env, + configDir: getFreshellConfigDir(env), + isWsl: isWSL(), + }), allowedHosts, port: vitePort, watch: { diff --git a/config/vitest/vitest.codex-real-provider-smoke.config.ts b/config/vitest/vitest.codex-real-provider-smoke.config.ts deleted file mode 100644 index de0496028..000000000 --- a/config/vitest/vitest.codex-real-provider-smoke.config.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Vitest inherits NODE_ENV from the parent process. Override when running -// inside a production Freshell server. -if (process.env.NODE_ENV === 'production') { - process.env.NODE_ENV = 'test' -} - -import { defineConfig } from 'vitest/config' -import path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const projectRoot = path.resolve(__dirname, '../..') - -export default defineConfig({ - root: projectRoot, - resolve: { - alias: { - '@': path.resolve(projectRoot, './src'), - '@test': path.resolve(projectRoot, './test'), - '@shared': path.resolve(projectRoot, './shared'), - }, - }, - test: { - environment: 'node', - globalSetup: ['./test/setup/server-global-setup.ts'], - include: [ - 'test/integration/server/codex-real-provider-smoke.test.ts', - ], - testTimeout: 60000, - hookTimeout: 30000, - pool: 'threads', - poolOptions: { - threads: { - singleThread: false, - isolate: true, - }, - }, - }, -}) diff --git a/config/vitest/vitest.config.ts b/config/vitest/vitest.config.ts index 5e97dfb1f..841f4a4da 100644 --- a/config/vitest/vitest.config.ts +++ b/config/vitest/vitest.config.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const projectRoot = path.resolve(__dirname, '../..') +const realProviderContractsEnabled = process.env.FRESHELL_RUN_REAL_PROVIDER_CONTRACTS === '1' export default defineConfig({ root: projectRoot, @@ -28,24 +29,16 @@ export default defineConfig({ setupFiles: ['./test/setup/dom.ts'], exclude: [ '**/node_modules/**', - '**/server-node-modules/**', - '**/bundled-node/**', '**/.worktrees/**', '**/.claude/worktrees/**', 'docs/plans/**', // Port contract-freeze tests run under config/vitest/vitest.port.config.ts (node environment) 'test/unit/port/**', - // Server tests run under config/vitest/vitest.server.config.ts (node environment) - 'test/server/**', - 'test/unit/server/**', - 'test/integration/server/**', - 'test/unit/visible-first/read-model-route-harness.test.ts', - 'test/unit/visible-first/terminal-mirror-fixture.test.ts', - 'test/unit/visible-first/cli-command-harness.test.ts', - 'test/integration/session-repair.test.ts', - 'test/integration/session-search-e2e.test.ts', + // These integration trees own their own runtime/artifact setup. + 'test/integration/tooling/**', + 'test/integration/electron/**', 'test/e2e-browser/**', - 'test/integration/real/**', + ...(realProviderContractsEnabled ? [] : ['test/integration/real/**']), // Electron tests run under config/vitest/vitest.electron.config.ts (node environment) 'test/unit/electron/**', // Electron E2E tests run under Playwright, not Vitest diff --git a/config/vitest/vitest.electron-runtime.config.ts b/config/vitest/vitest.electron-runtime.config.ts new file mode 100644 index 000000000..b7f451377 --- /dev/null +++ b/config/vitest/vitest.electron-runtime.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const configDir = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(configDir, '../..') + +/** + * The checkout-free Electron runtime lane is intentionally separate from the + * ordinary Electron unit tests. It owns a staged artifact and must never + * silently pass when its integration test is not selected. + */ +export default defineConfig({ + root: projectRoot, + test: { + environment: 'node', + include: ['test/integration/electron/**/*.test.ts'], + exclude: ['docs/plans/**'], + passWithNoTests: false, + testTimeout: 120_000, + hookTimeout: 120_000, + }, +}) diff --git a/config/vitest/vitest.opencode-serve-real-provider-smoke.config.ts b/config/vitest/vitest.opencode-serve-real-provider-smoke.config.ts deleted file mode 100644 index 946607372..000000000 --- a/config/vitest/vitest.opencode-serve-real-provider-smoke.config.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Vitest inherits NODE_ENV from the parent process. Override when running -// inside a production Freshell server. -if (process.env.NODE_ENV === 'production') { - process.env.NODE_ENV = 'test' -} - -import { defineConfig } from 'vitest/config' -import path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const projectRoot = path.resolve(__dirname, '../..') - -export default defineConfig({ - root: projectRoot, - resolve: { - alias: { - '@': path.resolve(projectRoot, './src'), - '@test': path.resolve(projectRoot, './test'), - '@shared': path.resolve(projectRoot, './shared'), - }, - }, - test: { - environment: 'node', - globalSetup: ['./test/setup/server-global-setup.ts'], - include: [ - 'test/integration/server/opencode-serve-real-provider-smoke.test.ts', - ], - testTimeout: 120000, - hookTimeout: 30000, - pool: 'threads', - poolOptions: { - threads: { - singleThread: true, - isolate: true, - }, - }, - }, -}) diff --git a/config/vitest/vitest.oracle-t2.config.ts b/config/vitest/vitest.oracle-t2.config.ts deleted file mode 100644 index 3db55c8ff..000000000 --- a/config/vitest/vitest.oracle-t2.config.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Vitest inherits NODE_ENV from the parent process. When this runs from inside -// a production Freshell server (NODE_ENV=production), force it back to `test` -// so the harness boots cleanly. -if (process.env.NODE_ENV === 'production') { - process.env.NODE_ENV = 'test' -} - -import { defineConfig } from 'vitest/config' -import path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const projectRoot = path.resolve(__dirname, '../..') - -/** - * Dedicated config for the equivalence oracle's T2 LIVE behavioral-invariant - * tests (`test/integration/port/oracle/**`). - * - * These boot a REAL external freshell server, seed provider auth into an - * isolated HOME, and make a LIVE (cheap) model call — so, like vitest.oracle: - * - NO globalSetup (the harness owns build + boot + reap of its own server). - * - node environment; VERY generous timeout: a Kimi round-trip can take - * 30–120s on top of a cold server boot. - * - single-fork / no file parallelism so spawned ports & pids never contend - * and only one live turn is in flight at a time. - * - * DELIBERATELY separate from vitest.oracle.config.ts (the fast T0/T1 rungs) and - * NOT wired into the shared test-coordinator/full-suite. Run explicitly and - * only with the gate ON: - * FRESHELL_RUN_REAL_PROVIDER_CONTRACTS=1 npm run test:oracle:t2 - */ -export default defineConfig({ - root: projectRoot, - resolve: { - alias: { - '@': path.resolve(projectRoot, './src'), - '@test': path.resolve(projectRoot, './test'), - '@shared': path.resolve(projectRoot, './shared'), - }, - }, - test: { - environment: 'node', - include: ['test/integration/port/oracle/**/*.test.ts'], - testTimeout: 240000, - hookTimeout: 240000, - pool: 'forks', - poolOptions: { - forks: { - singleFork: true, - }, - }, - fileParallelism: false, - }, -}) diff --git a/config/vitest/vitest.oracle.config.ts b/config/vitest/vitest.oracle.config.ts index ba1deedf2..f2debae0c 100644 --- a/config/vitest/vitest.oracle.config.ts +++ b/config/vitest/vitest.oracle.config.ts @@ -14,16 +14,15 @@ const __dirname = path.dirname(__filename) const projectRoot = path.resolve(__dirname, '../..') /** - * Dedicated config for the equivalence oracle's LIVE conformance tests + * Dedicated config for the Rust oracle's live conformance tests * (`test/unit/port/oracle/**`). * * Unlike the fast contract-freeze drift guard (config/vitest/vitest.port.config.ts), - * these tests boot a REAL external freshell server process via + * these tests boot a real external Rust server via * `port/oracle/harness/external-server.ts`, so: - * - NO globalSetup: the harness ensures `dist/server/index.js` is built and - * boots/reaps its own isolated server. We must NOT trigger the server - * global-setup dist rebuild here. - * - node environment, generous 120s timeout for cold boot + first build. + * - NO globalSetup: the harness builds the worktree's release binary and + * boots/reaps its own isolated server. + * - Node test environment, generous 120s timeout for cold boot + first build. * - single-fork / no file parallelism so spawned ports & pids never contend. * * NOT wired into the shared test-coordinator/full-suite — run explicitly via diff --git a/config/vitest/vitest.runtime.config.ts b/config/vitest/vitest.runtime.config.ts new file mode 100644 index 000000000..0970f6295 --- /dev/null +++ b/config/vitest/vitest.runtime.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vitest/config' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const CONFIG_DIR = path.dirname(fileURLToPath(import.meta.url)) +const PROJECT_ROOT = path.resolve(CONFIG_DIR, '../..') + +export default defineConfig({ + root: PROJECT_ROOT, + resolve: { + alias: { + '@': path.resolve(PROJECT_ROOT, './src'), + '@test': path.resolve(PROJECT_ROOT, './test'), + '@shared': path.resolve(PROJECT_ROOT, './shared'), + }, + }, + test: { + environment: 'node', + include: ['test/integration/tooling/source-runtime-rust.test.ts'], + exclude: ['docs/plans/**', '**/node_modules/**', '**/.worktrees/**'], + passWithNoTests: false, + testTimeout: 90_000, + hookTimeout: 30_000, + pool: 'threads', + poolOptions: { + threads: { + singleThread: true, + isolate: true, + }, + }, + fileParallelism: false, + }, +}) diff --git a/config/vitest/vitest.server.config.ts b/config/vitest/vitest.server.config.ts deleted file mode 100644 index fcfe860e4..000000000 --- a/config/vitest/vitest.server.config.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Vitest inherits NODE_ENV from the parent process. Override when running -// inside a production Freshell server. -if (process.env.NODE_ENV === 'production') { - process.env.NODE_ENV = 'test' -} - -import { defineConfig } from 'vitest/config' -import path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const projectRoot = path.resolve(__dirname, '../..') - -export default defineConfig({ - root: projectRoot, - resolve: { - alias: { - '@': path.resolve(projectRoot, './src'), - '@test': path.resolve(projectRoot, './test'), - '@shared': path.resolve(projectRoot, './shared'), - }, - }, - test: { - environment: 'node', - globalSetup: ['./test/setup/server-global-setup.ts'], - include: [ - 'test/server/**/*.test.ts', - 'test/unit/server/**/*.test.ts', - 'test/unit/visible-first/**/*.test.ts', - 'test/integration/server/**/*.test.ts', - 'test/integration/real/**/*.test.ts', - 'test/integration/session-repair.test.ts', - 'test/integration/session-search-e2e.test.ts', - 'test/integration/extension-system.test.ts', - ], - exclude: [ - 'docs/plans/**', - 'test/integration/server/codex-real-provider-smoke.test.ts', - 'test/integration/server/opencode-serve-real-provider-smoke.test.ts', - 'test/unit/visible-first/slow-network-controller.test.ts', - ], - testTimeout: 30000, - hookTimeout: 30000, - // Maximum parallelization settings - pool: 'threads', - poolOptions: { - threads: { - singleThread: false, - isolate: true, - }, - }, - fileParallelism: true, - maxConcurrency: 10, - sequence: { - shuffle: true, // Detect order-dependent tests - }, - }, -}) diff --git a/crates/freshell-extensions/Cargo.toml b/crates/freshell-extensions/Cargo.toml index 4012f52dd..22bb7ed46 100644 --- a/crates/freshell-extensions/Cargo.toml +++ b/crates/freshell-extensions/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "freshell-extensions" version = "0.1.0" -description = "Extension manifest + registry substrate for the freshell Rust port (df1 EXT-01+): the STRICT freshell.json validator, ported behavior-for-behavior from the legacy zod-4 schema (server/extension-manifest.ts) and pinned by a generated differential oracle (crates/freshell-extensions/fixtures/manifest-oracle.json, produced by port/contract/generate-manifest-oracle.ts). Deliberately I/O-free: callers hand in manifest file TEXT, receive either the fully-typed manifest (defaults materialized) or zod-parity issues." +description = "Extension manifest + registry substrate for the freshell Rust port (df1 EXT-01+): the STRICT freshell.json validator, pinned by the frozen migration fixture crates/freshell-extensions/fixtures/manifest-oracle.json. Deliberately I/O-free: callers hand in manifest file TEXT, receive either the fully-typed manifest (defaults materialized) or parity issues." edition.workspace = true rust-version.workspace = true publish.workspace = true diff --git a/crates/freshell-extensions/src/lib.rs b/crates/freshell-extensions/src/lib.rs index 17da56c0b..0025f0f98 100644 --- a/crates/freshell-extensions/src/lib.rs +++ b/crates/freshell-extensions/src/lib.rs @@ -1,7 +1,6 @@ //! Extension manifest validation for the freshell Rust port (df1 EXT-01). //! -//! Ports the legacy strict manifest schema — `server/extension-manifest.ts` -//! (zod 4.3.6, the package-lock pin) — with behavior-for-behavior parity: +//! Ports the strict manifest schema with behavior-for-behavior parity: //! //! * strict objects reject unknown keys at every level (`unrecognized_keys`) //! * category↔config-block coupling refine (exactly one `client`/`server`/ @@ -21,11 +20,9 @@ //! emission order (schema-definition order; `unrecognized_keys` last per //! object; refines after their object's base issues) //! -//! Behavior is pinned by a differential oracle: -//! `fixtures/manifest-oracle.json` (124 cases) generated from the UNMODIFIED -//! legacy schema by `port/contract/generate-manifest-oracle.ts`; iterated by -//! `tests/oracle.rs`. Never hand-edit the fixture to match this crate — -//! regenerate it and fix the crate instead. +//! Behavior is pinned by the frozen migration fixture +//! `fixtures/manifest-oracle.json` (124 cases), iterated by `tests/oracle.rs`. +//! Keep the fixture as provenance and fix this crate when it exposes a mismatch. //! //! Locale note: JSON text in, typed manifest out. No I/O, no clocks, no //! randomness — hermetic by construction. diff --git a/crates/freshell-extensions/tests/oracle.rs b/crates/freshell-extensions/tests/oracle.rs index c61339c5e..faa722b43 100644 --- a/crates/freshell-extensions/tests/oracle.rs +++ b/crates/freshell-extensions/tests/oracle.rs @@ -1,7 +1,6 @@ //! Differential oracle conformance test (df1 EXT-01). //! -//! Iterates `fixtures/manifest-oracle.json` — generated from the UNMODIFIED -//! legacy zod-4.3.6 schema by `port/contract/generate-manifest-oracle.ts` — +//! Iterates the frozen migration fixture `fixtures/manifest-oracle.json` — //! and asserts, for every case: //! * same verdict class (valid / invalid-manifest / invalid-JSON-text) //! * on success: the typed manifest re-serializes to EXACTLY zod's output @@ -11,8 +10,7 @@ //! matches byte-for-byte IN ORDER //! //! NEVER patch this test's expectations or the fixture to match the crate. -//! The legacy schema is the oracle; fix the crate (or regenerate the fixture -//! from the legacy schema after a deliberate legacy change / zod bump). +//! The fixture is frozen provenance; fix the crate when it diverges. use freshell_extensions::{parse_manifest, ManifestError}; @@ -42,22 +40,6 @@ fn js_value_eq(a: &serde_json::Value, b: &serde_json::Value) -> bool { #[test] fn oracle_conformance() { let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("oracle fixture parses"); - let meta = &fixture["meta"]; - assert_eq!( - meta["schemaSource"].as_str().unwrap(), - "server/extension-manifest.ts (UNMODIFIED legacy zod schema)" - ); - // Exact-version pin: the fixture is only meaningful when generated by the - // LOCK-PINNED zod. The generator hard-refuses on a drifted node_modules; - // this assert is the crate-side tripwire (update together with the lock - // pin when deliberately bumping zod). - assert_eq!( - meta["zodVersion"].as_str().unwrap(), - "4.3.6", - "fixture must derive from the package-lock-pinned zod, got {}", - meta["zodVersion"] - ); - let cases = fixture["cases"].as_array().expect("cases array"); assert!( cases.len() >= 100, @@ -136,3 +118,30 @@ fn oracle_conformance() { assert!(parse_error >= 1, "expected at least one parse-error case"); eprintln!("oracle conformance: {valid} valid / {invalid} invalid / {parse_error} parse-error cases ALL MATCH"); } + +#[test] +fn frozen_fixture_is_nonempty_and_schema_mutations_are_rejected() { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("oracle fixture parses"); + let cases = fixture["cases"].as_array().expect("cases array"); + assert!(!cases.is_empty(), "frozen fixture must contain cases"); + + let valid = cases + .iter() + .find(|case| case["expected"]["success"].as_bool() == Some(true)) + .expect("frozen fixture must contain a valid case"); + let raw = valid["rawText"].as_str().expect("valid rawText"); + let mut value: serde_json::Value = + serde_json::from_str(raw).expect("valid case parses as JSON"); + value + .as_object_mut() + .expect("valid manifest case is an object") + .insert( + "__oracle_mutation__".to_string(), + serde_json::Value::Bool(true), + ); + let mutated = serde_json::to_string(&value).expect("mutated manifest serializes"); + assert!( + matches!(parse_manifest(&mutated), Err(ManifestError::Invalid(_))), + "adding an unknown manifest key must change the verdict" + ); +} diff --git a/crates/freshell-platform/src/cli_launch.rs b/crates/freshell-platform/src/cli_launch.rs index e2970f2d2..fa26537b2 100644 --- a/crates/freshell-platform/src/cli_launch.rs +++ b/crates/freshell-platform/src/cli_launch.rs @@ -88,7 +88,7 @@ pub enum LaunchIntent { Resume, } -/// `McpInjection` (`server/mcp/config-writer.ts:247-250`) — the per-mode MCP +/// `McpInjection` (the retained standalone MCP client) — the per-mode MCP /// config injection result, precomputed by the IO layer /// ([`crate::mcp_inject::generate_mcp_injection`]) and consumed by /// [`resolve_coding_cli_command`]. diff --git a/crates/freshell-platform/src/cli_launch_goldens.rs b/crates/freshell-platform/src/cli_launch_goldens.rs index 09de8cbc7..ef7d9d056 100644 --- a/crates/freshell-platform/src/cli_launch_goldens.rs +++ b/crates/freshell-platform/src/cli_launch_goldens.rs @@ -18,7 +18,7 @@ const CLAUDE_SETTINGS_WIN: &str = r#"{"hooks":{"SessionStart":[{"hooks":[{"type" const MCP_UNIX: &[&str] = &[ "--import", "/repo/node_modules/tsx/dist/loader.mjs", - "/repo/server/mcp/server.ts", + "/repo/tools/freshell-mcp/server.ts", ]; struct MapEnv(BTreeMap); @@ -283,7 +283,7 @@ fn g_x1_codex_live_fresh() { "-c".to_string(), r#"mcp_servers.freshell.command="node""#.to_string(), "-c".to_string(), - r#"mcp_servers.freshell.args=["--import", "/repo/node_modules/tsx/dist/loader.mjs", "/repo/server/mcp/server.ts"]"#.to_string(), + r#"mcp_servers.freshell.args=["--import", "/repo/node_modules/tsx/dist/loader.mjs", "/repo/tools/freshell-mcp/server.ts"]"#.to_string(), ] ); assert!(launch.env.is_empty()); // folded from retired G-X0 (S5.e) @@ -330,7 +330,7 @@ fn g_x3_codex_no_app_server_model_sandbox() { "-c".to_string(), r#"mcp_servers.freshell.command="node""#.to_string(), "-c".to_string(), - r#"mcp_servers.freshell.args=["--import", "/repo/node_modules/tsx/dist/loader.mjs", "/repo/server/mcp/server.ts"]"#.to_string(), + r#"mcp_servers.freshell.args=["--import", "/repo/node_modules/tsx/dist/loader.mjs", "/repo/tools/freshell-mcp/server.ts"]"#.to_string(), "--model".to_string(), "gpt-5.1-codex".to_string(), "--sandbox".to_string(), diff --git a/crates/freshell-platform/src/mcp_inject.rs b/crates/freshell-platform/src/mcp_inject.rs index a04955842..440068def 100644 --- a/crates/freshell-platform/src/mcp_inject.rs +++ b/crates/freshell-platform/src/mcp_inject.rs @@ -1,4 +1,4 @@ -//! MCP config injection — the IO port of `server/mcp/config-writer.ts` +//! MCP config injection for the retained standalone MCP client //! (`port/machine/specs/cli-argv-fidelity.md` §3.2). //! //! Per-mode injection (`generateMcpInjection`, `cw:252-423`): @@ -20,10 +20,10 @@ //! server of its own, so this port adopts **option (a)**: resolve the SAME //! Node-repo layout — repo root found by walking up from the process cwd //! looking for a `package.json` with `"name": "freshell"` (the reference walks -//! from `server/mcp/`; both resolve the same root when the server runs from +//! from the standalone tools tree; both resolve the same root when the server runs from //! the repo checkout, which is the deployment under test) — and inject the //! reference-identical `node --import /node_modules/tsx/dist/loader.mjs -//! /server/mcp/server.ts` (dev) or `/dist/server/mcp/server.js` +//! /tools/freshell-mcp/server.ts` (dev) or `/dist/tools/freshell-mcp/server.js` //! (production build present + `NODE_ENV=production`). When `tsx` cannot be //! resolved the reference-exact error is raised (`cw:72-79`). The golden tests //! inject [`McpRuntime::server_command_args`] as a seam, so they remain valid @@ -68,6 +68,15 @@ pub enum McpServerArg { Path(String), } +/// An MCP command is a complete executable plus its arguments. Keeping the +/// executable tagged alongside arguments prevents platform conversion from +/// silently leaving a path-valued command on the wrong side of WSL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpServerCommand { + pub command: McpServerArg, + pub args: Vec, +} + /// The environment seam for the config writer: tmp dir (`os.tmpdir()`), WSL /// detection (`cw:45-51`), `wslpath -w` conversion (`cw:57-70`), and the MCP /// server command args (U1 seam — `cw:89-107`). @@ -76,14 +85,23 @@ pub trait McpRuntime { fn tmp_dir(&self) -> PathBuf; /// `isWslEnvironment()` (`cw:45-51`): linux && (WSL_DISTRO_NAME || WSL_INTEROP || WSLENV). fn is_wsl_environment(&self) -> bool; - /// `convertToWindowsPath` (`cw:57-70`): `wslpath -w`, 3s timeout, input on failure. + /// `convertToWindowsPath`: `wslpath -w`, 3s timeout, and the host path + /// unchanged when conversion is unavailable or fails. /// Callers must pre-gate on [`Self::is_wsl_environment`] (as the reference does /// via `needsWinPaths`). fn convert_to_windows_path(&self, linux_path: &str) -> String; /// The host-form MCP server command args (pre-conversion) — `cw:89-107` - /// minus the `needsWinPaths` mapping, which [`build_mcp_server_command_args`] - /// applies. + /// minus the `needsWinPaths` mapping applied by the command renderer. fn server_command_args(&self) -> Result, McpInjectError>; + + /// Complete server command. The default preserves the existing seam for + /// test runtimes while production overrides it with the explicit command. + fn server_command(&self) -> Result { + Ok(McpServerCommand { + command: McpServerArg::Literal("node".to_string()), + args: self.server_command_args()?, + }) + } } /// The live runtime (see the module-level U1 decision). @@ -108,15 +126,36 @@ impl McpRuntime for RealMcpRuntime { } fn server_command_args(&self) -> Result, McpInjectError> { + Ok(self.server_command()?.args) + } + + fn server_command(&self) -> Result { + let node = std::env::var("FRESHELL_MCP_NODE").ok(); + let entry = std::env::var("FRESHELL_MCP_ENTRY").ok(); + match (node, entry) { + (Some(command), Some(entry)) if !command.is_empty() && !entry.is_empty() => { + return Ok(McpServerCommand { + command: McpServerArg::Path(command), + args: vec![McpServerArg::Path(entry)], + }); + } + (Some(_), None) | (None, Some(_)) | (Some(_), Some(_)) => { + return Err(McpInjectError::new( + "FRESHELL_MCP_NODE and FRESHELL_MCP_ENTRY must be configured together.", + )); + } + (None, None) => {} + } let repo_root = find_repo_root(); - let built = repo_root.join("dist/server/mcp/server.js"); + let built = repo_root.join("dist/tools/freshell-mcp/server.js"); let node_env_production = std::env::var("NODE_ENV") .map(|v| v == "production") .unwrap_or(false); if node_env_production && built.is_file() { - return Ok(vec![McpServerArg::Path( - built.to_string_lossy().into_owned(), - )]); + return Ok(McpServerCommand { + command: McpServerArg::Literal("node".to_string()), + args: vec![McpServerArg::Path(built.to_string_lossy().into_owned())], + }); } // `require.resolve('tsx')` resolves the package export "." → // `./dist/loader.mjs` (rev 2 pin vs node_modules/tsx/package.json). @@ -126,16 +165,19 @@ impl McpRuntime for RealMcpRuntime { "Unable to resolve MCP dependency \"tsx\". Ensure project dependencies are installed.", )); } - Ok(vec![ - McpServerArg::Literal("--import".to_string()), - McpServerArg::Path(tsx.to_string_lossy().into_owned()), - McpServerArg::Path( - repo_root - .join("server/mcp/server.ts") - .to_string_lossy() - .into_owned(), - ), - ]) + Ok(McpServerCommand { + command: McpServerArg::Literal("node".to_string()), + args: vec![ + McpServerArg::Literal("--import".to_string()), + McpServerArg::Path(tsx.to_string_lossy().into_owned()), + McpServerArg::Path( + repo_root + .join("tools/freshell-mcp/server.ts") + .to_string_lossy() + .into_owned(), + ), + ], + }) } } @@ -164,62 +206,109 @@ fn find_repo_root() -> PathBuf { } /// `convertToWindowsPath`'s exec half: `wslpath -w ` with a 3s timeout, -/// falling back to the input on any failure (`cw:57-70`). +/// falling back to the input path if the utility is unavailable or fails. fn convert_to_windows_path_live(linux_path: &str) -> String { + convert_to_windows_path_with_command("wslpath", linux_path) +} + +/// Join a stdout reader only while the caller's process deadline remains. +/// +/// A helper process can outlive the command we spawned while inheriting its +/// stdout handle. In that case `read_to_end` cannot finish until the helper +/// exits, so an unconditional `JoinHandle::join` would defeat the conversion +/// timeout. Dropping the handle detaches that reader; it will finish when the +/// inherited pipe closes while the caller returns its bounded fallback. +fn join_reader_until( + reader: std::thread::JoinHandle, + deadline: std::time::Instant, +) -> Option { + while !reader.is_finished() { + if std::time::Instant::now() >= deadline { + return None; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + reader.join().ok() +} + +fn convert_to_windows_path_with_command(program: &str, linux_path: &str) -> String { + use std::io::Read; use std::process::{Command, Stdio}; - use std::sync::mpsc; - use std::time::Duration; + use std::time::{Duration, Instant}; - let child = Command::new("wslpath") + let child = Command::new(program) .arg("-w") .arg(linux_path) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn(); - let Ok(child) = child else { + let Ok(mut child) = child else { return linux_path.to_string(); }; - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - let _ = tx.send(child.wait_with_output()); + + let Some(mut stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return linux_path.to_string(); + }; + let reader = std::thread::spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) }); - match rx.recv_timeout(Duration::from_secs(3)) { - Ok(Ok(output)) if output.status.success() => { - let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if trimmed.is_empty() { - linux_path.to_string() - } else { - trimmed + + let deadline = Instant::now() + Duration::from_secs(3); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + let _ = join_reader_until(reader, deadline); + return linux_path.to_string(); + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = join_reader_until(reader, deadline); + return linux_path.to_string(); } } - // Failure or timeout (the reader thread reaps the child either way). - _ => linux_path.to_string(), + }; + let Some(Ok(output)) = join_reader_until(reader, deadline) else { + return linux_path.to_string(); + }; + + if status.success() { + let converted = String::from_utf8_lossy(&output).trim().to_string(); + if converted.is_empty() { + linux_path.to_string() + } else { + converted + } + } else { + linux_path.to_string() } } -/// `buildMcpServerCommandArgs(platform)` (`cw:89-107`): the runtime's host-form -/// args with the `needsWinPaths` conversion applied to path elements when -/// `platform === 'windows' && isWslEnvironment()`. -pub fn build_mcp_server_command_args( +/// Render a complete MCP command for a provider target. This is the canonical +/// path used by every injection renderer. +pub fn build_mcp_server_command( rt: &dyn McpRuntime, target: ProviderTarget, -) -> Result, McpInjectError> { +) -> Result<(String, Vec), McpInjectError> { let needs_win_paths = target == ProviderTarget::Windows && rt.is_wsl_environment(); - Ok(rt - .server_command_args()? - .into_iter() - .map(|arg| match arg { - McpServerArg::Literal(s) => s, - McpServerArg::Path(p) => { - if needs_win_paths { - rt.convert_to_windows_path(&p) - } else { - p - } - } - }) - .collect()) + let command = rt.server_command()?; + let convert = |arg: McpServerArg| match arg { + McpServerArg::Literal(value) => value, + McpServerArg::Path(value) if needs_win_paths => rt.convert_to_windows_path(&value), + McpServerArg::Path(value) => value, + }; + Ok(( + convert(command.command), + command.args.into_iter().map(convert).collect(), + )) } /// `tomlEscape` (`cw:142-144`): wrap in `"` with `\` → `\\` and `"` → `\"`. @@ -231,6 +320,12 @@ pub fn toml_escape(value: &str) -> String { /// joined with `", "` (comma + space, `cw:267`). Pure — exposed so the argv /// goldens can drive it with the §4 `MCP_UNIX` seam. pub fn codex_inline_toml_args(server_args: &[String]) -> Vec { + codex_inline_toml_command_args("node", server_args) +} + +/// Render Codex's command-plus-args pair without assuming the executable is +/// `node`; explicit packaged commands may themselves be path-valued. +pub fn codex_inline_toml_command_args(server_command: &str, server_args: &[String]) -> Vec { let toml_args = server_args .iter() .map(|a| toml_escape(a)) @@ -238,7 +333,10 @@ pub fn codex_inline_toml_args(server_args: &[String]) -> Vec { .join(", "); vec![ "-c".to_string(), - format!("mcp_servers.freshell.command={}", toml_escape("node")), + format!( + "mcp_servers.freshell.command={}", + toml_escape(server_command) + ), "-c".to_string(), format!("mcp_servers.freshell.args=[{toml_args}]"), ] @@ -285,11 +383,11 @@ fn write_mcp_config_file( if let Some(dir) = file_path.parent() { std::fs::create_dir_all(dir).map_err(|e| McpInjectError::new(e.to_string()))?; } - let server_args = build_mcp_server_command_args(rt, target)?; + let (server_command, server_args) = build_mcp_server_command(rt, target)?; let config = serde_json::json!({ "mcpServers": { "freshell": { - "command": "node", + "command": server_command, "args": server_args, } } @@ -477,12 +575,12 @@ fn opencode_inject( }; if !user_managed { - let server_args = build_mcp_server_command_args(rt, target)?; + let (server_command, server_args) = build_mcp_server_command(rt, target)?; let obj = existing_config.as_object_mut().expect("validated object"); if !obj.get("mcp").map(|m| m.is_object()).unwrap_or(false) { obj.insert("mcp".to_string(), serde_json::json!({})); } - let mut command = vec![serde_json::Value::String("node".to_string())]; + let mut command = vec![serde_json::Value::String(server_command)]; command.extend(server_args.into_iter().map(serde_json::Value::String)); obj.get_mut("mcp") .and_then(|m| m.as_object_mut()) @@ -548,9 +646,9 @@ pub fn generate_mcp_injection( }) } "codex" => { - let server_args = build_mcp_server_command_args(rt, target)?; + let (server_command, server_args) = build_mcp_server_command(rt, target)?; Ok(McpInjection { - args: codex_inline_toml_args(&server_args), + args: { codex_inline_toml_command_args(&server_command, &server_args) }, env: BTreeMap::new(), }) } diff --git a/crates/freshell-platform/src/mcp_inject_tests.rs b/crates/freshell-platform/src/mcp_inject_tests.rs index 6c3780d67..f94098364 100644 --- a/crates/freshell-platform/src/mcp_inject_tests.rs +++ b/crates/freshell-platform/src/mcp_inject_tests.rs @@ -60,7 +60,7 @@ fn mcp_unix_args() -> Vec { vec![ McpServerArg::Literal("--import".to_string()), McpServerArg::Path("/repo/node_modules/tsx/dist/loader.mjs".to_string()), - McpServerArg::Path("/repo/server/mcp/server.ts".to_string()), + McpServerArg::Path("/repo/tools/freshell-mcp/server.ts".to_string()), ] } @@ -72,6 +72,94 @@ fn fake_rt(tmp: &Path, wsl: bool) -> FakeRt { } } +#[cfg(unix)] +fn conversion_script(scratch: &Scratch, name: &str, contents: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = scratch.path().join(name); + std::fs::write(&path, contents).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + path +} + +#[cfg(unix)] +#[test] +fn live_wslpath_conversion_falls_back_to_the_host_path_on_failures() { + let scratch = Scratch::new("live-conversion-errors"); + let missing = scratch.path().join("missing-wslpath"); + assert_eq!( + convert_to_windows_path_with_command(missing.to_string_lossy().as_ref(), "/repo/file"), + "/repo/file" + ); + + let nonzero = conversion_script(&scratch, "nonzero", "#!/bin/sh\nexit 9\n"); + assert_eq!( + convert_to_windows_path_with_command(nonzero.to_string_lossy().as_ref(), "/repo/file"), + "/repo/file" + ); + + let empty = conversion_script(&scratch, "empty", "#!/bin/sh\nexit 0\n"); + assert_eq!( + convert_to_windows_path_with_command(empty.to_string_lossy().as_ref(), "/repo/file"), + "/repo/file" + ); +} + +#[cfg(unix)] +#[test] +fn live_wslpath_timeout_falls_back_and_reaps_the_child() { + let scratch = Scratch::new("live-conversion-timeout"); + let pid_file = scratch.path().join("timeout.pid"); + let timeout = conversion_script( + &scratch, + "timeout", + &format!( + "#!/bin/sh\necho $$ > '{}'\nexec sleep 30\n", + pid_file.display() + ), + ); + + let started = std::time::Instant::now(); + assert_eq!( + convert_to_windows_path_with_command(timeout.to_string_lossy().as_ref(), "/repo/file"), + "/repo/file" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "timed-out conversion should return promptly" + ); + + let pid = std::fs::read_to_string(&pid_file).expect("timeout script wrote its pid"); + let status = std::process::Command::new("kill") + .arg("-0") + .arg(pid.trim()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("kill -0 status"); + assert!(!status.success(), "timed-out wslpath child must be reaped"); +} + +#[cfg(unix)] +#[test] +fn live_wslpath_timeout_does_not_wait_for_a_grandchild_pipe_holder() { + let scratch = Scratch::new("grandchild-pipe-holder"); + let holder = conversion_script( + &scratch, + "grandchild-holder", + "#!/bin/sh\n(sleep 5) &\nwhile :; do sleep 1; done\n", + ); + + let started = std::time::Instant::now(); + assert_eq!( + convert_to_windows_path_with_command(holder.to_string_lossy().as_ref(), "/repo/file"), + "/repo/file" + ); + assert!( + started.elapsed() < std::time::Duration::from_millis(4_500), + "timed-out conversion must not join a reader blocked by a grandchild" + ); +} + #[test] fn claude_writes_tmp_json_0600_pretty_two_space() { let scratch = Scratch::new("claude"); @@ -87,7 +175,7 @@ fn claude_writes_tmp_json_0600_pretty_two_space() { ); assert!(inj.env.is_empty()); let written = std::fs::read_to_string(&expected_path).unwrap(); - let expected_json = "{\n \"mcpServers\": {\n \"freshell\": {\n \"command\": \"node\",\n \"args\": [\n \"--import\",\n \"/repo/node_modules/tsx/dist/loader.mjs\",\n \"/repo/server/mcp/server.ts\"\n ]\n }\n }\n}"; + let expected_json = "{\n \"mcpServers\": {\n \"freshell\": {\n \"command\": \"node\",\n \"args\": [\n \"--import\",\n \"/repo/node_modules/tsx/dist/loader.mjs\",\n \"/repo/tools/freshell-mcp/server.ts\"\n ]\n }\n }\n}"; assert_eq!(written, expected_json); #[cfg(unix)] { @@ -145,7 +233,7 @@ fn g_x4_codex_windows_target_on_wsl_unc_toml() { assert_eq!(inj.args[2], "-c"); assert_eq!( inj.args[3], - "mcp_servers.freshell.args=[\"--import\", \"\\\\\\\\wsl.localhost\\\\Ubuntu\\\\repo\\\\node_modules\\\\tsx\\\\dist\\\\loader.mjs\", \"\\\\\\\\wsl.localhost\\\\Ubuntu\\\\repo\\\\server\\\\mcp\\\\server.ts\"]" + "mcp_servers.freshell.args=[\"--import\", \"\\\\\\\\wsl.localhost\\\\Ubuntu\\\\repo\\\\node_modules\\\\tsx\\\\dist\\\\loader.mjs\", \"\\\\\\\\wsl.localhost\\\\Ubuntu\\\\repo\\\\tools\\\\freshell-mcp\\\\server.ts\"]" ); } @@ -157,7 +245,7 @@ fn codex_unix_target_on_wsl_keeps_host_paths() { let inj = generate_mcp_injection(&rt, "codex", "term1", None, ProviderTarget::Unix).unwrap(); assert_eq!( inj.args[3], - "mcp_servers.freshell.args=[\"--import\", \"/repo/node_modules/tsx/dist/loader.mjs\", \"/repo/server/mcp/server.ts\"]" + "mcp_servers.freshell.args=[\"--import\", \"/repo/node_modules/tsx/dist/loader.mjs\", \"/repo/tools/freshell-mcp/server.ts\"]" ); } @@ -258,7 +346,7 @@ fn opencode_merge_refcount_and_cleanup_lifecycle() { "node", "--import", "/repo/node_modules/tsx/dist/loader.mjs", - "/repo/server/mcp/server.ts" + "/repo/tools/freshell-mcp/server.ts" ]) ); let sidecar = read_sidecar(&cwd).unwrap(); diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index 4d9e61bc0..c2b55c448 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -1,4 +1,4 @@ -//! Client → server messages (`ClientMessage`, 31 discriminants). +//! Client → server messages (`ClientMessage`, 28 discriminants). //! //! These are the Zod-validated inbound surface. Deserialization is //! accept-and-strip (no `deny_unknown_fields`), mirroring the runtime. @@ -8,8 +8,8 @@ use serde_json::Value; use std::collections::BTreeMap; use crate::common::{ - double_option, AgentProvider, CodexDurability, PermissionMode, Sandbox, SessionLocator, - SessionType, Shell, StringOrNumber, TerminalAttachIntent, TerminalAttachPriority, + double_option, AgentProvider, CodexDurability, Sandbox, SessionLocator, SessionType, Shell, + StringOrNumber, TerminalAttachIntent, TerminalAttachPriority, }; /// A message sent from a client to the server. @@ -55,12 +55,6 @@ pub enum ClientMessage { UiLayoutSync(UiLayoutSync), #[serde(rename = "ui.screenshot.result")] UiScreenshotResult(UiScreenshotResult), - #[serde(rename = "codingcli.create")] - CodingCliCreate(CodingCliCreate), - #[serde(rename = "codingcli.input")] - CodingCliInput(CodingCliInput), - #[serde(rename = "codingcli.kill")] - CodingCliKill(CodingCliKill), #[serde(rename = "freshAgent.create")] FreshAgentCreate(FreshAgentCreate), #[serde(rename = "freshAgent.attach")] @@ -85,14 +79,11 @@ pub enum ClientMessage { /// The exact `type` discriminants of every client→server message, in the frozen /// inventory's order. This is the T0 conformance checklist. -pub const CLIENT_MESSAGE_TYPES: [&str; 31] = [ +pub const CLIENT_MESSAGE_TYPES: [&str; 28] = [ "amplifier.activity.list", "claude.activity.list", "client.diagnostic", "codex.activity.list", - "codingcli.create", - "codingcli.input", - "codingcli.kill", "freshAgent.approval.respond", "freshAgent.attach", "freshAgent.compact", @@ -452,51 +443,6 @@ pub struct PaneReconcileRequest { pub panes: Vec, } -// --- codingcli.* ------------------------------------------------------------ - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodingCliCreate { - pub prompt: String, - /// Free-form provider string (`CodingCliProvider`). - pub provider: String, - pub request_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_turns: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub permission_mode: Option, - /// Retained solely so the handler can detect-and-reject; see kata ejh6. - #[serde(skip_serializing_if = "Option::is_none")] - pub resume_session_id: Option, - /// Canonical identity carrier (kata ejh6). Parity with the TS - /// `CodingCliCreateSchema.sessionRef`. The spec - /// (`port/machine/specs/cli-argv-fidelity.md` section 3.3/U7) governs - /// `TerminalCreate.resume_session_id` (the spawn-time id) and is silent - /// on `CodingCliCreate`; adding the canonical carrier here preserves the - /// shared-contract invariant without violating the spec. - #[serde(skip_serializing_if = "Option::is_none")] - pub session_ref: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sandbox: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodingCliInput { - pub data: String, - pub session_id: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodingCliKill { - pub session_id: String, -} - // --- freshAgent.* ----------------------------------------------------------- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -537,7 +483,7 @@ pub struct FreshAgentCreate { with = "double_option" )] pub model_selection: Option>, - /// Free string here (unlike `codingcli.create`, which uses the enum). + /// Free string here because fresh-agent provider names are extension-defined. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/freshell-protocol/src/common.rs b/crates/freshell-protocol/src/common.rs index 0fbca2b19..f22938a25 100644 --- a/crates/freshell-protocol/src/common.rs +++ b/crates/freshell-protocol/src/common.rs @@ -139,7 +139,7 @@ pub enum SessionType { Freshopencode, } -/// Sandbox policy shared by codingcli/freshAgent create/send. +/// Sandbox policy shared by terminal and fresh-agent launch requests. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Sandbox { @@ -148,7 +148,7 @@ pub enum Sandbox { DangerFullAccess, } -/// Permission mode enum (used by `codingcli.create`; freshAgent uses a free +/// Permission mode enum (used by terminal and fresh-agent launch requests; freshAgent uses a free /// string here, so it is *not* this type there). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/freshell-protocol/src/lib.rs b/crates/freshell-protocol/src/lib.rs index 62a338d97..10d40dff8 100644 --- a/crates/freshell-protocol/src/lib.rs +++ b/crates/freshell-protocol/src/lib.rs @@ -37,7 +37,7 @@ pub use settings::*; pub const WS_PROTOCOL_VERSION: u32 = 7; /// Every `type` discriminant the protocol speaks, both directions, sorted. -/// (31 client→server + 58 server→client = 89.) +/// (28 client→server + 53 server→client = 81.) pub fn all_message_types() -> Vec<&'static str> { let mut types: Vec<&'static str> = client_messages::CLIENT_MESSAGE_TYPES .iter() diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index cfb49b348..f7ce97a18 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -1,4 +1,4 @@ -//! Server → client messages (`ServerMessage`, 58 discriminants). +//! Server → client messages (`ServerMessage`, 53 discriminants). //! //! These are TypeScript-typed (not runtime-validated) on the wire; their frozen //! shape authority is `port/contract/ws-server-messages.schema.json`. @@ -32,16 +32,6 @@ pub enum ServerMessage { CodexActivityListResponse(CodexActivityListResponse), #[serde(rename = "codex.activity.updated")] CodexActivityUpdated(CodexActivityUpdated), - #[serde(rename = "codingcli.created")] - CodingCliCreated(CodingCliCreated), - #[serde(rename = "codingcli.event")] - CodingCliEvent(CodingCliEvent), - #[serde(rename = "codingcli.exit")] - CodingCliExit(CodingCliExit), - #[serde(rename = "codingcli.killed")] - CodingCliKilled(CodingCliKilled), - #[serde(rename = "codingcli.stderr")] - CodingCliStderr(CodingCliStderr), #[serde(rename = "config.fallback")] ConfigFallback(ConfigFallback), // Extension surface (P1.8 pane-identity ledger, not in the frozen T0 @@ -148,18 +138,13 @@ pub enum ServerMessage { /// The exact `type` discriminants of every server→client message, in the frozen /// inventory's order. This is the T0 conformance checklist. -pub const SERVER_MESSAGE_TYPES: [&str; 58] = [ +pub const SERVER_MESSAGE_TYPES: [&str; 53] = [ "amplifier.activity.list.response", "amplifier.activity.updated", "claude.activity.list.response", "claude.activity.updated", "codex.activity.list.response", "codex.activity.updated", - "codingcli.created", - "codingcli.event", - "codingcli.exit", - "codingcli.killed", - "codingcli.stderr", "config.fallback", "error", "extension.server.error", @@ -465,48 +450,6 @@ pub struct OpencodeActivityUpdated { pub upsert: Vec, } -// --- codingcli.* ------------------------------------------------------------ - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodingCliCreated { - pub provider: String, - pub request_id: String, - pub session_id: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodingCliEvent { - /// Provider-specific payload (opaque). - pub event: Value, - pub provider: String, - pub session_id: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodingCliExit { - pub exit_code: i64, - pub provider: String, - pub session_id: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodingCliKilled { - pub session_id: String, - pub success: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodingCliStderr { - pub provider: String, - pub session_id: String, - pub text: String, -} - // --- config / error --------------------------------------------------------- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/freshell-protocol/tests/inventory.rs b/crates/freshell-protocol/tests/inventory.rs index 37b4bab83..602e147c1 100644 --- a/crates/freshell-protocol/tests/inventory.rs +++ b/crates/freshell-protocol/tests/inventory.rs @@ -31,12 +31,12 @@ fn client_types_match_inventory_exactly() { let inv = inventory(); assert_eq!( inv["clientToServer"]["count"].as_u64(), - Some(31), - "inventory declares 31 client→server types" + Some(28), + "inventory declares 28 client→server types" ); let expected = json_type_set(&inv["clientToServer"]["types"]); let actual: BTreeSet = CLIENT_MESSAGE_TYPES.iter().map(|s| s.to_string()).collect(); - assert_eq!(actual.len(), 31, "crate declares 31 client types (no dups)"); + assert_eq!(actual.len(), 28, "crate declares 28 client types (no dups)"); assert_eq!( actual, expected, "CLIENT_MESSAGE_TYPES must equal the frozen inventory (no missing/extra)" @@ -48,12 +48,12 @@ fn server_types_match_inventory_exactly() { let inv = inventory(); assert_eq!( inv["serverToClient"]["count"].as_u64(), - Some(58), - "inventory declares 58 server→client types" + Some(53), + "inventory declares 53 server→client types" ); let expected = json_type_set(&inv["serverToClient"]["types"]); let actual: BTreeSet = SERVER_MESSAGE_TYPES.iter().map(|s| s.to_string()).collect(); - assert_eq!(actual.len(), 58, "crate declares 58 server types (no dups)"); + assert_eq!(actual.len(), 53, "crate declares 53 server types (no dups)"); assert_eq!( actual, expected, "SERVER_MESSAGE_TYPES must equal the frozen inventory (no missing/extra)" @@ -61,14 +61,14 @@ fn server_types_match_inventory_exactly() { } #[test] -fn combined_surface_is_89() { +fn combined_surface_is_81() { let all = all_message_types(); - assert_eq!(all.len(), 89, "31 client + 58 server = 89 discriminants"); + assert_eq!(all.len(), 81, "28 client + 53 server = 81 discriminants"); // sorted + unique let unique: BTreeSet<&str> = all.iter().copied().collect(); assert_eq!( unique.len(), - 89, + 81, "no discriminant collides across directions" ); } diff --git a/crates/freshell-protocol/tests/roundtrip.rs b/crates/freshell-protocol/tests/roundtrip.rs index d700a672e..729eaa971 100644 --- a/crates/freshell-protocol/tests/roundtrip.rs +++ b/crates/freshell-protocol/tests/roundtrip.rs @@ -335,24 +335,6 @@ fn rich_client_messages() { other => panic!("expected TerminalAttach, got {other:?}"), } - // codingcli.create — sessionRef (canonical carrier, ejh6 Task 11) + resumeSessionId (retained for reject). - let wire = r#"{"type":"codingcli.create","prompt":"hi","provider":"claude","requestId":"r1","cwd":"/x","maxTurns":3,"model":"sonnet","permissionMode":"acceptEdits","sandbox":"workspace-write","resumeSessionId":"prev","sessionRef":{"provider":"claude","sessionId":"sess-canonical"}}"#; - match client_roundtrip(wire, "codingcli.create") { - ClientMessage::CodingCliCreate(c) => { - assert_eq!(c.permission_mode, Some(PermissionMode::AcceptEdits)); - assert_eq!(c.sandbox, Some(Sandbox::WorkspaceWrite)); - assert_eq!(c.resume_session_id, Some("prev".to_string())); - assert_eq!( - c.session_ref, - Some(SessionLocator { - provider: "claude".to_string(), - session_id: "sess-canonical".to_string() - }) - ); - } - other => panic!("expected CodingCliCreate, got {other:?}"), - } - // ping — unit variant. match client_roundtrip(r#"{"type":"ping"}"#, "ping") { ClientMessage::Ping => {} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index b385deb37..b8c5d742a 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -1872,10 +1872,8 @@ async fn main() -> ExitCode { rebind.shutdown_all().await; // SAFE-11/TERM-22: reap every owned child tree before exit. Legacy parity // (`server/index.ts:981-1049`'s `shutdown()`): after the HTTP/WS surface is - // drained, `joinCodexShutdownOwners` reaps `registry.shutdownGracefully()` - // (terminals) and the Codex/opencode sidecars together, then - // `codingCliSessionManager.shutdown()` covers any remaining coding-CLI - // session. This port's equivalents run in the same spot: + // drained, the terminal registry and provider runtimes are shut down + // together. This port's equivalents run in the same spot: // * `registry.kill_all()` — every tracked PTY terminal (`mode:'shell'` // and any other registry-tracked terminal, e.g. a plain `sleep 300` // shell) — the gap this fix closes; nothing previously killed these. diff --git a/crates/freshell-server/src/network.rs b/crates/freshell-server/src/network.rs index 2bba875db..753436061 100644 --- a/crates/freshell-server/src/network.rs +++ b/crates/freshell-server/src/network.rs @@ -4181,15 +4181,23 @@ mod tests { assert_eq!(probe.probe("127.0.0.1".to_string(), port).await, Some(true)); accept_task.abort(); - // Closed: pick a high port nothing is listening on and expect Some(false). - // (Bind-then-drop to get a genuinely free ephemeral port number, then - // probe it after the listener is gone — connection refused.) - let temp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let free_port = temp_listener.local_addr().unwrap().port(); - drop(temp_listener); - assert_eq!( - probe.probe("127.0.0.1".to_string(), free_port).await, - Some(false) + // Closed: bind-then-drop an ephemeral listener and probe the released + // port. Another parallel test can reclaim that port in the tiny gap + // between drop and connect, so retry a bounded number of candidates + // instead of treating that scheduling race as a probe failure. + let mut found_closed_port = false; + for _ in 0..8 { + let temp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let free_port = temp_listener.local_addr().unwrap().port(); + drop(temp_listener); + if probe.probe("127.0.0.1".to_string(), free_port).await == Some(false) { + found_closed_port = true; + break; + } + } + assert!( + found_closed_port, + "could not obtain a closed loopback port after bounded retries" ); } diff --git a/crates/freshell-server/src/session_directory.rs b/crates/freshell-server/src/session_directory.rs index 51289d2f4..05bc57703 100644 --- a/crates/freshell-server/src/session_directory.rs +++ b/crates/freshell-server/src/session_directory.rs @@ -203,6 +203,15 @@ impl DirItem { } if let Some(v) = &self.cwd { o.insert("cwd".into(), json!(v)); + // A linked worktree is grouped under its common repository but + // must retain its checkout for the client’s worktree-aware + // sidebar presentation. Ordinary checkouts omit this redundant + // field when checkout and project paths are identical. + if let Some(checkout_path) = freshell_platform::git_meta::resolve_git_checkout_root(v) + .filter(|checkout_path| checkout_path != &self.project_path) + { + o.insert("checkoutPath".into(), json!(checkout_path)); + } } if self.is_subagent { o.insert("isSubagent".into(), json!(true)); @@ -1281,6 +1290,48 @@ mod join_tests { use super::*; use freshell_ws::identity::TerminalIdentityRegistry; + struct LinkedWorktreeFixture { + root: std::path::PathBuf, + project: std::path::PathBuf, + checkout: std::path::PathBuf, + gitdir: std::path::PathBuf, + } + + impl LinkedWorktreeFixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "freshell-session-directory-worktree-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let project = root.join("project"); + let checkout = root.join("checkouts/feature"); + let gitdir = root.join("administrative/.git/worktrees/feature"); + std::fs::create_dir_all(project.join(".git")).unwrap(); + std::fs::create_dir_all(&checkout).unwrap(); + std::fs::create_dir_all(&gitdir).unwrap(); + std::fs::write( + checkout.join(".git"), + format!("gitdir: {}\n", gitdir.display()), + ) + .unwrap(); + std::fs::write(gitdir.join("commondir"), "../../../../project/.git\n").unwrap(); + + Self { + root, + project, + checkout, + gitdir, + } + } + } + + impl Drop for LinkedWorktreeFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + fn file_item(provider: &str, session_id: &str, last_activity_at: i64) -> DirItem { DirItem { session_id: session_id.to_string(), @@ -1308,6 +1359,35 @@ mod join_tests { } } + #[test] + fn linked_worktree_payload_keeps_checkout_path_separate_from_project_path() { + let fixture = LinkedWorktreeFixture::new(); + assert_eq!( + std::fs::read_to_string(fixture.checkout.join(".git")).unwrap(), + format!("gitdir: {}\n", fixture.gitdir.display()) + ); + assert_eq!( + std::fs::read_to_string(fixture.gitdir.join("commondir")).unwrap(), + "../../../../project/.git\n" + ); + let checkout_path = fixture.checkout.to_string_lossy().into_owned(); + let project_path = fixture.project.to_string_lossy().into_owned(); + assert_eq!( + freshell_platform::git_meta::resolve_git_repo_root(&checkout_path).as_deref(), + Some(project_path.as_str()), + "the commondir fixture must select the common repository, not the administrative fallback" + ); + + let mut item = file_item("claude", "session-1", 1); + item.project_path = project_path.clone(); + item.cwd = Some(checkout_path.clone()); + let payload = item.to_value(); + + assert_eq!(payload["projectPath"], serde_json::json!(project_path)); + assert_eq!(payload["checkoutPath"], serde_json::json!(checkout_path)); + assert_eq!(payload["cwd"], serde_json::json!(checkout_path)); + } + // ── provider_display_name ── #[test] diff --git a/crates/freshell-sessions/Cargo.toml b/crates/freshell-sessions/Cargo.toml index f9408a911..1d97f56e1 100644 --- a/crates/freshell-sessions/Cargo.toml +++ b/crates/freshell-sessions/Cargo.toml @@ -42,6 +42,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time" # correlation refusal (`amplifier-session-locator.ts:727-733`'s `log.warn` # equivalent) -- same version already used by `freshell-ws`. tracing = "0.1" +# Session-directory project grouping follows the shared git-root resolution +# used by terminal metadata, so linked worktree sessions group under their +# parent repository while preserving the checkout as `cwd`. +freshell-platform = { path = "../freshell-platform" } [dev-dependencies] # The opencode SQLite parity test builds fixture databases with a writable connection diff --git a/crates/freshell-sessions/src/directory_index.rs b/crates/freshell-sessions/src/directory_index.rs index 81b590002..715104dc5 100644 --- a/crates/freshell-sessions/src/directory_index.rs +++ b/crates/freshell-sessions/src/directory_index.rs @@ -469,11 +469,20 @@ fn item_from_meta( source_file: Option, legacy_session_id: Option, ) -> IndexedSession { + // Session transcripts record the checkout as `cwd`. For a linked git + // worktree, the sidebar's project grouping follows the common repository + // root, while `cwd` remains the checkout for resume and display details. + // This is the same resolver used by terminal metadata. + let project_path = meta + .cwd + .as_deref() + .and_then(freshell_platform::git_meta::resolve_git_repo_root) + .unwrap_or_else(|| meta.cwd.clone().unwrap_or_else(|| "unknown".to_string())); IndexedSession { session_id, legacy_session_id, provider: provider.to_string(), - project_path: meta.cwd.clone().unwrap_or_else(|| "unknown".to_string()), + project_path, title: meta.title.clone(), title_provider_generated: meta.title_provider_generated, summary: meta.summary.clone(), @@ -2343,6 +2352,83 @@ pub(crate) mod tests { use std::sync::Arc; use std::time::Duration; + struct LinkedWorktreeFixture { + root: PathBuf, + project: PathBuf, + checkout: PathBuf, + gitdir: PathBuf, + } + + impl LinkedWorktreeFixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "freshell-directory-index-worktree-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let project = root.join("project"); + let checkout = root.join("checkouts/feature"); + let gitdir = root.join("administrative/.git/worktrees/feature"); + std::fs::create_dir_all(project.join(".git")).unwrap(); + std::fs::create_dir_all(&checkout).unwrap(); + std::fs::create_dir_all(&gitdir).unwrap(); + std::fs::write( + checkout.join(".git"), + format!("gitdir: {}\n", gitdir.display()), + ) + .unwrap(); + std::fs::write(gitdir.join("commondir"), "../../../../project/.git\n").unwrap(); + + Self { + root, + project, + checkout, + gitdir, + } + } + } + + impl Drop for LinkedWorktreeFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + #[test] + fn linked_worktree_session_groups_under_parent_repo_and_keeps_checkout_cwd() { + let fixture = LinkedWorktreeFixture::new(); + assert_eq!( + std::fs::read_to_string(fixture.checkout.join(".git")).unwrap(), + format!("gitdir: {}\n", fixture.gitdir.display()) + ); + assert_eq!( + std::fs::read_to_string(fixture.gitdir.join("commondir")).unwrap(), + "../../../../project/.git\n" + ); + let checkout_path = fixture.checkout.to_string_lossy().into_owned(); + let project_path = fixture.project.to_string_lossy().into_owned(); + assert_eq!( + freshell_platform::git_meta::resolve_git_repo_root(&checkout_path).as_deref(), + Some(project_path.as_str()), + "the commondir fixture must select the common repository, not the administrative fallback" + ); + + let indexed = item_from_meta( + &ParsedSessionMeta { + cwd: Some(checkout_path.clone()), + ..Default::default() + }, + "claude", + "session-1".to_string(), + false, + None, + None, + ); + + assert_eq!(indexed.project_path, project_path); + assert_eq!(indexed.cwd.as_deref(), Some(checkout_path.as_str())); + } + /// Poll `predicate` every 10ms until it's true or `timeout` elapses. /// Returns whether it became true -- used to observe a detached /// background refresh (stale-while-revalidate) settling, since that diff --git a/crates/freshell-tauri/tests/server_spawn_smoke.rs b/crates/freshell-tauri/tests/server_spawn_smoke.rs index 7c5a585b7..44b73d399 100644 --- a/crates/freshell-tauri/tests/server_spawn_smoke.rs +++ b/crates/freshell-tauri/tests/server_spawn_smoke.rs @@ -5,11 +5,9 @@ //! (the GUI launch itself is display-gated, covered separately by the xvfb smoke). //! //! The server binary is discovered via `FRESHELL_SERVER_BIN`, else as a sibling of -//! the test executable (`target//freshell-server`, where a workspace -//! `cargo test` also builds/leaves the server bin). If it cannot be found, the test -//! SOFT-SKIPS with a printed notice rather than failing — so `cargo test -p -//! freshell-tauri` is green whether or not the sibling binary happens to be built, -//! while a workspace `cargo test` (which builds it) exercises the real path. +//! the test executable (`target//freshell-server`). The test fails when +//! neither location contains the explicit Rust binary: callers must build the +//! artifact first so this smoke can never pass without exercising the real path. use std::path::PathBuf; use std::time::Duration; @@ -17,7 +15,7 @@ use std::time::Duration; use freshell_tauri::health::{self, HealthProbe}; use freshell_tauri::server::{self, ReapOutcome, SpawnConfig}; -/// Find the `freshell-server` binary to drive, or `None` to soft-skip. +/// Find the `freshell-server` binary to drive, if the caller has built it. fn discover_server_binary() -> Option { if let Some(explicit) = std::env::var_os("FRESHELL_SERVER_BIN") { let p = PathBuf::from(explicit); @@ -38,13 +36,8 @@ fn discover_server_binary() -> Option { #[test] fn app_bound_spawn_health_reap_end_to_end() { - let Some(server_binary) = discover_server_binary() else { - eprintln!( - "SKIP app_bound_spawn_health_reap_end_to_end: freshell-server binary not found \ - (set FRESHELL_SERVER_BIN or run a workspace `cargo build`/`cargo test`)." - ); - return; - }; + let server_binary = discover_server_binary() + .expect("freshell-server binary not found; build it first or set FRESHELL_SERVER_BIN"); eprintln!("using server binary: {}", server_binary.display()); // Isolated HOME so the smoke never reads/writes the real ~/.freshell. diff --git a/crates/freshell-terminal/tests/batch_wire_golden.rs b/crates/freshell-terminal/tests/batch_wire_golden.rs index bbb01e420..37304f976 100644 --- a/crates/freshell-terminal/tests/batch_wire_golden.rs +++ b/crates/freshell-terminal/tests/batch_wire_golden.rs @@ -1,25 +1,21 @@ //! **Batch-framing fidelity test** — the acceptance gate for the deferred 3.3b work //! (`terminal.output.batch`). //! -//! The live-wire batch SEGMENT structure is chunk-nondeterministic (node-pty read -//! boundaries + flush timing vary the frame set boot-to-boot — proven empirically), so -//! the byte-exact original-vs-rust proof cannot be a live capture. Instead it is done -//! HERE, over FIXED frame sequences, against goldens generated from the ORIGINAL's own -//! source-of-truth logic (`port/oracle/baselines/batch/generate-batch-goldens.ts` -//! imports `createTerminalOutputBarrierScanner` + `buildTerminalOutputBatches` + -//! `measureTerminalOutputPayloadBytes`). +//! The live-wire batch segment structure is chunk-nondeterministic (PTY read +//! boundaries + flush timing vary the frame set boot-to-boot), so the byte-exact +//! proof is done HERE, over fixed frame sequences, against frozen migration goldens. //! //! For every committed scenario this test: //! 1. verifies the golden file's own sha256 (committed-golden integrity); //! 2. reconstructs the scenario's fragments, classifies them with the Rust -//! [`BarrierScanner`], builds batches + the wire projection with the SAME ids and -//! budgets the generator used; +//! [`BarrierScanner`], builds batches + the wire projection with the same ids and +//! budgets captured in the frozen fixture; //! 3. asserts the Rust wire payloads are **byte-identical** (canonical sorted-key //! JSON) to the golden payloads — every `endOffset` (UTF-16 code units), //! `rawFrameCount`, `barrier` reason, `data`, and `serializedBytes`. //! -//! A mismatch is a REAL fidelity failure (prints the first differing payload); it never -//! rewrites the golden. This is the deterministic ORIGINAL≡RUST batch-framing proof. +//! A mismatch is a real fidelity failure (prints the first differing payload); it never +//! rewrites the golden. This is the deterministic Rust batch-framing proof. use std::path::PathBuf; @@ -45,8 +41,8 @@ fn sha256_hex(bytes: &[u8]) -> String { h.finalize().iter().map(|b| format!("{b:02x}")).collect() } -/// Recursively sort object keys → a stable canonical string form (matches the -/// generator's `sortKeys` + `JSON.stringify`), so the comparison is byte-exact and +/// Recursively sort object keys → a stable canonical string form matching the +/// fixture's canonical JSON, so the comparison is byte-exact and /// order-independent regardless of serde_json's `preserve_order`. fn canonical(value: &Value) -> String { fn sort(v: &Value) -> Value { @@ -67,8 +63,8 @@ fn canonical(value: &Value) -> String { serde_json::to_string(&sort(value)).expect("serialize canonical json") } -/// The generator's `classifyFrames` (`replay-ring.ts:62-79`): run each fragment through -/// one persistent scanner, seqs 1..N, one frame per fragment. +/// Reconstruct each frozen fixture's fragments through one persistent scanner, +/// with seqs 1..N and one frame per fragment. fn classify(fragments: &[String], stream_id: &str) -> Vec { let mut scanner = BarrierScanner::new(); fragments @@ -128,7 +124,7 @@ fn reproduce(golden: &Value) -> Vec { out } -/// Every committed batch golden (kept in lockstep with the generator's SCENARIOS). +/// Every committed batch golden retained as frozen migration provenance. const SCENARIOS: &[&str] = &[ "single-ground", "multi-merge", @@ -179,7 +175,7 @@ fn rust_batch_framing_reproduces_every_committed_golden_byte_for_byte() { let ce = canonical(e); assert_eq!( ca, ce, - "[{name}] payload[{i}] diverged from the ORIGINAL-derived golden.\n rust : {ca}\n golden: {ce}" + "[{name}] payload[{i}] diverged from the frozen golden.\n rust : {ca}\n golden: {ce}" ); } checked += 1; diff --git a/crates/freshell-ws/src/reconcile.rs b/crates/freshell-ws/src/reconcile.rs index 4f8940dd8..1365ad43f 100644 --- a/crates/freshell-ws/src/reconcile.rs +++ b/crates/freshell-ws/src/reconcile.rs @@ -166,7 +166,7 @@ fn resolve_authoritative_ref( /// ingress where a wire `resumeSessionId` remains honored — old persisted /// pane content can carry a legacy-only claim INDEFINITELY, so this /// promotion stays forever with NO later-removal plan. Every create-class -/// door (WS `terminal.create` / `codingcli.create` / `freshAgent.*`, REST +/// door (WS `terminal.create` / `freshAgent.*`, REST /// `/api/tabs`·split·respawn) rejects the field outright with the frozen /// refusal text; this one alone promotes it. fn promoted_legacy_claim(pane: &ReconcilePane) -> Option { diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index b0b054afe..090a6b0f1 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -661,12 +661,12 @@ async fn handle_client_text( // (silent drop, no terminal, no error). Presence must be read here, at // the raw layer, before dedupe/restore planning (zero side effects on // reject). The two INVALID_MESSAGE families (terminal.create, - // codingcli.create) were armed in Task 6; Task 7 armed the freshAgent + // terminal.create was armed in Task 6; Task 7 armed the freshAgent // families (create.failed envelope / attach event channel). if let Some(resume_value) = value.get("resumeSessionId") { let _ = resume_value; // presence is what matters; the value is never read match value.get("type").and_then(|t| t.as_str()) { - Some("terminal.create") | Some("codingcli.create") => { + Some("terminal.create") => { let reply = ServerMessage::Error(ErrorMsg { code: ErrorCode::InvalidMessage, message: LEGACY_RESUME_IDENTITY_REFUSAL.to_string(), @@ -1354,9 +1354,7 @@ async fn handle_client_text( // (`src/store/layoutMirrorMiddleware.ts`) feeds the shared // Deliberately inert remainder -- every arm here is unreachable from the // frozen client's live surface: `hello` was already consumed by the - // pre-loop handshake (`evaluate_hello`); `codingcli.*` has - // no runtime here and the frozen client never sends it (zero senders in - // `src/`). The user-reachable fresh-agent control frames + // pre-loop handshake (`evaluate_hello`). The user-reachable fresh-agent control frames // (approval.respond / question.respond / fork / compact) are refused or // dispatched BEFORE/INSIDE this match by `fresh_agent_control_refusal` + the // claude arms above -- they must never fall through to this silent arm again. diff --git a/crates/freshell-ws/tests/live_session_ref_guard.rs b/crates/freshell-ws/tests/live_session_ref_guard.rs index 909d18dab..70eb0bda8 100644 --- a/crates/freshell-ws/tests/live_session_ref_guard.rs +++ b/crates/freshell-ws/tests/live_session_ref_guard.rs @@ -496,28 +496,3 @@ async fn legacy_reject_ws_restore_codex_legacy() { "no terminal may spawn" ); } - -/// ejh6: a `codingcli.create` carrying the legacy field hits the raw-Value -/// guard with `INVALID_MESSAGE` + frozen text. Rust has no codingcli handler -/// (the `_ => true` arm of the dispatch) — without the guard this silently -/// no-ops; the guard is the loud rejector. -#[tokio::test] -async fn legacy_reject_ws_codingcli_create() { - let (url, _registry) = spawn_server().await; - let (mut ws, _inv) = connect_and_capture_inventory(&url).await; - send_create( - &mut ws, - json!({ - "type": "codingcli.create", "requestId": "req-codingcli-legacy", - "prompt": "hi", "provider": "claude", - "resumeSessionId": "legacy-codingcli", - }), - ) - .await; - let err = expect_refusal_for(&mut ws, "req-codingcli-legacy").await; - assert_eq!(err["code"], json!("INVALID_MESSAGE"), "{err}"); - assert_eq!( - err["message"], - json!("Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity."), - ); -} diff --git a/docker/cloud-run/Dockerfile b/docker/cloud-run/Dockerfile index 3c6461c99..f0660406d 100644 --- a/docker/cloud-run/Dockerfile +++ b/docker/cloud-run/Dockerfile @@ -1,17 +1,15 @@ -# freshell-e2e — Cloud Run Jobs image for Playwright e2e tests. +# freshell-e2e — Cloud Run Jobs image for Playwright browser tests. # # Multi-stage build: -# 1. rust-builder: compiles the freshell-server release binary -# 2. node-builder: installs npm deps (native modules) + builds dist/client + dist/server -# 3. runtime: Node.js + Playwright chromium + pre-built artifacts (no build tools) +# 1. rust-builder: compiles the native freshell-server release binary +# 2. node-builder: installs JavaScript tooling and builds client/tools +# 3. runtime: Node + Playwright + Rust/client/tools artifacts # -# The image is self-contained: no build steps run at test time (except the -# MCP bridge's incremental tsc rebuild, which is a no-op on unchanged source). -# The entrypoint translates CLOUD_RUN_TASK_INDEX/CLOUD_RUN_TASK_COUNT into -# Playwright --shard flags and forwards pass-through args. +# Node remains in this image for Playwright and the retained JavaScript tooling. +# The application backend is always the Rust executable copied from stage one. # --------------------------------------------------------------------------- -# Stage 1: Build the Rust server binary +# Stage 1: build the Rust server. # --------------------------------------------------------------------------- FROM rust:1-bookworm AS rust-builder @@ -30,116 +28,81 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* WORKDIR /build - -# Copy Cargo manifests first for layer caching (dependencies build only when -# Cargo.toml/Cargo.lock change). Also copy extensions/ — some crates use -# include_str! to embed extension files at compile time. COPY Cargo.toml Cargo.lock ./ COPY crates/ ./crates/ COPY extensions/ ./extensions/ - -# Build only the server binary (release profile). -RUN cargo build --release -p freshell-server +RUN cargo build --release -p freshell-server --locked # --------------------------------------------------------------------------- -# Stage 2: Build Node.js dependencies and dist artifacts +# Stage 2: build retained Node tooling and static artifacts. # --------------------------------------------------------------------------- FROM node:22-bookworm AS node-builder ENV DEBIAN_FRONTEND=noninteractive \ NONINTERACTIVE=1 -# Build-time dependencies for native modules (node-pty): -# - build-essential + python3: needed for node-gyp (node-pty native module) -# - pkg-config + libssl-dev: needed by native modules that link against OpenSSL -# These are NOT needed in the runtime stage — only for compilation. -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - python3 \ - pkg-config \ - libssl-dev \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /app - -# Copy package manifests first for npm ci layer caching. COPY package.json package-lock.json ./ -# Install all dependencies (including devDependencies for Playwright + tsc). -RUN npm ci +# Lifecycle scripts are disabled here; the final image needs only retained JS +# tooling and static artifacts around the Rust backend. +RUN npm ci --ignore-scripts -# Copy all source code (.dockerignore excludes node_modules, dist, target, -# .git, .worktrees, docs, etc.). COPY . . - -# Build the client and server. -RUN npm run build:client && npm run build:server +RUN npm run build:client && npm run build:tools # --------------------------------------------------------------------------- -# Stage 3: Runtime image with Node, Playwright, and pre-built artifacts +# Stage 3: test runtime with no compiler toolchain. # --------------------------------------------------------------------------- FROM node:22-bookworm ENV DEBIAN_FRONTEND=noninteractive \ - NONINTERACTIVE=1 + NONINTERACTIVE=1 \ + PLAYWRIGHT_BROWSERS_PATH=/ms-playwright LABEL org.opencontainers.image.title="freshell-e2e" \ - org.opencontainers.image.description="Cloud Run Jobs image for Playwright e2e tests" \ + org.opencontainers.image.description="Cloud Run Jobs image for Playwright browser tests" \ org.opencontainers.image.source="https://github.com/danshapiro/freshell" \ org.opencontainers.image.licenses="MIT" -# Runtime system dependencies: -# - jq: needed by scripts/deploy-tab-diff.sh (called by deploy-tab-diff-rust.spec.ts) -# - ca-certificates: TLS cert bundle (already in base, explicit for clarity) -# Note: bash, curl, git, procps, ssh, and wget are all already in node:22-bookworm. -# procps provides `ps --ppid` used by the RustServer fixture (helpers/rust-server.ts). -# The Rust server uses rustls (not OpenSSL), so libssl-dev/pkg-config are NOT -# needed at runtime — only in the build stages above. RUN apt-get update && apt-get install -y --no-install-recommends \ jq \ ca-certificates \ && rm -rf /var/lib/apt/lists/* -# Install Playwright chromium browser and its system dependencies. -# Must match the @playwright/test version in package.json (1.58.2). -# PLAYWRIGHT_BROWSERS_PATH places browsers in a shared system path so the -# node user can access them (default ~/.cache/ms-playwright would be root's home). -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +# Must match the @playwright/test version in package.json. RUN npx --yes playwright@1.58.2 install --with-deps chromium WORKDIR /app - -# Copy the pre-built Rust server binary. COPY --from=rust-builder /build/target/release/freshell-server /app/target/release/freshell-server - -# Copy pre-built node_modules and dist from the builder stage. -# This lets the runtime stage skip build-essential/python3 (native modules -# like node-pty are already compiled; tsc is a pure JS tool). +COPY --from=node-builder /app/dist/client ./dist/client +COPY --from=node-builder /app/dist/tools ./dist/tools COPY --from=node-builder /app/node_modules ./node_modules -COPY --from=node-builder /app/dist ./dist -# Copy all source code (.dockerignore excludes node_modules, dist, target, -# .git, .worktrees, docs, etc. — so the builder copies above are preserved). +# Preserve the source tree for local release checks. Build outputs and +# dependency directories are excluded by .dockerignore, so these copies cannot +# overwrite the staged artifacts. COPY . . -# Point the Rust server fixture at the pre-built binary (fail-closed override). +# Check the assembled runtime tree after all staged artifacts are present. +# With --runtime-root the guard checks the required Rust/client/tools files and +# executable, scans only shipped dist/, target/, and node_modules/ roots, +# rejects retired artifact directories and direct top-level retired backend +# packages, and permits nested transitive packages retained by tooling. +RUN scripts/verify-container-layout.sh --fixture /app --runtime-root + ENV FRESHELL_E2E_RUST_SERVER_BIN=/app/target/release/freshell-server -# Copy the entrypoint script (after COPY . . so it's not overwritten by -# a stale copy in the source tree). COPY docker/cloud-run/entrypoint.sh /usr/local/bin/e2e-entrypoint.sh RUN chmod +x /usr/local/bin/e2e-entrypoint.sh -# Switch to non-root user for runtime (node:22-bookworm provides UID 1000). -# This is required by server-side tests that verify permission errors -# propagate (claude-transcript-locator.test.ts chmod 0o000 tests) — root -# would bypass those mode bits. Must be AFTER the entrypoint COPY+chmod above -# which write to root-owned /usr/local/bin/. +# The browser test harness needs an unprivileged user for permission checks. RUN chown -R node:node /app USER node -# Basic healthcheck: verify the Node.js runtime and key artifacts are present. HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD node -v && test -x /app/target/release/freshell-server + CMD test -x /app/target/release/freshell-server \ + && test -f /app/dist/client/index.html \ + && test -f /app/dist/tools/freshell-mcp/server.js ENTRYPOINT ["/usr/local/bin/e2e-entrypoint.sh"] diff --git a/docker/cloud-run/entrypoint.sh b/docker/cloud-run/entrypoint.sh index 86b371a37..c7f4ca8bf 100755 --- a/docker/cloud-run/entrypoint.sh +++ b/docker/cloud-run/entrypoint.sh @@ -31,6 +31,13 @@ # Playwright. Pass as a container arg or in PLAYWRIGHT_ARGS. set -euo pipefail +log_json() { + local severity="$1" + local event="$2" + local message="$3" + printf '{"severity":"%s","event":"%s","message":"%s"}\n' "$severity" "$event" "$message" +} + # Cloud Run sets CLOUD_RUN_TASK_INDEX (0-based) and CLOUD_RUN_TASK_COUNT # when the job is configured with --tasks > 1. TASK_INDEX="${CLOUD_RUN_TASK_INDEX:-0}" @@ -40,7 +47,11 @@ TASK_COUNT="${CLOUD_RUN_TASK_COUNT:-1}" if [ "${TEST_MODE:-}" = "vitest" ]; then SHARD_INDEX=$((TASK_INDEX + 1)) SHARD_COUNT="$TASK_COUNT" - CONFIGS="${VITEST_CONFIGS:-config/vitest/vitest.config.ts config/vitest/vitest.server.config.ts}" + CONFIGS="${VITEST_CONFIGS:-config/vitest/vitest.config.ts}" + if [ "$CONFIGS" != "config/vitest/vitest.config.ts" ]; then + log_json error vitest_config_rejected "Only the retained default Vitest config is supported in this image." + exit 2 + fi # Parse VITEST_ARGS_JSON (JSON array) into a bash array using jq. # This preserves argument boundaries (spaces, metacharacters, etc.) @@ -60,7 +71,7 @@ if [ "${TEST_MODE:-}" = "vitest" ]; then EXIT_CODE=0 for config in $CONFIGS; do echo "[vitest-entrypoint] Running vitest: $config ${SHARD_ARG[*]-} ${EXTRA_ARGS[*]-}" - npx vitest run --passWithNoTests --config "$config" "${SHARD_ARG[@]}" "${EXTRA_ARGS[@]}" || EXIT_CODE=$? + npx vitest run --config "$config" "${SHARD_ARG[@]}" "${EXTRA_ARGS[@]}" || EXIT_CODE=$? done exit "$EXIT_CODE" fi @@ -123,28 +134,30 @@ SHARD=$((TASK_INDEX + 1)) echo "[e2e-entrypoint] Duration-aware shard ${SHARD}/${TASK_COUNT}" # 1. Discover spec files that will actually run (respects --project, grep, -# and positional spec-path filters). Falls back to globbing if --list fails. +# and positional spec-path filters). Discovery errors are fatal: silently +# broadening a selection would make a green job meaningless. echo "[e2e-entrypoint] Discovering spec files via --list..." -LIST_OUTPUT=$(npx playwright test --config "$CONFIG" --list \ - "${FLAGS[@]}" "${SPEC_FILTERS[@]}" 2>/dev/null || true) - -if [ -n "$LIST_OUTPUT" ]; then - # Extract unique spec basenames from lines like: - # " [chromium] › auth.spec.ts:4:3 › ..." - mapfile -t SPEC_NAMES < <( - echo "$LIST_OUTPUT" | sed -n 's/.*› \([^:]*\.spec\.ts\):.*/\1/p' | sort -u - ) +if LIST_OUTPUT=$(npx playwright test --config "$CONFIG" --list \ + "${FLAGS[@]}" "${SPEC_FILTERS[@]}" 2>&1); then + LIST_STATUS=0 else - echo "[e2e-entrypoint] --list produced no output, falling back to glob" - mapfile -t SPEC_NAMES < <( - ls "$SPECS_DIR"/*.spec.ts 2>/dev/null | xargs -n1 basename 2>/dev/null | sort - ) + LIST_STATUS=$? +fi +if [ "$LIST_STATUS" -ne 0 ]; then + log_json error e2e_discovery_failed "Playwright test discovery failed." + exit "$LIST_STATUS" fi +# Extract unique spec basenames from lines like: +# " [chromium] › auth.spec.ts:4:3 › ..." +mapfile -t SPEC_NAMES < <( + echo "$LIST_OUTPUT" | sed -n 's/.*› \([^:]*\.spec\.ts\):.*/\1/p' | sort -u +) + SPEC_COUNT="${#SPEC_NAMES[@]}" if [ "$SPEC_COUNT" -eq 0 ]; then - echo "[e2e-entrypoint] No spec files found. Running all tests." - exec npx playwright test --config "$CONFIG" "${FLAGS[@]}" "${SPEC_FILTERS[@]}" + log_json error e2e_no_specs "No spec files discovered." + exit 1 fi echo "[e2e-entrypoint] Found ${SPEC_COUNT} spec files" @@ -182,8 +195,8 @@ SORTED_PAIRS=$(printf '%s' "$PAIRS" | sort -rn) declare -a shard_totals=() declare -a shard_specs=() for ((i = 0; i < TASK_COUNT; i++)); do - shard_totals[$i]=0 - shard_specs[$i]="" + shard_totals[i]=0 + shard_specs[i]="" done while read -r dur spec; do @@ -197,11 +210,11 @@ while read -r dur spec; do min_total=${shard_totals[$i]} fi done - shard_totals[$min_shard]=$(( min_total + dur )) - if [ -z "${shard_specs[$min_shard]}" ]; then - shard_specs[$min_shard]="$spec" + shard_totals[min_shard]=$(( min_total + dur )) + if [ -z "${shard_specs[min_shard]}" ]; then + shard_specs[min_shard]="$spec" else - shard_specs[$min_shard]="${shard_specs[$min_shard]} $spec" + shard_specs[min_shard]="${shard_specs[min_shard]} $spec" fi done <<< "$SORTED_PAIRS" @@ -237,7 +250,7 @@ fi # substring filter ambiguity between similarly-named specs). read -ra MY_SPEC_PATHS <<< "$MY_SPECS" for i in "${!MY_SPEC_PATHS[@]}"; do - MY_SPEC_PATHS[$i]="${SPECS_DIR}/${MY_SPEC_PATHS[$i]}" + MY_SPEC_PATHS[i]="${SPECS_DIR}/${MY_SPEC_PATHS[i]}" done echo "[e2e-entrypoint] Playwright flags: ${FLAGS[*]-}" diff --git a/docker/cloud-run/test-durations.txt b/docker/cloud-run/test-durations.txt index dcf62dacf..ad3e1381d 100644 --- a/docker/cloud-run/test-durations.txt +++ b/docker/cloud-run/test-durations.txt @@ -1,9 +1,9 @@ # Estimated durations for e2e spec files (seconds). # # These estimates reflect the TOTAL time each spec file takes when run under -# the cloud config (test/e2e-browser/playwright.cloud.config.ts) with ALL -# active projects (chromium + legacy-chromium + rust-chromium). Matrix specs -# that run under 3 projects have proportionally higher estimates. +# the cloud config (test/e2e-browser/playwright.cloud.config.ts) with the +# single Chromium project. The browser selection is intentionally identical +# for local and Cloud Run jobs so shard estimates remain comparable. # # The entrypoint reads this file to perform greedy duration-aware shard # assignment instead of Playwright's count-based --shard round-robin. Specs diff --git a/docs/development/test-sandbox.md b/docs/development/test-sandbox.md index 19a0f0867..f60099955 100644 --- a/docs/development/test-sandbox.md +++ b/docs/development/test-sandbox.md @@ -2,7 +2,7 @@ Destructive and ops-style test suites (process kills, config corruption, restart storms) and agent verification runs execute inside a disposable Docker container so accidents physically -cannot touch the host's live servers, real data (`~/.freshell`, `~/.claude`, `~/.codex`, +cannot touch the host's live Rust server, real data (`~/.freshell`, `~/.claude`, `~/.codex`, `~/.local/share/opencode`), or unrelated processes. ## The one command @@ -48,8 +48,8 @@ of each is slower — see below): - `freshell-sandbox-cargo-target` — **sandbox-owned**, not the host's `target/`. Sharing the host target directory would cause lock contention with concurrent host builds. - `freshell-sandbox-node-modules` — **sandbox-owned**, populated via `npm ci` inside the - container on first use. The host's `node_modules` has host-built native modules (e.g. - `node-pty`) that won't run inside the container's different environment. + container on first use. The host's `node_modules` contains host-specific tooling and + should not be shared with the container's environment. - `freshell-sandbox-playwright-cache` — downloaded browser binaries. Reset everything (forces a clean re-warm on next run): diff --git a/docs/development/windows-electron-build.md b/docs/development/windows-electron-build.md index 4d4951e32..eda651deb 100644 --- a/docs/development/windows-electron-build.md +++ b/docs/development/windows-electron-build.md @@ -1,99 +1,108 @@ # Building the Windows Electron App This documents how to produce the Windows desktop installer -(`release/Freshell Setup .exe`). +(`release/Freshell Setup .exe`). The desktop app has one app-bound +backend: the native Rust `freshell-server` executable. Node is packaged only +for the standalone MCP client and the isolated Claude SDK sidecar. -## Key constraint: it must run on native Windows +## Key constraint: build on native Windows -The Windows build **cannot be produced from WSL/Linux**. `npm run +The Windows build must run as a native Windows process. `npm run electron:build:win` begins with `scripts/assert-native-windows-build.ts`, which -hard-fails unless `process.platform === 'win32'` — because `node-pty` has to be -compiled for win32. Running the pipeline from Linux produces a broken installer -(a tiny NSIS stub with no bundled `node.exe`) and, if you let it, a Linux -AppImage instead. If you see a ~few-hundred-KB `Freshell Setup *.exe`, you built -on the wrong platform. +hard-fails unless `process.platform === 'win32'`. This ensures Cargo produces a +native `freshell-server.exe` and Electron Builder packages the Windows +artifact, rather than a Linux binary or a non-runnable installer stub. ## Prerequisites (on the Windows side) -- Node.js (matching `engines.node`, currently `>=22.5.0`) and npm. -- Visual Studio Build Tools with the **Desktop development with C++** workload, - and Python 3 — required for `node-gyp` to compile `node-pty`. -- No extra download tools are needed: `scripts/prepare-bundled-node.ts` fetches - the standalone Node binary and headers over Node's own `http`/`https` and - extracts them with the bundled `tar` and `extract-zip` packages (not external - `curl`/`tar`/`unzip`). +- Node.js (matching `engines.node`, currently `>=22.5.0`) and npm for the + client, tooling, and Electron build. +- A Rust stable toolchain with the MSVC target (`rustup`, Cargo, and the + Visual Studio Build Tools **Desktop development with C++** workload). +- No Node native-module compiler or Python setup is required for the + app-bound backend. The Rust server owns PTY support. ## Option A — from a native Windows shell ```powershell -npm install # installs Windows-native deps (compiles node-pty for win32) +npm ci $env:CI = "true" -npm run electron:build:win # assert win32 → build → prepare:bundled-node → electron-builder --win nsis +npm run electron:build:win # assert win32 → client/tools/Rust → Electron Builder NSIS ``` -`electron:build:win` runs, in order: the platform assert, `npm run build` -(typecheck + client + server), `build:electron`, `build:wizard`, -`build:launch-chooser`, `prepare:bundled-node` (downloads the standalone Node, -recompiles `node-pty`, prunes `server-node-modules`), then `electron-builder ---win nsis --publish never`. - -Output lands in `release/`. +`electron:build:win` runs, in order: the native-platform assertion, client and +tool typechecks/builds, the release `freshell-server.exe` Cargo build, +`build:electron`, `build:wizard`, `build:launch-chooser`, +`prepare:claude-sidecar`, `prepare:electron-runtime`, `electron-builder --win +nsis --publish never`, and the artifact verifier. `prepare:claude-sidecar` +runs a locked `npm ci` in `crates/freshell-claude-sidecar` and verifies the +Claude SDK package before it is copied into the installer; no sidecar +`node_modules` directory needs to be checked into the repository. Output lands +in `release/`. ## Option B — driving the Windows build from WSL -Your dev checkout usually lives on the WSL filesystem, but the build must run as -a native Windows process. **Do not** build over the `\\wsl.localhost\...` UNC -path (slow and fragile over 9p). Instead, copy the working tree to a -Windows-local path and run Windows' own npm against it via interop. +Your dev checkout usually lives on the WSL filesystem, but the build must run +as a native Windows process. **Do not** build over the `\\wsl.localhost\...` +UNC path (slow and fragile over 9p). Copy the worktree to a Windows-local path +and run Windows' own npm and Cargo against it via interop. -1. Copy the worktree to a Windows-local dir, excluding regenerable/platform dirs: +1. Copy the worktree to a Windows-local directory, excluding generated and + platform-specific directories: ```bash rsync -rlt --delete --no-perms --no-owner --no-group \ --exclude='.git' --exclude='node_modules/' --exclude='dist/' \ - --exclude='release/' --exclude='bundled-node/' --exclude='server-node-modules/' \ + --exclude='target/' --exclude='release/' --exclude='electron-runtime/' \ ./ "/mnt/c/Users//AppData/Local/Temp/freshell-electron-build/" ``` -2. Run Windows npm in that dir via `cmd.exe`. Always `cd /d` to a real Windows - path first — `cmd.exe` launched from WSL inherits the UNC cwd and will warn - and mangle relative paths: +2. Run Windows npm in that directory via `cmd.exe`. Always `cd /d` to a real + Windows path first — `cmd.exe` launched from WSL inherits the UNC cwd and + will warn and mangle relative paths: ```bash - cmd.exe /c 'cd /d C:\Users\\AppData\Local\Temp\freshell-electron-build && set "CI=true" && set "PORT=39517" && npm install && npm run electron:build:win' + cmd.exe /c 'cd /d C:\Users\\AppData\Local\Temp\freshell-electron-build && set "CI=true" && set "PORT=39517" && npm ci && npm run electron:build:win' ``` - - `PORT=` is belt-and-suspenders for the `prebuild` guard. (It - normally auto-skips here because the copied `.git` is a worktree pointer, - so `isLinkedWorktreeCheckout` is true — but WSL2 forwards `localhost`, so a - live dev server on the default port is otherwise visible to the guard.) - - Reusing a previous build dir keeps its warm Windows `node_modules` (with the - already-compiled win32 `node-pty`), making `npm install` a fast no-op. + `PORT=` keeps the build's preflight isolated from any unrelated + local service. The package build installs and verifies the isolated Claude + sidecar from its committed lockfile as part of the command. Reusing a + previous Windows-local build directory keeps its native dependencies warm, + while `target/`, `dist/`, and `electron-runtime/` are rebuilt for the copied + checkout. 3. To move artifacts off `/mnt/c`, prefer WSL `cp` over `cmd copy` — `cmd`'s quote/path handling through interop is unreliable for paths with spaces. ## What you get -`config/electron-builder.yml` targets **`nsis`** for Windows: a one-click, per-user -installer (`oneClick: true`, `perMachine: false`). +`config/electron-builder.yml` targets **`nsis`** for Windows: a one-click, +per-user installer (`oneClick: true`, `perMachine: false`). -- `release/Freshell Setup .exe` — the installer. Running it installs to - `%LOCALAPPDATA%\Programs\Freshell\Freshell.exe` and (with `runAfterFinish`) - launches the app. +- `release/Freshell Setup .exe` — the installer. Running it installs + to `%LOCALAPPDATA%\Programs\Freshell\Freshell.exe` and launches the app + when `runAfterFinish` is enabled. - `release/win-unpacked/Freshell.exe` — the app executable itself; run it directly to launch without installing. -The installer is **unsigned** unless a code-signing certificate is configured, so -Windows SmartScreen will warn on first run. +The installer is **unsigned** unless a code-signing certificate is configured, +so Windows SmartScreen may warn on first run. ## Sanity-check a build A good build should show: -- `release/Freshell Setup .exe` is full size (hundreds of MB), not a - small stub. -- `release/win-unpacked/resources/bundled-node/bin/node.exe` exists (the bundled - server runtime — absent in broken cross-builds). -- `release/win-unpacked/resources/server-node-modules/node-pty/prebuilds/win32-x64/conpty.node` - exists. +- `release/Freshell Setup .exe` is a full-size installer, not a small + stub. +- `release/win-unpacked/resources/bin/freshell-server.exe` exists and is the + app-bound backend. +- `release/win-unpacked/resources/client/index.html` exists. +- `release/win-unpacked/resources/node/bin/node.exe` exists only for the + packaged MCP client and Claude sidecar. +- The packaged resources contain no legacy backend directory or compiled + legacy backend artifact, and no backend-specific native Node addon is + packaged. + +The authoritative checkout-free checks are `npm run verify:electron-artifact` +and `npm run test:electron:runtime`. diff --git a/docs/plans/2026-08-26-retire-node-server-v2.md b/docs/plans/2026-08-26-retire-node-server-v2.md new file mode 100644 index 000000000..3ff612f43 --- /dev/null +++ b/docs/plans/2026-08-26-retire-node-server-v2.md @@ -0,0 +1,2008 @@ +# Rust-Only Freshell Backend Retirement Plan (v2) + +> **For agentic workers:** Execute this plan in order on +> `.worktrees/retire-node-server-v2`. Use a fresh implementer plus specification +> and quality review after every task. Each task must finish with its focused +> tests green and a focused commit before the next task starts. + +## User Request + +### Requested result +Retire Freshell's legacy Node.js application server so the Rust server is the only supported backend/server path going forward. + +### Explicit constraints +- Use the requested the-usual workflow. +- Work in the fresh isolated `the-usual/retire-node-server-v2` worktree created from updated, green `origin/main`; preserve the first run as a superseded audit record until this replacement plan is validated. +- Treat current Rust server behavior as the compatibility baseline. +- Inventory and triage Node-only server features absent from Rust. If important and not tracked elsewhere, file them as katas. +- Do not carry the prior BrowserPane security-redesign premise into this retirement. +- Node may remain for non-server frontend/build/test tooling, the Electron shell, standalone CLI/MCP clients, and the isolated Claude SDK sidecar; no Node process may remain as Freshell's HTTP/WebSocket/backend server. +- Relocate retained CLI/MCP client source and build artifacts out of the legacy `server/` and `dist/server/` namespaces; do not rewrite them in Rust solely for this retirement. +- Remove or clearly disable current client/CLI/MCP actions that only call Node-only endpoints absent from the Rust baseline; already-tracked future capabilities remain owned by their existing issues. +- Make every supported source, packaged Electron, daemon/service, container, test, and release server path launch `freshell-server` rather than the Node backend. +- Use Red-Green-Refactor TDD and preserve appropriate unit, integration, and end-to-end coverage for retained behavior. +- Keep end-user documentation in `README.md`; update `docs/index.html` only for a major user-facing UI change. +- Commit `.kata.toml` whenever it is modified. +- Do not create or open a PR without explicit user approval, and do not push behavior changes directly to `origin/main`. +- Never restart the live self-hosted Rust server on port 3001 without the user's explicit word `APPROVED`. +- Prefer bash; repository code must use robust structured JSONL logging with severity where logging is needed. + +### Accepted tradeoffs and residuals +- Current Rust server behavior, rather than every legacy Node-only behavior, is the compatibility baseline for retirement. +- Node-only server features absent from Rust are not automatic porting requirements; important untracked features are preserved as katas instead. +- The prior run's BrowserPane security redesign is outside this retirement scope. +- Retained Node CLI/MCP programs are non-server backend clients and may remain after being disentangled from the legacy server build. + +**Goal:** `freshell-server` is the only executable that listens on Freshell's +HTTP/WebSocket port, owns Freshell PTYs, or composes backend state. Browser, +Electron, standalone service, container, test, and release paths all start that Rust +binary. Node remains only in the explicitly permitted frontend/build/test, +Electron-shell, standalone CLI/MCP-client, and Claude-sidecar roles. + +**Architecture:** Keep the Rust backend unchanged as the product compatibility +baseline. First move neutral TypeScript contracts and the retained HTTP clients +out of `server/`, and make Rust-absent actions truthful without porting them. +Then make every live harness, source command, Electron process plan, installer, +container, and CI/release job Rust-backed. Only after those consumers are green +delete the legacy implementation, its tests, configs, dependencies, and emitted +namespace. Permanent structural and non-vacuity guards prevent a Node backend or +an empty test lane from returning. + +**Tech stack:** Rust 1.96.0 (`freshell-server`, Cargo workspace, Tokio/Axum, +structured `tracing` output), React/Vite/TypeScript, standalone Node 22 CLI and +MCP HTTP clients, Vitest, Playwright, Electron/electron-builder, bash launchers, +Docker, and GitHub Actions. + +## Global Execution Constraints + +- Work only in `/home/dan/code/freshell/.worktrees/retire-node-server-v2` on + `the-usual/retire-node-server-v2`. Preserve + `/home/dan/code/freshell/.worktrees/retire-node-server` and its plan as an + untouched superseded audit record. +- Current Rust behavior is authoritative. Do not port attachments, fresh-agent + exec/diff/send, external editor opening, extension lifecycle/assets, raw TCP + forwarding, WebSocket proxy upgrades, `/api/run`, paged transcript turns, + terminal viewport/paged scrollback, `codingcli.*`, or the incident dump merely + to delete Node. The interactive precheck self-update prompt is likewise + triaged, not silently equated with Rust's server-side update check. Existing + parity issue #624/checklist items retain ownership. +- Never contact, stop, restart, or health-check port 3001. Every executable test + owns an isolated `HOME`/`FRESHELL_HOME`, token, PID, and OS-assigned or unique + non-3001 loopback port. Lifecycle/restart-storm tests use + `scripts/sandbox-test.sh`; no broad kill pattern is allowed. +- Direct Vitest runs go through `npm run test:vitest -- ...`; broad branch runs + use the shared coordinator. Before a configured Playwright run, obey the + repository rule for an unset `FRESHELL_E2E_BACKEND`. A required spec in + `CLOUD_SKIP_SPECS`, a zero-test filter, or a soft skip is not coverage. +- New Node/Electron/tooling logs are one JSON object per line with `severity`, + `event`, and non-secret context. New Rust logs use the configured structured + `tracing` subscriber. Never log tokens, authorization headers, prompts, + attachment/file bodies, or sidecar payloads. +- No task starts a PR. A branch push is permitted for the final review handoff; + never push to `origin/main`. Native required checks run only after the user + explicitly approves PR creation. Do not deploy the result. +- `.kata.toml` is expected to remain byte-identical. If implementation really + changes it, include it in the focused task commit. Normal Kata create/search + operations must not change it. +- `docs/index.html` remains unchanged: the default UI layout is not being + redesigned. User-visible capability and install/runtime statements belong in + `README.md`; contributor/runtime commands belong in `AGENTS.md` and the + Windows Electron build guide. + +## File Responsibility and Interface Map + +- `scripts/retirement/runtime-surfaces.json` is the checked-in, closed inventory + of every supported launch, service, packaging, container, test-fixture, and + release owner, including root executables and surviving `port/**` bootstrap + scripts. `scripts/retirement/runtime-boundary.ts` reconciles the manifest in + both directions: every discovered owner maps to exactly one row and every row + resolves to tracked evidence. It ignores historical `docs/plans/**` and frozen + evidence, and reports sorted `manifestDrift`, `legacyDebt`, and + `unexpectedNodeBackend` entries. +- `shared/tab-registry-types.ts` and `shared/freshell-home.ts` own application + contracts formerly imported from the Node backend. + `config/vite/get-network-host.ts` owns Vite's bind-host lookup. + `scripts/testing/repo-context.ts` owns test-coordinator Git and worktree + discovery. +- `tools/freshell-cli/**` is the retained package CLI; `tools/freshell-mcp/**` is + the retained stdio MCP bridge; `tools/node-client-runtime/**` owns common + client config, terminal-key translation, shared error constants, and the + minimal runtime-dependency manifest. These programs are + HTTP clients only: they never listen, own a PTY, import `server/**`, or compose + backend state. `tsconfig.tools.json` emits only `dist/tools/**`. +- `crates/freshell-platform/src/mcp_inject.rs` injects the retained MCP client. + Its production interface accepts the explicit pair `FRESHELL_MCP_NODE` and + `FRESHELL_MCP_ENTRY`; checkout fallback resolves + `dist/tools/freshell-mcp/server.js` or the TypeScript source under `tools/`. +- `src/components/**`, `src/lib/api.ts`, `src/store/freshAgentThunks.ts`, and + `shared/ws-protocol.ts` advertise only current Rust-baseline behavior. A + disabled action never sends a request to a known-missing route. +- `test/e2e-browser/helpers/rust-server.ts`, `external-target.ts`, `fixtures.ts`, + and `playwright.config.ts` own one Rust-backed browser lane. An external target + is read-only and never stopped; an owned target records/reaps its exact PID. +- `scripts/testing/**`, `config/vitest/vitest.config.ts`, and + the dedicated `vitest.runtime.config.ts`, `vitest.electron.config.ts`, and + `vitest.electron-runtime.config.ts` own the broad/artifact gates: retained + default Vitest, source-runtime smoke, the Rust workspace, Electron unit tests, + and staged Electron runtime acceptance. Artifact-dependent trees are excluded + from default discovery; required lanes reject zero selection and do not use + `--passWithNoTests`. +- `scripts/start-rust-server.ts`, `scripts/launch.sh`, + `scripts/launch-rust.sh`, root `run-rust-server.sh`, and retained + `port/**` bootstrap scripts own source start/serve lifecycle. They launch or + build only `target/{debug,release}/freshell-server` and preserve exact-PID + safety. +- `electron/server-spawner.ts` owns the Electron app-bound Rust child. Electron + supports app-bound and remote modes; the advertised but never provisioned + Electron daemon mode and its service managers/templates are removed. The + standalone `installers/systemd/freshell-rust.service` remains the supported + Rust service path. The app-bound process contract has `serverBinary`, + `clientDir`, `claudeNodeBinary`, `claudeSidecarEntry`, `mcpNodeBinary`, + `mcpEntry`, `homeDir`, `configDir`, and `logDir`; it has no Node server entry + or `NODE_PATH`. +- `scripts/prepare-electron-runtime.ts` stages the host-native Rust server, built + client, compiled MCP bridge plus its minimal production dependency closure, + and the isolated Claude Node/sidecar runtime. `config/electron-builder.yml` + packages only those staged resources plus Electron assets/installers. +- `docker/cloud-run/**`, `examples/docker/Dockerfile`, `.github/workflows/**`, + and `scripts/verify-electron-artifact.ts` own container/CI/release proof that + the backend artifact is Rust and forbidden Node-server artifacts are absent. +- `README.md` is the end-user truth. `AGENTS.md`, `.env.example`, and + `docs/development/windows-electron-build.md` are active contributor/operator + truth. Historical plans and port evidence remain as provenance. + +## Requirement Trace + +| Requirement | Delivering tasks | Proof | +| --- | --- | --- | +| Rust is the sole backend/server | 1, 4, 6-11 | Closed runtime manifest has zero drift/debt; source/browser/Electron/container/release provenance names `freshell-server`; `server/` and `dist/server/` do not exist. | +| CLI/MCP remain standalone Node clients | 2, 7-8, 10 | Sources and output are `tools/**`/`dist/tools/**`; MCP injection and package bin use them; unit and live Rust E2E pass; no client listens or imports backend code. | +| Rust-absent actions are honest | 2-3, 5 | A 33-action/14-alias table rejects every unsupported action or argument locally without HTTP; browser client makes no missing-route requests; dead REST/WS declarations disappear. | +| Browser uses only Rust | 3-5, 11 | One `chromium` project, Rust fixture provenance, at least 308 tests in at least 86 files, zero legacy project/kind, and configured E2E green. | +| Electron/service use packaged Rust | 7-9, 11 | Electron daemon mode is absent; app-bound Electron E2E, standalone-service inspection, checkout-free native artifact acceptance, and all-OS CI receipts show the Rust binary and reject Node backend artifacts. | +| Test/build/release proof is non-vacuous | 4, 6, 9, 11 | No `--passWithNoTests`; Cargo workspace is in the broad gate; Tauri smoke fails without a binary; selection/artifact floors and provenance assertions pass. | +| Node-only gaps are triaged, not silently ported | 3, 5, 11 | Final external receipt repeats source/caller/Kata/GitHub/checklist searches; expected result is no important untracked gap; a Kata is filed only on contrary evidence. | +| Safety/docs/process constraints | all, especially 11 | Isolated ports/PIDs, no port-3001 contact, README/active guides updated, `docs/index.html` untouched, `.kata.toml` unchanged or committed. | + +--- + +### Task 1: Establish the Runtime Boundary and Move Neutral TypeScript Owners + +**Files:** + +- Create: `scripts/retirement/runtime-surfaces.json` +- Create: `scripts/retirement/runtime-boundary.ts` +- Create: `test/unit/architecture/rust-only-server-runtime.test.ts` +- Create: `shared/tab-registry-types.ts` +- Create: `shared/freshell-home.ts` +- Create: `config/vite/get-network-host.ts` +- Create: `scripts/testing/repo-context.ts` +- Modify: `src/store/tabRegistryTypes.ts` +- Modify: `server/tabs-registry/types.ts` +- Modify: `server/freshell-home.ts` +- Modify: `config/vite/vite.config.ts` +- Modify: `scripts/testing/test-coordinator.ts` +- Modify: `scripts/precheck.ts` +- Modify: `test/unit/vite-config.test.ts` +- Modify: `test/e2e-browser/helpers/session-corpus/session-corpus.test.ts` +- Delete: `test/e2e/update-flow.test.ts` with the retired interactive updater + skip-contract fixtures +- Modify: existing coordinator/precheck/tab-registry tests that import the moved owners + +**Interfaces:** + +- `analyzeRuntimeBoundary(root): Promise<{ manifestDrift: string[]; + legacyDebt: string[]; unexpectedNodeBackend: string[] }>` loads a closed + manifest seeded from the load-bearing review's 44 runtime/resource owners and + returns stable sorted repo-relative evidence. Every tracked executable, + package command, service/template, container entrypoint, fixture server, + release job, root launcher, and surviving `port/**` bootstrap owner must map to + exactly one manifest row; every row must resolve. Each row declares its role. + Sanctioned Node roles are explicit entrypoint/module rules, not directory-wide + exclusions: Vite/Vitest/Electron-main/CLI/MCP/Claude-sidecar modules plus the + explicitly listed non-backend test infrastructure listeners are allowed. The + listener rows are `scripts/testing/coordinator-endpoint.ts`, + `test/e2e-browser/helpers/echo-ws-fixture.ts`, + `test/e2e-browser/helpers/harness-06/{target-server,update-feed,fake-ai}.ts`, + `test/e2e-browser/fixtures/providers/{fake-codex-app-server.mjs,fake-opencode-server.mjs}`, + `test/e2e-browser/fixtures/fake-opencode.cjs`, the individual + `scripts/proofs/browser-*-probe.ts` files, and `electron/port-check.ts`. + Those rows own only test coordination, probes, or fake targets and no Freshell + PTY/backend state. Backend listeners, WebSocket servers, Freshell PTY + ownership, or imports from `server/**` still fail outside those exact rows. +- `getFreshellHomeDir(env)` and `getFreshellConfigDir(env)` preserve the current + `FRESHELL_HOME`-then-home behavior without relying on the `NodeJS` global type; + the two legacy `server/**` modules are temporary re-exports until Task 10. +- `getNetworkHost({ env, configDir, isWsl })` is dependency-injected and has no + import from `server/**`; Vite's live wrapper supplies process env and WSL + detection. +- `resolveGitRepoRoot`, `resolveGitCheckoutRoot`, and cache reset remain available + to the coordinator from `scripts/testing/repo-context.ts`. +- `scripts/precheck.ts` retains branch confirmation, dependency checks, and port + conflict checks. It retires the interactive Node precheck self-update prompt; + Rust retains its distinct server-side update-check behavior, and Task 11 + explicitly triages whether the removed interactive flow has an existing owner + or needs a Kata. + +- [ ] **Step 1: Write the failing behavioral test** + + Add `rust-only-server-runtime.test.ts` with a synthetic-tree test proving an + invented Node HTTP listener is `unexpectedNodeBackend`, an allowlist test for + Vite/Vitest/Electron-main/CLI/MCP/Claude-sidecar roles and each exact + coordinator/fixture/probe listener row above, and manifest reconciliation tests + for an unlisted tracked owner, a stale row, and duplicate ownership. The + current-tree test requires the known debt entries + `server/index.ts`, `package.json:scripts.start`, + `config/electron-builder.yml:dist/server`, + `test/e2e-browser/playwright.config.ts:legacy-chromium`, the stale legacy + comment in root `run-rust-server.sh`, and the inherited build path in + `port/laptop-bootstrap/2-bootstrap-wsl.sh`. Extend existing + Vite/coordinator/tab-registry tests to import only the new neutral paths. Rework + `session-corpus.test.ts` so it tests corpus writer/file invariants without + importing the soon-to-be-deleted Node Amplifier/OpenCode production readers; + Rust-owned browser/API corpus specs remain the production ingestion proof. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:e2e:helpers -- test/e2e-browser/helpers/session-corpus/session-corpus.test.ts + ``` + + Expected: FAIL because `scripts/retirement/runtime-boundary.ts` and the neutral + modules do not exist. No test may fail by contacting a server or port 3001. + +- [ ] **Step 3: Add the minimal implementation** + + Check in the closed manifest and implement two-way reconciliation before + moving the neutral code without changing its data semantics. Discovery is + deliberately broader than the manifest and fails closed on a new root + executable, package script, service resource, container command, test server, + workflow launch step, or retained `port/**` bootstrap path. Classify the + explicitly listed coordinator/fixture/probe listeners as non-backend rows; + an unlisted listener or any listener that owns Freshell backend state remains + unexpected. Replace the + coordinator import of `server/coding-cli/utils.ts`, the + Vite import of `server/get-network-host.ts`, and the client import of + `server/tabs-registry/types.ts`. Make `server/freshell-home.ts` and + `server/tabs-registry/types.ts` temporary NodeNext `.js` re-exports from the + neutral owners so the intermediate backend consumes the same contracts. Remove + only the interactive update-check block/import from `scripts/precheck.ts`; + preserve its serve-branch and port protections and record the removed flow for + Task 11 triage. Delete `test/e2e/update-flow.test.ts` and its + `--skip-update-check`/`SKIP_UPDATE_CHECK` fixtures because the interactive + updater no longer exists; do not leave a passing test for a removed behavior. + Remove the two Node provider-reader imports/assertions from the session-corpus + helper test while preserving writer/schema/hash coverage; do not + move deleted backend readers into a neutral namespace. Keep a temporary + explicit debt list so + later tasks can remove entries one by one; manifest rows remain after their + classification changes from legacy debt to Rust or sanctioned Node client. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:e2e:helpers -- test/e2e-browser/helpers/session-corpus/session-corpus.test.ts + ``` + + Expected: PASS; synthetic Node listener rejection bites, sanctioned tooling is + accepted, manifest drift is empty, and current legacy debt is enumerated rather + than hidden. + +- [ ] **Step 5: Refactor while green** + + Deduplicate path normalization/file walking, sort every diagnostic, and extract + pure adapters around process env/filesystem access. Preserve public schema/type + names so client persistence does not migrate. Add fixtures showing that a file + under `docs/plans/**` is ignored while the same text under `scripts/**` is debt, + that root and `port/**` executable owners cannot escape inventory, and that a + fake `tools/` or `electron/` Node HTTP listener cannot bypass capability + detection. Keep semantic listener detection as defense in depth behind the + closed surface manifest. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "server/(tabs-registry/types|get-network-host|coding-cli/utils|updater)" src config scripts test/e2e-browser test/unit --glob '!test/unit/server/**' + npm run typecheck:client + npm run test:vitest -- run test/unit/architecture test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/server/testing test/unit/server/prebuild-guard.test.ts --config config/vitest/vitest.server.config.ts + ``` + + Expected: the search returns no retained consumer of those paths; typecheck and + impacted tests PASS. References confined to legacy implementation/tests remain + eligible for Task 10 deletion. + +- [ ] **Step 7: Commit the task** + + ```bash + git add scripts/retirement test/unit/architecture shared/tab-registry-types.ts shared/freshell-home.ts config/vite/get-network-host.ts scripts/testing/repo-context.ts src/store/tabRegistryTypes.ts server/tabs-registry/types.ts server/freshell-home.ts config/vite/vite.config.ts scripts/testing/test-coordinator.ts scripts/precheck.ts test/unit/vite-config.test.ts test/e2e/update-flow.test.ts test/e2e-browser/helpers/session-corpus + git commit -m "refactor: isolate neutral code from Node server" + ``` + +### Task 2: Relocate and Make Truthful the Standalone CLI and MCP Clients + +**Files:** + +- Create: `tools/freshell-cli/**` from retained `server/cli/**` +- Create: `tools/freshell-mcp/{server.ts,freshell-tool.ts,http-client.ts}` +- Create: `tools/node-client-runtime/{action-capabilities,config,keys,codex-restore-contract}.ts` +- Create: `tsconfig.tools.json` +- Move: `test/unit/server/mcp/{freshell-tool,http-client,server}.test.ts` to `test/unit/mcp/` +- Modify: `test/unit/cli/**` +- Delete after replacement: `test/e2e/agent-cli-flow.test.ts` +- Delete after replacement: `test/e2e/agent-cli-screenshot-smoke.test.ts` +- Create: `test/e2e-browser/specs/cli-rust.spec.ts` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `server/agent-api/router.ts` +- Modify: `server/coding-cli/codex-app-server/restore-decision.ts` +- Modify: `server/mcp/config-writer.ts` +- Modify: `test/unit/server/mcp/config-writer.test.ts` +- Modify: `test/unit/server/mcp/config-writer-paths.test.ts` +- Modify: `crates/freshell-platform/src/mcp_inject.rs` +- Modify: `crates/freshell-platform/src/mcp_inject_tests.rs` +- Modify: `crates/freshell-platform/src/cli_launch.rs` +- Modify: `crates/freshell-platform/src/cli_launch_goldens.rs` +- Modify: `test/e2e-browser/helpers/mcp-stdio-client.ts` +- Modify: `test/e2e-browser/playwright.config.ts` +- Modify: `test/e2e-browser/specs/mcp-bridge-rust.spec.ts` +- Modify: `test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts` +- Create: `test/fixtures/tools/rust-action-capability-matrix.json` +- Retain until Task 10: `server/mcp/config-writer.ts` as part of the still-pending legacy backend only; it is not copied into `tools/**` + +**Interfaces:** + +- `package.json#bin.freshell` points to `dist/tools/freshell-cli/index.js`. + `typecheck:tools` runs that config with `--noEmit`; `build:tools` runs + `tsc -p tsconfig.tools.json`. The config uses + NodeNext/NodeNext, `rootDir: "tools"`, `outDir: "dist/tools"`, and includes + only `tools/**/*.ts`. Tool-relative runtime imports carry `.js`; no tool emits + under `dist/server` or requires a compiled `shared/**` tree. +- A checked-in capability matrix contains all 33 canonical actions and 14 + aliases. Validation, CLI help, MCP schema/description, and tests consume the + same table; unclassified or duplicate actions fail the build. Supported rows + preserve current Rust request paths/output shapes. Unsupported rows return a + deterministic local exit-code-2 or `{ error, hint }` result and make zero HTTP + requests. +- Unsupported rows/variants are: `run`; `fresh-send`; `attach`; `new-tab` with + `agent` other than Rust-supported `opencode`; `split-pane` with any of + `agent`, `model`, or `effort`; `wait-for` without a pattern or with + `stable|exit|prompt`. Rust-supported `capture` `J`/`e` arguments remain + accepted and advertised as no-op parameters, matching the current Rust + baseline. Help and MCP parameter schemas do not advertise the unsupported + rows above. Direct + Claude/Codex terminals continue through supported `mode` values rather than + the rejected `agent` sugar. +- Replace the hard-coded-`node` args-only seam with + `McpServerCommand { command: McpServerArg, args: Vec }` and + `McpRuntime::server_command()`. Every generated Claude/Gemini/Kimi JSON, + Codex TOML pair, and OpenCode command array uses that command field. + `RealMcpRuntime` resolves an explicit `FRESHELL_MCP_NODE` plus + `FRESHELL_MCP_ENTRY` pair first, production (`node` plus) + `dist/tools/freshell-mcp/server.js` second, and dev + `tools/freshell-mcp/server.ts` with the tsx loader third. Supplying only one + explicit variable is an error, not a fallback. Command-aware conversion covers + both the executable and every path-valued argument/config selector in native + Linux, macOS, and Windows plus WSL-to-Windows and Windows-to-WSL crossings; + conversion failure is fatal. +- During the intermediate Tasks 2-9 branch, the legacy backend's + `buildMcpServerCommandArgs` resolves the same `dist/tools`/`tools` entrypoints; + it never points at the deleted `server/mcp/server.ts` source. The whole config + writer disappears with the backend in Task 10. +- Retained Node programs are stdout-disciplined clients: CLI owns stdout UX; MCP + stdout is JSON-RPC only and diagnostics are structured JSONL on stderr. + +- [ ] **Step 1: Write the failing behavioral test** + + Move the MCP/CLI tests to their final paths and add assertions that imports + resolve under `tools/**`, `npm run build:tools` creates both final entrypoints, + the complete 33-action/14-alias matrix is classified exactly once, every + unsupported row/variant above makes zero fake-HTTP calls, `package.json#bin` + is outside `dist/server`, and `mcp_inject` prefers the explicit packaged pair + and rejects a half-configured pair. Change retained config-writer tests to + require its production/dev injection paths under `dist/tools`/`tools` and no + path under `server/mcp`. Put `// @vitest-environment node` at the top of the + moved MCP tests so the default config runs their filesystem/stdio behavior + under the correct environment. Add `cli-rust.spec.ts` against an owned Rust + server and the compiled `dist/tools/freshell-cli/index.js`; its scenarios cover + health/list/create/mutate tab and pane operations, send/capture/wait, browser + navigation/screenshot, paged session listing/search, and the local unsupported + `run` result. Register it explicitly in the pre-collapse `rust-chromium` + `testMatch` and in the pre-collapse `RUST_ONLY_SPECS` exclusion so the legacy + `chromium` project cannot also collect it. This replaces the two + Express/Node-backend fake E2E files. Keep `mcp-qa-smoke-rust.spec.ts` explicitly + local-only because its codex-binary contract is unavailable in cloud E2E; its + positive local receipt is required and the cloud skip is not counted as + replacement coverage. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/cli test/unit/mcp --config config/vitest/vitest.config.ts + cargo test -p freshell-platform --locked mcp_inject + ``` + + Expected: FAIL because the tool sources/final imports do not exist and current + clients still call `/api/run` and `/api/fresh-agent/send`; the Rust test also + reports the old `server/mcp`/`dist/server/mcp` paths. + +- [ ] **Step 3: Add the minimal implementation** + + Move the CLI and only the stdio/client MCP modules. Extract config-dir, key + translation, the action-capability table, and the raw-Codex-resume message to + neutral modules; update the legacy `agent-api/router.ts` and restore-decision + module to consume/re-export + those neutral contracts so removing `server/cli/**` does not break the + intermediate branch. Leave `server/mcp/config-writer.ts` solely inside the + legacy backend until Task 10; do not copy it or any backend/provider module + into `tools/**`, but repoint its generated client command to the new tool + entrypoint so the intermediate backend remains buildable. Add the dedicated + tools TypeScript build and update all source/test/package/Rust-injection paths. + Implement deterministic local unsupported results for every listed + action/variant; remove their happy-path help and parameter schemas. Keep + `@modelcontextprotocol/sdk` as a production dependency of the retained MCP + program. Convert every Rust injection renderer from the old args-only, + hard-coded `node` contract to `McpServerCommand`, including WSL path conversion + of both the executable and every path argument. Update retained + `cli_launch.rs` documentation so the old `server/mcp` path cannot trip the + final structural gate. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run typecheck:tools + npm run build:tools + npm run build:server + test -f dist/tools/freshell-cli/index.js + test -f dist/tools/freshell-mcp/server.js + npm run test:vitest -- run test/unit/cli test/unit/mcp --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/server/mcp/config-writer.test.ts test/unit/server/mcp/config-writer-paths.test.ts --config config/vitest/vitest.server.config.ts + cargo test -p freshell-platform --locked mcp_inject + ``` + + Expected: PASS; both outputs exist outside `dist/server`, unsupported actions + produce the frozen local errors with zero HTTP calls, and every MCP injection + target points at `tools`/`dist/tools`; the full action table is reconciled. + +- [ ] **Step 5: Refactor while green** + + Consolidate CLI/MCP auth URL resolution in `tools/node-client-runtime/config.ts`, + make unsupported-action metadata a read-only table used by validation and help, + and remove duplicated path conversion in `mcp_inject.rs`. Add parse/round-trip + goldens for every provider renderer with command plus args, spaces, quotes, + backslashes, native Linux/macOS/Windows paths, and both WSL crossing directions; + convert config selector paths as well and fail on conversion errors. Add + negative tests proving neither executable opens a listening socket and MCP + stderr remains valid JSONL without corrupting stdout JSON-RPC. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "server/(cli|mcp)|dist/server/(cli|mcp)" package.json tools crates/freshell-platform test/unit/cli test/unit/mcp test/e2e test/e2e-browser/helpers test/e2e-browser/specs/mcp-*.spec.ts + FRESHELL_E2E_BACKEND=local npm run test:e2e:local -- --project=rust-chromium test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts + ``` + + Expected: the search finds no old path; unit tests prove every unsupported + variant has zero transport. The explicit local E2E command avoids the current + cloud skip during this pre-collapse task, runs a nonzero test count, starts one + owned Rust server, executes + `dist/tools/freshell-mcp/server.js`, and PASSes. Task 4 removes the temporary + dual-project registration but preserves the explicitly owned local-only cloud + skip for `mcp-qa-smoke-rust.spec.ts`. + +- [ ] **Step 7: Commit the task** + + ```bash + git add tools tsconfig.tools.json package.json package-lock.json crates/freshell-platform test/fixtures/tools test/unit/cli test/unit/mcp test/unit/server/mcp/config-writer.test.ts test/unit/server/mcp/config-writer-paths.test.ts test/e2e test/e2e-browser/helpers/mcp-stdio-client.ts test/e2e-browser/playwright.config.ts test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts server/agent-api/router.ts server/coding-cli/codex-app-server/restore-decision.ts + git add -A server/cli server/mcp + git commit -m "refactor: separate Node clients from legacy server" + ``` + +### Task 3: Remove or Clearly Disable Rust-Absent Browser Actions + +**Files:** + +- Modify: `src/components/panes/BrowserPane.tsx` +- Modify: `src/components/fresh-agent/FreshAgentComposer.tsx` +- Modify: `src/components/fresh-agent/FreshAgentView.tsx` +- Modify: `src/components/fresh-agent/FreshAgentDiffPanel.tsx` +- Modify: `src/components/panes/EditorPane.tsx` +- Modify: `src/components/panes/ExtensionPane.tsx` +- Modify: `src/lib/pane-action-registry.ts` +- Modify: `src/components/context-menu/menu-defs.ts` +- Modify: `test/unit/client/components/panes/BrowserPane.test.tsx` +- Modify: `test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx` +- Modify: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- Modify: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` +- Replace: `test/unit/client/components/panes/EditorPane.openInEditor.test.tsx` with disabled-action assertions +- Modify: `test/unit/client/components/ExtensionPane.test.tsx` +- Modify: `test/unit/client/components/context-menu/menu-defs.test.ts` +- Modify: `test/e2e-browser/playwright.config.ts` +- Create: `test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts` + +**Interfaces:** + +- BrowserPane continues to proxy `http://localhost:` through + `/api/proxy/http//...` and loads ordinary non-loopback URLs directly. + A remote browser targeting `https://localhost` or Freshell's own loopback port + shows `Remote loopback forwarding is unavailable; use a localhost HTTP URL or open the URL on the server host.` It never POSTs/DELETEs `/api/proxy/forward`. +- Attachment selection is not rendered; `!command` shows + `Shell commands are unavailable here; open a shell pane instead` and does not + send or call REST; diff summaries are non-expandable and state that full diff + loading is unavailable. +- External editor/reveal menu actions and callbacks are removed; the embedded + editor's save/preview behavior remains and never calls `/api/files/open`. +- Client/server extension panes render an accessible unsupported-baseline panel + and never call lifecycle/asset endpoints. CLI-category extension behavior is + left unchanged. + +- [ ] **Step 1: Write the failing behavioral test** + + Change the seven focused component/menu tests to require the exact messages and zero + calls to `/api/proxy/forward`, `/api/fresh-agent/attachments`, + `/api/fresh-agent/exec`, `/api/fresh-agent/diff`, `/api/files/open`, and + `/api/extensions/:name/start`. Add one Rust-owned E2E spec with five scenarios: + localhost HTTP still uses the supported Rust proxy; remote HTTPS loopback + renders the baseline message with no raw-forward request; an editor pane's + context menu lacks external-open/reveal while save still works; a + server/client extension pane renders the accessible unsupported panel with no + start/asset request; an actual markdown file is read, edited, saved, verified + on disk, and rendered in preview through Rust's supported editor routes; and a + fake-provider fresh-agent pane has no attachment + control, blocks `!command`, and cannot expand a diff without making any of the + three removed fresh-agent requests. Capture all page requests and fail on a + forbidden route. Register this Rust-only spec in the pre-collapse + `rust-chromium` `testMatch` and keep it out of `CLOUD_SKIP_SPECS`. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/client/components/panes/BrowserPane.test.tsx test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/EditorPane.openInEditor.test.tsx test/unit/client/components/ExtensionPane.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts --config config/vitest/vitest.config.ts + ``` + + Expected: FAIL because current components perform at least one listed + Rust-absent request or expose the active control. + +- [ ] **Step 3: Add the minimal implementation** + + Remove BrowserPane forwarding state/retry/cleanup and replace only the + unsupported remote-loopback branch with the explicit outcome. Remove attachment + upload state and file input. Keep `!` detection solely to block with the exact + notice. Render diff filenames/status as text, unregister external editor/reveal + callbacks and their menu entries, and short-circuit unsupported + extension categories before any request/iframe URL is constructed. Preserve + Rust's existing localhost proxy and editor read/save/preview behavior. + +- [ ] **Step 4: Run the focused GREEN command** + + Run the Step 2 command again. + + Expected: PASS; every disabled branch is accessible and deterministic, and the + fake API/fetch clients record zero missing-route calls. + +- [ ] **Step 5: Refactor while green** + + Extract a single `RUST_BASELINE_UNAVAILABLE` message map used by controls and + tests, remove dead upload/forward/diff loader types and retry state, and preserve + semantic buttons/`aria-disabled` for controls that remain visible. Keep the + normal localhost HTTP proxy helper independently testable. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "/api/(proxy/forward|fresh-agent/(attachments|exec|diff)|files/open|extensions/.*/start)" src + npm run typecheck:client + npm run lint + npm run test:e2e -- --project=rust-chromium test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts test/e2e-browser/specs/browser-pane.spec.ts + ``` + + Expected: the search returns no production caller; typecheck/lint PASS; the + configured E2E run reports a nonzero test count and PASSes against an owned Rust + server, including the disk-verified editor round trip. Neither required spec + appears in `CLOUD_SKIP_SPECS`. + +- [ ] **Step 7: Commit the task** + + ```bash + git add src/components src/lib/pane-action-registry.ts test/unit/client/components test/e2e-browser/playwright.config.ts test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts + git commit -m "fix: align browser actions with Rust baseline" + ``` + +### Task 4: Collapse Browser E2E to One Owned Rust Backend + +**Files:** + +- Modify: `test/e2e-browser/helpers/external-target.ts` +- Modify: `test/e2e-browser/helpers/fixtures.ts` +- Modify: `test/e2e-browser/helpers/rust-server.ts` +- Create: `test/e2e-browser/helpers/server-fixture-support.ts` +- Delete: `test/e2e-browser/helpers/test-server.ts` +- Delete: `test/e2e-browser/helpers/test-server.test.ts` +- Create: `test/e2e-browser/helpers/server-fixture-support.test.ts` +- Modify: `test/e2e-browser/helpers/rust-server.test.ts` +- Modify: `test/e2e-browser/playwright.config.ts` +- Delete: `test/e2e-browser/playwright.gate01.config.ts` +- Delete: `test/e2e-browser/gate01-run-slice.sh` +- Delete: `test/e2e-browser/helpers/gate01-collate.ts` +- Delete: `test/e2e-browser/helpers/gate01-collate.test.ts` +- Modify: `test/e2e-browser/playwright.cloud.config.ts` +- Modify: `test/e2e-browser/global-setup.ts` +- Modify: `test/e2e-browser/global-teardown.ts` +- Modify: `test/e2e-browser/perf/run-sample.ts` +- Modify: `test/e2e-browser/helpers/leak-metrics.ts` +- Modify: `test/e2e-browser/vitest.config.ts` +- Modify: `test/setup/e2e-browser-global-setup.ts` +- Modify: `test/e2e-electron/electron-app.test.ts` +- Modify: `port/oracle/harness/external-server.ts` +- Create temporarily: `port/oracle/harness/legacy-node-server.ts` from the + oracle-only process-owning portion of `test-server.ts`; Task 5 deletes it +- Modify: the closed current set returned by + `rg -l "kind\\s*:\\s*['\"]legacy['\"]" test/e2e-browser/specs | sort`; + convert each remaining literal legacy server selection to the Rust baseline + fixture or delete the obsolete assertion +- Modify: the closed current set of specs returned by + `rg -l '\be2eServerKind\b' test/e2e-browser/specs | sort`; remove the obsolete + fixture parameter and convert any executable legacy conditional to one + Rust-baseline assertion +- Modify: the closed current set returned by + `rg -l '\bTestServer\b|test-server\.js' test/e2e-browser/specs test/e2e-browser/perf test/e2e-electron | sort`; + direct owned constructors become `RustServer`, while shared types/port/home + helpers import from `server-fixture-support.ts` +- Modify: the closed current comment/config set returned by + `rg -l 'legacy-chromium|dist/server/index' test/e2e-browser test/e2e-electron | sort`; + remove stale executable-path/project claims so structural gates do not confuse + historical comments with active owners +- Create: `test/e2e-browser/helpers/selection-nonvacuity.test.ts` + +**Interfaces:** + +- `E2eServerKind` and the `e2eServerKind` fixture option are removed. The fixture + starts an owned `RustServer`; `createE2eServerHandle` returns that owned server + or a non-owned `ExternalServer` when an explicit external URL is configured. +- `server-fixture-support.ts` owns `E2eServerInfo`, ephemeral-port allocation, + isolated-home env construction, and setup-wizard seeding without any process + constructor. `test-server.ts` is deleted only after every direct constructor + and type/helper import in the browser/Electron sets and the oracle harness has + moved. The oracle keeps its Node constructor temporarily under + `port/oracle/harness/legacy-node-server.ts` so Task 4 stays green; Task 5 + deletes that explicitly while converting oracles to Rust. +- `playwright.config.ts` exposes one primary application project named + `chromium` with Rust fixtures. Its match-all application projects use an exact + `continuity-smoke.spec.ts` exclusion only; all other Rust-only specs formerly + covered by `RUST_ONLY_SPECS` run in the primary project. CI-only + `firefox`/`webkit` projects inherit the same Rust fixture contract and the same + continuity exclusion. `continuity-smoke` remains a separately selected, + Rust-only specialized project without `e2eServerKind`; none is a Node/Rust + split lane. + There is no `legacy-chromium`, `rust-chromium`, `MATRIX_SPECS`, or browser-E2E + Node `TestServer`. +- Selection inspection requires at least 308 tests in at least 86 files (the + observed pre-retirement Rust floor), zero legacy projects, and zero unexplained + required specs intersecting `CLOUD_SKIP_SPECS`. The codex-binary-dependent + `mcp-qa-smoke-rust.spec.ts` remains explicitly local-only with a required + positive local receipt; cloud never substitutes for it. +- `gate01-baseline.json` remains frozen audit evidence, but its Node/Rust slice + runner, alternate config, collator, and collator test are deleted so there is no + executable path that can regenerate it by launching Node. +- Owned-server readiness proves `/api/server-info` runtime/provenance identifies + `freshell-server`; unauthenticated health alone is insufficient. + +- [ ] **Step 1: Write the failing behavioral test** + + Add `selection-nonvacuity.test.ts` to import local/cloud configs and fixture + factories, asserting the primary-project/literal-Rust contract across + chromium/firefox/webkit/continuity projects, the exact continuity-only + exclusion on match-all projects, positive floors, no browser legacy helper + import (including the visible-first audit runner), no unexplained cloud skip, + and a provenance failure when a fake healthy process reports a non-Rust + runtime. Require the mcp-qa skip to carry its local-only classification and + local test selector. Update browser helper tests to expect only `RustServer` + construction. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:e2e:helpers + npm exec playwright -- test --config test/e2e-browser/playwright.config.ts --project=chromium --list + ``` + + Expected: the helper test FAILS because the default fixture is `legacy` and + legacy projects/helpers still exist. The list command may already pass before + implementation; it is a non-starting baseline/selection receipt, not the RED + assertion. + +- [ ] **Step 3: Add the minimal implementation** + + Make Rust the only owned constructor, retain the external-target no-stop seam, + and move/rename shared types out of `test-server.ts`. Collapse the application + lane to a match-all `chromium` project and replace `RUST_ONLY_SPECS` on every + match-all project with an exact `continuity-smoke.spec.ts` exclusion; keep the + separately selected `continuity-smoke` project so no Rust-only spec disappears. + Convert conditional Rust branches to unconditional current-baseline assertions + and delete legacy-only expectations/spec registrations. + Build `dist/client` and `target/release/freshell-server` in global setup. Point + Electron remote-connect E2E and `perf:audit:visible-first`'s owned sample server + at `RustServer`. Move the oracle-only Node process constructor beside the oracle + and move its free-port/isolated-home imports to `server-fixture-support.ts`, so + deleting the browser `test-server.ts` does not break the intermediate commit. + Remove stale project/server comments from the third closed set. Delete the + completed GATE-01 executable/collator while retaining + its JSON as frozen historical evidence; update helper-config, teardown, and leak + comments/types to the new fixture names. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:e2e:helpers + npm exec playwright -- test --config test/e2e-browser/playwright.config.ts --project=chromium --list + ``` + + Expected: PASS; the explicitly selected output names `[chromium]`, reports at + least 308 tests in at least 86 files, and contains no `legacy-chromium` or + zero-test warning. Config inspection separately proves every retained project + is Rust-only. + +- [ ] **Step 5: Refactor while green** + + Rename matrix descriptions/comments to Rust-baseline language, deduplicate + owned/external server info types, and centralize exact-child stop/restart logic + in the Rust fixture. Preserve external-target non-ownership and add a test that + `stop()` never signals an external PID. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "legacy-chromium|e2eServerKind|TestServer|test-server\.js|dist/server/index|kind[[:space:]]*:[[:space:]]*['\"]legacy['\"]" test/e2e-browser test/e2e-electron --glob '!gate01-baseline.json' + npm run test:vitest -- run test/unit/port/oracle/external-handshake-t0.test.ts --config config/vitest/vitest.port.config.ts + npm run test:e2e -- --project=chromium test/e2e-browser/specs/auth.spec.ts test/e2e-browser/specs/terminal-lifecycle.spec.ts test/e2e-browser/specs/server-restart-recovery.spec.ts test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts + npm run test:e2e -- --project=chromium + ``` + + Expected: search returns no executable legacy path; the focused and full + Chromium project runs report positive counts and PASS, and server-info + provenance in every worker identifies the owned Rust binary on a non-3001 port. + The full Chromium run excludes only `continuity-smoke.spec.ts`; its specialized + project remains separately selected and is not silently dropped. + +- [ ] **Step 7: Commit the task** + + ```bash + git add test/e2e-browser test/setup/e2e-browser-global-setup.ts test/e2e-electron/electron-app.test.ts port/oracle/harness/external-server.ts port/oracle/harness/legacy-node-server.ts + git commit -m "test: make browser coverage Rust-only" + ``` + +### Task 5: Retire Dead Contracts and Rebase Active Port Oracles on Rust + +**Files:** + +- Modify: `shared/ws-protocol.ts` +- Modify: `crates/freshell-protocol/src/{client_messages,server_messages,common}.rs` +- Modify: `crates/freshell-protocol/tests/roundtrip.rs` +- Modify: `crates/freshell-ws/src/{terminal,reconcile}.rs` +- Modify: `crates/freshell-ws/tests/live_session_ref_guard.rs` +- Modify: `src/lib/api.ts` +- Delete: `src/store/freshAgentThunks.ts` +- Modify: `test/unit/client/lib/api.test.ts` +- Modify: `test/unit/client/lib/fresh-agent-ws.test.ts` +- Delete: `test/helpers/visible-first/protocol-harness.ts` +- Delete: `test/helpers/visible-first/read-model-route-harness.ts` +- Delete: `test/helpers/visible-first/terminal-mirror-fixture.ts` +- Delete: `test/unit/visible-first/protocol-harness.test.ts` +- Delete: `test/unit/visible-first/read-model-route-harness.test.ts` +- Delete: `test/unit/visible-first/terminal-mirror-fixture.test.ts` +- Modify: `test/unit/visible-first/acceptance-contract.test.ts` +- Modify: `port/contract/ws-message-inventory.json` +- Regenerate: `port/contract/ws-protocol.schema.json` +- Regenerate: `port/contract/ws-server-messages.schema.json` +- Delete: `port/contract/generate-manifest-oracle.ts` +- Modify: `port/contract/README.md` +- Modify: `crates/freshell-extensions/Cargo.toml` +- Modify: `crates/freshell-extensions/src/lib.rs` +- Modify: `crates/freshell-extensions/tests/oracle.rs` +- Delete: `port/oracle/baselines/batch/generate-batch-goldens.ts` +- Modify: `crates/freshell-terminal/tests/batch_wire_golden.rs` +- Modify: `port/oracle/harness/external-server.ts` +- Modify: `port/oracle/harness/normalize.ts` +- Modify: `port/oracle/harness/invariants.ts` +- Modify: `port/oracle/harness/t2-live.ts` +- Modify: `port/oracle/harness/t2-live-claude.ts` +- Modify: `port/oracle/harness/t2-live-codex.ts` +- Delete: `port/oracle/harness/legacy-node-server.ts` +- Delete: `port/oracle/harness/opencode-warm-proxy.ts` +- Delete: `port/oracle/baselines/pty/generate-pty-goldens.ts` +- Delete: `port/oracle/fixtures/generate-handshake-fixture.ts` +- Create: `test/unit/port/oracle/rust-only-oracle-boundary.test.ts` +- Modify: `test/unit/port/oracle/{external-handshake-t0,t0-equivalence-rust,t1-equivalence-rust,t1-batch-equivalence-rust,freshagent-wireshape-differential}.test.ts` +- Modify: `test/unit/port/oracle/{handshake-determinism-t0,pty-determinism-t1,t0-known-providers-discovery-rust}.test.ts` +- Modify: `test/unit/port/normalize.test.ts` +- Modify: `port/contract/nondeterministic-fields.md` +- Move: `test/unit/port/oracle/t2-opencode-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-opencode-rust-baseline.test.ts` +- Move: `test/unit/port/oracle/t2-claude-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-claude-rust-baseline.test.ts` +- Move: `test/unit/port/oracle/t2-codex-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-codex-rust-baseline.test.ts` +- Delete: `test/integration/port/oracle/{t2-claude-haiku,t2-codex-gptmini,t2-opencode-kimi}.test.ts` +- Modify: `config/vitest/vitest.oracle.config.ts` +- Delete: `config/vitest/vitest.oracle-t2.config.ts` +- Modify: `package.json` + +**Interfaces:** + +- `codingcli.create/input/kill` and `codingcli.created/event/exit/stderr/killed` + are absent from TS/Rust schemas, handlers, inventories, and generated schemas. +- `api.ts` no longer exports terminal viewport/paged-scrollback or paged + fresh-agent-turn helpers; no production caller exists. Whole-thread snapshots, + WS terminal replay, and terminal search remain. +- Client WS tests construct normalized Rust-baseline provider event frames + directly; they do not import Node SDK/OpenCode adapter implementations merely + to make test input. +- Oracle target selection is Rust-only. T0 asserts Rust schema conformance and + two-boot determinism; T1 asserts Rust bytes against committed goldens and keeps + mutation tests that prove comparisons bite; wire-shape checks compare current + Rust to a committed normalized Rust fixture with at least one captured frame. +- The gated T2 provider contracts have no `target`/warm-proxy switch and always + start an owned Rust server. They assert fatal lifecycle/persistence invariants, + positive event counts, request ceilings, isolated writes, and exact-child + teardown; they do not compare with or read the historical original-side T2 + JSON files. They prove ownership from their own PID ledger and never inspect, + connect to, or make assertions about a listener on port 3001. +- Those real-provider T2 contracts remain explicitly opt-in and may skip when + `FRESHELL_RUN_REAL_PROVIDER_CONTRACTS` is unset. They are useful supplemental + provider checks, not required replacement coverage for any deleted Node test; + always-running fake/provider-shape Rust tests own retirement closure. +- Historical reports/baselines stay untouched as provenance, but no active oracle + command can build or launch Node. +- `crates/freshell-extensions/fixtures/manifest-oracle.json` remains a frozen + migration artifact consumed by Rust mutation tests; its Node schema generator + and active regeneration claim are removed from the contract README, crate + metadata/docs, and oracle test. +- `port/oracle/baselines/batch/*.json` likewise remain frozen byte goldens for + `batch_wire_golden.rs`; the Node terminal-stream generator is deleted and the + Rust test's mutation assertion keeps the fixture non-vacuous. +- Handshake and PTY fixtures likewise become frozen Rust-baseline provenance; + their Node-default generators are deleted rather than silently retargeted to + Rust. Determinism/discovery tests name Rust explicitly, and active protocol + documentation removes the retired `codingcli.*` family. + +- [ ] **Step 1: Write the failing behavioral test** + + Tighten protocol/API tests to assert the dead discriminators and exports are + rejected/absent. Change T0/T1/wire-shape tests to request only an owned Rust + target, require a nonempty capture, compare two Rust boots or committed Rust + fixtures, and prove a one-field/one-byte mutation fails the comparator. Change + the visible-first acceptance test to require its focused lane to omit the + Node-backed protocol harness while retaining the static contract and report + tests. Tighten the Rust extension fixture test to require a nonempty fixture and + prove a changed expected verdict fails, without importing the deleted Node + manifest generator. Rewrite the client fresh-agent WS cases to feed literal + normalized Rust-baseline frames instead of importing Node provider adapters. + Delete the `acceptance-contract.test.ts` case that reads `package.json` and + pins the exact focused-lane script string; retain its behavioral contract + constant assertions and verify the real script by running it in Step 4. + Add `rust-only-oracle-boundary.test.ts` as an always-running source/exports + guard: it rejects a `node` target, warm-proxy module, legacy build command, or + active read of `port/oracle/baselines/t2/*.json` even when live-provider gates + are off. It also rejects the temporary oracle-local Node constructor and any + active handshake/PTY fixture generator, plus `listenersOn3001`/`ss`-based + inspection; an assertion + that an allocated owned port is not 3001 remains allowed. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/client/lib/api.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/visible-first/acceptance-contract.test.ts --config config/vitest/vitest.config.ts + npm run test:visible-first:contract + npm run test:vitest -- run test/unit/port --config config/vitest/vitest.port.config.ts + cargo test -p freshell-protocol -p freshell-ws -p freshell-terminal -p freshell-extensions --locked + env -u FRESHELL_RUN_REAL_PROVIDER_CONTRACTS npm run test:oracle + ``` + + Expected: FAIL because dead messages/helpers still parse/export and the oracle + still constructs a Node target or compares against a live original. + +- [ ] **Step 3: Add the minimal implementation** + + Delete the caller-free client helpers/thunks and the full `codingcli.*` family + in both languages, regenerate the committed schemas/inventory, and remove the + Rust no-op/guard handlers. Delete the three self-testing visible-first harnesses + that instantiate Node `WsHandler`, Express routes for removed endpoints, or the + Node terminal replay ring; update `test:visible-first:contract` to select only + `acceptance-contract.test.ts` and `visible-first-acceptance-report.test.ts` + through `npm run test:vitest -- run ... --config + config/vitest/vitest.config.ts`. + Make the external oracle harness wrap the existing owned Rust fixture, delete + its temporary `legacy-node-server.ts`, all Node build/spawn/copy logic, and + original-side live generators, and reframe + current tests around Rust determinism plus committed goldens/fixtures. Preserve + mutation tests and nonempty-capture assertions. Delete the Node extension + manifest generator and document its committed output as frozen migration + provenance rather than an active regeneration workflow. Delete the Node batch, + handshake, and PTY generators too and update consuming Rust tests/docs to call + those committed fixtures frozen provenance; keep byte/field-mutation bite + proofs. Update the explicitly listed determinism/discovery/normalize tests and + nondeterministic-field documentation so no Node default or `codingcli.*` + vocabulary remains active. Collapse each T2 harness to Rust-only owned + startup, delete the OpenCode warm proxy, rename the three gated tests to + Rust-baseline files, and replace original-fixture equality with invariant, + positive-event, isolation, cost-ceiling, and cleanup assertions. Keep the old + T2 JSON only as unreferenced historical evidence. Delete the original-side T2 + integration files, their dedicated config, and `test:oracle:t2`; the retained + Rust T2 contracts remain opt-in under `test:oracle`. Remove their snapshots of + port-3001 listeners; exact owned-PID teardown is the safety proof. + +- [ ] **Step 4: Run the focused GREEN command** + + Run the Step 2 commands again. + + The `test:visible-first:contract` command is executed as a real lane; no test + reads package.json merely to assert the command's spelling. + + Expected: PASS; schema generation has no drift, the Rust crates reject removed + messages, client exports are gone, and every active always-running oracle + starts/reaps only an owned Rust process on a non-3001 port. Opt-in T2 skips are + reported as supplemental and are not counted as replacement coverage. + +- [ ] **Step 5: Refactor while green** + + Rename `equivalence` descriptions to `Rust baseline` or `determinism`, extract a + single Rust oracle boot helper, and retain the smallest committed fixtures that + exercise each comparator. Do not rewrite historical Markdown/PNG/JSON evidence + merely for mentioning the original server. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "codingcli\.|getTerminalViewport|getTerminalScrollback|loadFreshAgent(ThreadTurns|TurnBody)" shared src crates/freshell-protocol crates/freshell-ws port/contract test/unit/port --glob '!oracle/rust-only-oracle-boundary.test.ts' + ! rg -n "target: ['\"]node|FRESHELL_ORACLE_TARGET|build:server|dist/server/index|new TestServer|warmProxy|opencode-warm-proxy|baselines/t2" port/oracle/harness config/vitest/vitest.oracle.config.ts test/unit/port/oracle package.json --glob '!rust-only-oracle-boundary.test.ts' + ! rg -n "listenersOn3001|ss .*3001|grep.*3001" port/oracle/harness test/unit/port/oracle --glob '!rust-only-oracle-boundary.test.ts' + ! rg -n "server/extension-manifest|generate-manifest-oracle" port/contract package.json + ! rg -n "server/terminal-stream|generate-batch-goldens" port/oracle/baselines/batch crates/freshell-terminal/tests/batch_wire_golden.rs + ! rg -n "legacy-node-server|generate-(pty-goldens|handshake-fixture)|target: ['\"]node" port/oracle test/unit/port port/contract --glob '!oracle/rust-only-oracle-boundary.test.ts' + contract_hash_before="$(sha256sum port/contract/ws-message-inventory.json port/contract/ws-protocol.schema.json port/contract/ws-server-messages.schema.json)" + npm run contract:generate + contract_hash_after="$(sha256sum port/contract/ws-message-inventory.json port/contract/ws-protocol.schema.json port/contract/ws-server-messages.schema.json)" + test "$contract_hash_before" = "$contract_hash_after" + ``` + + Expected: all searches return no active match; generation completes and the + generated files are already up to date. + +- [ ] **Step 7: Commit the task** + + ```bash + git add shared/ws-protocol.ts crates/freshell-protocol crates/freshell-ws crates/freshell-terminal/tests/batch_wire_golden.rs crates/freshell-extensions/Cargo.toml crates/freshell-extensions/src/lib.rs crates/freshell-extensions/tests/oracle.rs src/lib/api.ts src/store/freshAgentThunks.ts test/unit/client/lib/api.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/helpers/visible-first test/unit/visible-first port/contract port/oracle test/unit/port test/integration/port/oracle config/vitest/vitest.oracle.config.ts config/vitest/vitest.oracle-t2.config.ts package.json + git commit -m "refactor: retire Node-only contracts and oracles" + ``` + +### Task 6: Make Source Build, Start, and Broad Tests Rust-First and Non-Vacuous + +**Files:** + +- Create: `scripts/start-rust-server.ts` +- Create: `scripts/testing/run-rust-tests.ts` +- Create: `scripts/testing/run-source-runtime-tests.ts` +- Create: `config/vitest/vitest.runtime.config.ts` +- Create: `test/unit/tooling/testing/test-selection.test.ts` +- Create: `test/integration/tooling/source-runtime-rust.test.ts` +- Modify: `package.json` +- Modify: `scripts/launch.sh` +- Modify: `scripts/launch-rust.sh` +- Modify: `run-rust-server.sh` +- Modify: `port/laptop-bootstrap/2-bootstrap-wsl.sh` +- Modify: `scripts/run-standard-tests.ts` +- Modify: `scripts/testing/coordinator-command-matrix.ts` +- Modify: `scripts/testing/test-coordinator.ts` +- Modify: `scripts/vitest-cloud.sh` +- Modify: `scripts/test/cloud-vitest-wrapper.test.sh` +- Modify: `scripts/test/cloud-vitest-entrypoint.test.sh` +- Modify: `docker/cloud-run/entrypoint.sh` +- Modify: `config/vitest/vitest.config.ts` +- Modify: `test/unit/vite-config.test.ts` +- Delete: `config/vitest/vitest.server.config.ts` +- Delete: `config/vitest/vitest.codex-real-provider-smoke.config.ts` +- Delete: `config/vitest/vitest.opencode-serve-real-provider-smoke.config.ts` +- Delete: `test/setup/server-global-setup.ts` +- Delete: `tsconfig.server.json` +- Delete: `test/integration/real/codex-app-server-fork-shape-contract.test.ts` +- Delete: `test/integration/real/codex-app-server-readiness-contract.test.ts` +- Delete: `test/integration/real/codex-remote-fork-contract.test.ts` +- Delete: `test/integration/real/coding-cli-session-contract.test.ts` +- Delete: `test/helpers/coding-cli/real-session-contract-harness.ts` +- Delete: `test/integration/extension-system.test.ts` +- Move: retained files from `test/unit/server/claude-sidecar/**` to `test/unit/claude-sidecar/**` +- Move: retained coordinator/global-setup tests from `test/unit/server/testing/**` to `test/unit/tooling/testing/**` +- Create: `test/unit/shared/title-utils.test.ts` from the shared + `extractTitleFromMessage` subject in `test/unit/server/title-utils.test.ts` +- Move: `test/unit/server/tabs-registry/types.test.ts` to + `test/unit/shared/tab-registry-types.test.ts` +- Modify: `test/unit/server/title-utils.test.ts` to leave only the + backend-owned JSONL extraction subject for Task 10 disposition +- Move: `test/unit/server/deploy-tab-diff-coverage-gate.test.ts` to `test/unit/tooling/deploy-tab-diff-coverage-gate.test.ts` +- Move: `test/unit/server/prebuild-guard.test.ts` to `test/unit/tooling/prebuild-guard.test.ts` +- Move: `test/unit/server/run-standard-tests.test.ts` to `test/unit/tooling/run-standard-tests.test.ts` +- Move: `test/unit/server/opencode-rebind-plugin.test.ts` to `test/unit/extensions/opencode-rebind-plugin.test.ts` +- Move: `test/unit/server/rust-claude-snapshot-contract.test.ts` to `test/unit/contracts/rust-claude-snapshot-contract.test.ts` +- Move: `test/unit/server/amplifier-cli-isolation.test.ts` to `test/unit/provider-fixtures/amplifier-cli-isolation.test.ts` +- Modify: `crates/freshell-tauri/tests/server_spawn_smoke.rs` +- Modify: `.github/workflows/rust-clippy.yml` + +**Interfaces:** + +- `dev:server` runs `cargo run -p freshell-server --locked`; `dev` runs Vite plus + that Rust server. `build:rust`, `check:rust`, and `test:rust` are explicit. + `build` produces client, tools, and release `freshell-server`; `start` executes + the release Rust binary through the cross-platform signal-forwarding script. +- `scripts/launch.sh` is a compatibility forwarder to the safe Rust launcher; + `launch-rust.sh` remains canonical and exact-PID verified. Root + `run-rust-server.sh` no longer advertises the Node command, and the retained + laptop bootstrap invokes the Rust-inclusive build/start contract rather than + inheriting a Node-server build path. +- Broad `npm test`/`npm run check`/`npm run verify` cover retained default Vitest, + an artifact-owning source-runtime phase, `cargo test --workspace --locked`, and + Electron Vitest under one coordinator gate. `test:server` runs the + `freshell-server` crate; `test:integration` runs + `cargo test --workspace --tests --locked`; `test:unit` remains default + `test/unit`. +- No required runner uses `--passWithNoTests`. Cloud Vitest runs only the retained + default config; `--config=server` is rejected with exit 2 and a Rust-lane hint. + Cargo runs in the Rust lane. +- Default Vitest explicitly excludes `test/integration/tooling/**` and + `test/integration/electron/**`. `vitest.runtime.config.ts` includes only the + source-runtime integration tree and rejects zero selection. The + `test:source-runtime` wrapper builds `dist/client`, `dist/tools`, and release + `freshell-server` before running that config. Thus the Node-only + `typecheck-client.yml` default lane never inherits Rust/artifact prerequisites, + while the broad coordinator still owns the source runtime smoke explicitly. +- The default config stops excluding + `test/unit/visible-first/cli-command-harness.test.ts` and its selection is + asserted. Its two obsolete Node route/mirror siblings were deleted in Task 5. +- Tauri `server_spawn_smoke` hard-fails when the explicit/sibling Rust binary is + absent; `run-rust-tests.ts` builds it and sets `FRESHELL_SERVER_BIN` before the + workspace tests. +- `source-runtime-rust.test.ts` spawns `npm start` on an OS-assigned non-3001 + port with an isolated `FRESHELL_HOME`, explicit test-only `AUTH_TOKEN`, and + absolute built-client path, then requires the SPA response and authenticated + server-info provenance to identify the exact release + `freshell-server` child before exact-PID teardown. It uses + `// @vitest-environment node` because it owns a child process and filesystem + fixture. +- Every retained subject formerly under `test/unit/server/**` is re-homed before + this task removes the server Vitest config. The current subject inventory + explicitly splits the shared `extractTitleFromMessage` cases into + `test/unit/shared/title-utils.test.ts` and moves the tab-registry schema test to + `test/unit/shared/tab-registry-types.test.ts`; backend-only JSONL title parsing + remains a Task 10 deletion candidate. Any additional retained subject found by + the inventory must be moved in this task or it blocks config deletion. + +- [ ] **Step 1: Write the failing behavioral test** + + Add `test-selection.test.ts` and update coordinator/runner tests to require + client+Rust+Electron broad phases, the script meanings above, absence of the + server/real-provider Vitest configs and `--passWithNoTests`, removal of their + now-invalid package scripts, and rejection of a simulated zero selected-test + result. Require the retained visible-first CLI harness to be selected by the + default lane and both artifact-dependent integration trees to be excluded from + it. Require the dedicated runtime config/wrapper to select the source smoke and + the broad coordinator to execute that phase. Require the closed runtime manifest to reconcile root launchers + and `port/**` bootstrap owners. Require the subject inventory to report no + retained implementation owner left under `test/unit/server/**`, including the + title-utils split and tab-registry schema move. Add the owned source-runtime integration test + described above. Change the Tauri smoke unit path to panic, not print SKIP, + when no binary can be resolved. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run build:client + npm run build:server + npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling/run-standard-tests.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/integration/tooling/source-runtime-rust.test.ts --config config/vitest/vitest.runtime.config.ts + bash scripts/test/cloud-vitest-wrapper.test.sh + cargo test -p freshell-tauri --locked --test server_spawn_smoke app_bound_spawn_health_reap_end_to_end -- --exact --nocapture + ``` + + Expected: FAIL because current plans are client+server+Electron Vitest, use + `--passWithNoTests`, and the Tauri smoke can soft-skip. + +- [ ] **Step 3: Add the minimal implementation** + + Move retained non-server tests before removing exclusions/config. Split + `test/unit/server/title-utils.test.ts` by subject, moving only the + `extractTitleFromMessage` cases to `test/unit/shared/title-utils.test.ts`; keep + backend-owned `extractTitleFromJsonlObject` cases recorded for Task 10 deletion. + Move `test/unit/server/tabs-registry/types.test.ts` to + `test/unit/shared/tab-registry-types.test.ts`. The subject-level inventory must + then show no additional retained owner under `test/unit/server/**` before this + task removes the server config. Implement the + Rust phases and source scripts, delete server TypeScript build/typecheck/start + scripts/config/global setup, and make cloud Vitest one truthful default-config + lane. Exclude artifact-dependent integration trees from default discovery; + create the source-runtime-only config and prerequisite-owning wrapper, and add + that wrapper as a positive-count broad phase. Update both cloud wrapper and + cloud entrypoint shell tests to reject, rather than require, + `--passWithNoTests`. Delete the four opt-in provider contracts and PTY harness that import the + legacy Codex/Claude/OpenCode runtime; they test external-provider or Node + implementation behavior, not Freshell's retained Rust backend. Delete the two + dedicated Node-backend real-provider configs/scripts; keep the two independent + Amplifier contracts excluded and opt-in. The start wrapper resolves `.exe` on + Windows, forwards argv/signals, + inherits stdio, emits structured JSONL only for wrapper errors, and never + backgrounds or kills an unowned PID. Update root `run-rust-server.sh` and the + retained laptop bootstrap to the same Rust-only build/start contract and + reclassify their manifest rows. Add the explicit build+env Tauri test + wrapper and matching CI step. Delete the Node-only extension-system integration + from the default lane; current Rust extension crate/browser coverage is the + baseline. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run build:client + npm run build:tools + cargo build --release -p freshell-server --locked + npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling test/unit/claude-sidecar test/unit/contracts test/unit/provider-fixtures test/unit/shared/title-utils.test.ts test/unit/shared/tab-registry-types.test.ts test/unit/visible-first/cli-command-harness.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:source-runtime + bash scripts/test/cloud-vitest-wrapper.test.sh + cargo build -p freshell-server --locked + FRESHELL_SERVER_BIN="$PWD/target/debug/freshell-server" cargo test -p freshell-tauri --locked --test server_spawn_smoke app_bound_spawn_health_reap_end_to_end -- --exact --nocapture + cargo test -p freshell-codex --features real-transport --locked + cargo test -p freshell-opencode --features real-transport --locked + ``` + + Expected: PASS; the Tauri smoke starts/reaps the exact binary on an ephemeral + port, and no required test selector is empty. + +- [ ] **Step 5: Refactor while green** + + Extract typed phase builders for `vitest|cargo|npm`, centralize structured + child-process logging, and keep Cargo argument routing separate from Vitest file + filters. Remove old `client|server|electron` naming from status receipts and + make zero-selection errors include the requested selectors and selected phase. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "vitest\.(server|codex-real-provider-smoke|opencode-serve-real-provider-smoke)|server-global-setup|tsconfig\.server|tsx watch server|dist/server/index|--passWithNoTests|test:real:coding-cli-contracts|test:codex-real-provider-smoke|test:opencode-serve-smoke" package.json config scripts run-rust-server.sh port/laptop-bootstrap docker/cloud-run test/setup test/unit/tooling .github/workflows/rust-clippy.yml + test ! -f tsconfig.server.json + test ! -f config/vitest/vitest.server.config.ts + test ! -f test/setup/server-global-setup.ts + npm run typecheck + FRESHELL_TEST_SUMMARY="retire Node server: Rust broad gate" npm test + ``` + + Expected: search returns no match; absence checks succeed; typecheck and the + coordinated broad test PASS with nonzero retained Vitest, source-runtime, + Rust workspace, and Electron phase counts. + +- [ ] **Step 7: Commit the task** + + ```bash + git add -A package.json package-lock.json scripts run-rust-server.sh port/laptop-bootstrap/2-bootstrap-wsl.sh config/vitest test/setup test/unit test/integration test/helpers crates/freshell-tauri .github/workflows/rust-clippy.yml tsconfig.server.json docker/cloud-run/entrypoint.sh + git commit -m "build: make Rust the default server and test lane" + ``` + +### Task 7: Cut Electron App-Bound Lifecycle Over to Rust and Retire Dead Daemon Mode + +**Files:** + +- Modify: `electron/server-spawner.ts` +- Modify: `electron/startup.ts` +- Modify: `electron/entry.ts` +- Modify: `electron/{types,desktop-config,launch-policy,preload}.ts` +- Modify: `electron/setup-wizard/wizard-logic.ts` +- Modify: `electron/setup-wizard/wizard.tsx` +- Delete: `electron/daemon/**` +- Delete: `installers/systemd/freshell.service.template` +- Delete: `installers/launchd/com.freshell.server.plist.template` +- Delete: `installers/windows/freshell-task.xml.template` +- Modify: `config/electron-builder.yml` +- Modify: `test/unit/electron/{server-spawner,startup,desktop-config,launch-policy,preload}.test.ts` +- Modify: `test/unit/electron/setup-wizard/wizard.test.tsx` +- Delete: `test/unit/electron/daemon/**` +- Create: `test/e2e-electron/app-bound-rust-server.test.ts` +- Modify: Electron tests/fixtures whose config union currently names `daemon` + +**Interfaces:** + +- Electron's supported `ServerMode` is `app-bound | remote`. The setup wizard no + longer advertises “Always-running daemon,” startup creates no daemon manager, + and packaged resources contain no Electron-owned launchd/systemd/Task + Scheduler templates. A persisted `serverMode: "daemon"` is migrated once to + `app-bound`, written back atomically, and surfaced through a clear structured + migration notice; all other persisted fields remain unchanged. +- `ServerSpawnResources` contains `serverBinary`, `clientDir`, + `claudeNodeBinary`, `claudeSidecarEntry`, `mcpNodeBinary`, `mcpEntry`, + `homeDir`, `configDir`, and `logDir`. No `nodeBinary`, `serverEntry`, native + modules, server modules, or `NODE_PATH` exists. Startup derives `homeDir` as + the parent of its existing absolute `configDir` and rejects a config directory + whose basename is not `.freshell`; `logDir` is `configDir/logs`. +- App-bound spawn env sets `PORT`, `FRESHELL_HOME`, `FRESHELL_CLIENT_DIR`, + `FRESHELL_CLAUDE_NODE`, `FRESHELL_CLAUDE_SIDECAR`, `FRESHELL_MCP_NODE`, and + `FRESHELL_MCP_ENTRY`; `FRESHELL_HOME` is exactly `homeDir`. The child working + directory is exactly `configDir`, so Rust loads `AUTH_TOKEN` from the existing + `.env`; token values are never logged. Dev uses + `target/debug/freshell-server`; packaged mode uses + `resources/bin/freshell-server[.exe]`. Readiness verifies authenticated + server-info provenance. +- App-bound ownership is the exact `ChildProcess` returned by spawn. Close/error + handlers clear that reference. Stop signals only that child, waits to a fixed + first deadline, escalates only that same PID, waits to a second fixed deadline, + and reports failure if it is still alive. No path/command-line scan or broad + kill is permitted. “Stopped” means the owned backend process exited; this task + adds no descendant-survival or restart-continuity guarantee. +- `installers/systemd/freshell-rust.service` remains the supported standalone + Rust service and is not an Electron daemon resource. + +- [ ] **Step 1: Write the failing behavioral test** + + Change spawner/startup/config/wizard tests to require the exact Rust command + and env, reject every Node-server field, reject daemon as a new configuration, + and prove a persisted daemon value migrates atomically to app-bound. Add + lifecycle tests with two same-path fake server processes: stopping Electron + reaps only its captured child, clears the reference on close/error, waits after + escalation, and reports a second-deadline failure. Add app-bound E2E that + launches Electron with staged Rust/MCP/Claude fixtures, authenticates, verifies + server-info runtime/commit, exits the app, and proves the exact Rust child is + gone while the foreign same-path process remains. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:electron -- test/unit/electron/server-spawner.test.ts test/unit/electron/startup.test.ts test/unit/electron/desktop-config.test.ts test/unit/electron/launch-policy.test.ts test/unit/electron/setup-wizard/wizard.test.tsx test/unit/electron/daemon + ``` + + Expected: FAIL because Electron currently plans bundled Node plus + `resources/server/index.js`, advertises daemon mode, constructs a daemon + manager, and Windows daemon stop can target a foreign same-path process. + +- [ ] **Step 3: Add the minimal implementation** + + Replace the spawn/resource types atomically, invoke the Rust binary with no + server script argument, set only the explicit Rust/client/MCP/Claude env, and + preserve cwd, redacted JSONL log piping, health timeout, and double-start + handling. Implement exact captured-child bounded stop. Remove daemon from the + schema/wizard/startup/IPC surface, migrate persisted daemon config to + app-bound, delete `electron/daemon/**` and its three templates/tests, and remove + those resources from electron-builder. Dev startup requires the Task 6 debug + build; it never falls back to tsx/Node backend. + +- [ ] **Step 4: Run the focused GREEN command** + + Run the Step 2 command without the now-deleted `test/unit/electron/daemon` + selector. + + Expected: PASS; every captured backend command begins with + `freshell-server[.exe]`, required env paths are absolute, exact-child stop is + bounded, daemon config migrates, and daemon cannot be newly selected. + +- [ ] **Step 5: Refactor while green** + + Extract `resolveDesktopRuntimeResources(resourcesPath, platform, isDev)` as a + pure app-bound function and a reusable exact-child wait helper. Keep process + identity tied to the spawn handle, preserve paths-with-spaces cases on every + platform, and ensure lifecycle/migration logs are redacted structured JSONL. + Remove dead daemon-only preload/launch-policy branches and fixtures. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "server/index|NODE_PATH|server-node-modules|nativeModules|nodeBinary|serverEntry" electron + ! rg -n "serverMode.*daemon|Always-running daemon|createDaemonManager|electron/daemon|freshell\.(service\.template|task\.xml)|com\.freshell\.server" electron config/electron-builder.yml + test ! -d electron/daemon + test ! -e installers/systemd/freshell.service.template + test -f installers/systemd/freshell-rust.service + cargo build -p freshell-server --locked + npm run build:electron + npm run test:e2e:electron -- test/e2e-electron/app-bound-rust-server.test.ts + ``` + + Expected: search and absence checks PASS; the standalone Rust service remains; + Electron build/E2E authenticate to a non-3001 owned `freshell-server`, stop + that backend PID exactly, and leave the foreign same-path fixture alive until + the fixture performs its own exact cleanup. + +- [ ] **Step 7: Commit the task** + + ```bash + git add -A electron installers config/electron-builder.yml test/unit/electron test/e2e-electron + git commit -m "feat: run Electron app-bound backend in Rust" + ``` + +### Task 8: Package the Rust Server and Only Sanctioned Node Runtimes in Electron + +**Files:** + +- Create: `scripts/prepare-electron-runtime.ts` +- Create: `scripts/verify-electron-artifact.ts` +- Create: `test/unit/electron/prepare-electron-runtime.test.ts` +- Create: `test/unit/electron/verify-electron-artifact.test.ts` +- Create: `test/integration/electron/checkout-free-runtime.test.ts` +- Create: `config/vitest/vitest.electron-runtime.config.ts` +- Modify: `scripts/prepare-bundled-node.ts` by extracting reusable Node-download code, then delete it +- Modify: `scripts/bundled-node-version.json` +- Modify: `scripts/assert-native-windows-build.ts` +- Modify: `config/electron-builder.yml` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `.gitignore` +- Delete after migration: generated/staging assumptions for `server-node-modules` and `bundled-node/native-modules` +- Delete/replace: `test/unit/electron/prepare-bundled-node.test.ts` + +**Interfaces:** + +- `prepare-electron-runtime --platform --arch ` + stages `electron-runtime/bin/freshell-server[.exe]`, `dist/client`, + `electron-runtime/node/bin/node[.exe]`, + `electron-runtime/claude-sidecar/**`, and + `electron-runtime/mcp/**`. The MCP directory contains + `dist/tools/freshell-mcp`, shared compiled client modules, and only the locked + production dependency closure for `@modelcontextprotocol/sdk` and `zod`, plus + a minimal `package.json` whose `name` is `freshell` and whose version matches + the packaged release. The metadata is required because the checkout-free MCP + entry reports its version by walking to package metadata. +- The Node binary is sanctioned for the Claude sidecar and standalone MCP client + only. Staging contains no `node-pty`, Freshell Node backend entrypoint, + `dist/server`, `server-node-modules`, or native-module rebuild output. The MCP + SDK's locked closure may include dormant HTTP-framework libraries such as + Express; structural and execution tests prove that the stdio MCP entrypoint + never listens or becomes Freshell's backend. +- Node archive extraction retains the existing locked `extract-zip` and `tar` + libraries, their integrity checks, and cross-platform error handling. The + retirement does not introduce a host-`tar` prerequisite merely to remove the + Node backend; failures emit redacted structured JSONL context. +- `verify-electron-artifact(path, platform)` fails unless the native Rust binary, + client index, MCP entry/dependencies, Claude entry/dependencies, and Node runtime + exist; it fails on any forbidden artifact or if the Rust binary cannot be + executed on the native host. Its bounded execution probe uses an empty temporary + cwd, removes `AUTH_TOKEN`, `.env` discovery, and inherited Freshell config env, + and requires exit code 1 plus + `AUTH_TOKEN is required. Refusing to start without authentication.` before any + listen event; it never starts a listening service. Foreign-platform + artifacts receive structural format checks locally and the native CI matrix + performs the execution probe. +- `electron:build`/`:win` build the host-native Rust server and tools, stage the + runtime, package, and verify the unpacked artifact before installers upload. +- `checkout-free-runtime.test.ts` copies the staged runtime to a temporary root + outside the checkout, runs with empty cwd/`NODE_PATH` and no root + `node_modules`, authenticates to Rust server-info, fetches the SPA plus a real + hashed asset, exercises the fake-Claude hook, speaks stdio JSON-RPC to the + compiled MCP entry with no listening socket, and reaps every exact owned child. +- `vitest.electron-runtime.config.ts` includes only + `test/integration/electron/**`, uses the Node environment, and rejects zero + selection. `test:electron:runtime` requires the producer-owned staged runtime + and runs that config; default Vitest continues to exclude this artifact-bound + tree. `electron-runtime/` is ignored as generated staging output. + +- [ ] **Step 1: Write the failing behavioral test** + + Add staging and artifact tests with an injected binary-probe runner and temporary + fake resource tree. Require the exact allowlist, assert each forbidden name + fails verification, and assert the probe runs in an empty cwd with auth/config + env removed and a deadline. Add the checkout-free acceptance test above, with + deliberate failures when it can see checkout files/root `node_modules`, MCP + writes non-JSON-RPC stdout, or any owned PID survives. Change the Windows + platform check message to require native Rust `.exe` production, not native + `node-pty` compilation. Assert the dedicated config selects this integration + test and the default config does not; assert the staging directory is ignored, + the staged MCP package metadata is present, and the checkout-free MCP + initialize response reports the staged package version rather than `0.0.0`. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:electron -- test/unit/electron/prepare-electron-runtime.test.ts test/unit/electron/verify-electron-artifact.test.ts test/unit/electron/native-windows-build-script.test.ts + npm run test:vitest -- run test/integration/electron/checkout-free-runtime.test.ts --config config/vitest/vitest.electron-runtime.config.ts + ``` + + Expected: FAIL because staging/verifier modules do not exist and builder config + still requires Node-server/native-module resources; the dedicated runtime + config/script is not implemented yet. + +- [ ] **Step 3: Add the minimal implementation** + + Refactor the verified Node download to the new staging script, delete header and + `node-pty` rebuild/pruned-server-dependency logic, copy the host-native Cargo + binary, build/copy `dist/tools`, and stage the two permitted Node consumers with + their locked dependency closures. Write the staged MCP `package.json` with the + release's `name: freshell` and version metadata so its initialize response is + stable outside a checkout. Preserve the locked archive libraries and + extraction checks. Rewrite electron-builder resources and npm Electron scripts + to use the staging directory and invoke the verifier on the unpacked result; + package only app-bound resources, with no Electron daemon templates. Add the + isolated Electron-runtime Vitest config/script and ignore generated + `electron-runtime/` staging. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:electron -- test/unit/electron/prepare-electron-runtime.test.ts test/unit/electron/verify-electron-artifact.test.ts test/unit/electron/native-windows-build-script.test.ts + npm run build:client + npm run build:tools + cargo build --release -p freshell-server --locked + npm run prepare:electron-runtime + npm run test:electron:runtime + ``` + + Expected: PASS; staging contains every allowlisted resource and none of the + forbidden Node-server/native-module paths, and the copied runtime works without + checkout or root dependency access. + +- [ ] **Step 5: Refactor while green** + + Split pure layout planning, dependency-closure calculation, and filesystem copy + execution. Add stable sorted JSONL receipts with file hashes and `severity` but + no tokens. Make the verifier share the same declarative allowlist without + allowing the producer to suppress forbidden-file checks. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "dist/server|server-node-modules|node-pty|native-modules|prepare-bundled-node" config/electron-builder.yml scripts package.json --glob '!verify-electron-artifact.ts' --glob '!prepare-electron-runtime.ts' + git check-ignore electron-runtime/ + npm run electron:build + npm run verify:electron-artifact + npm run test:electron:runtime + ``` + + Expected: search returns no match; the native host build/verification PASS and + reports a runnable `freshell-server`, client, MCP, and Claude sidecar, with zero + forbidden artifacts. This command does not launch or deploy a server. + +- [ ] **Step 7: Commit the task** + + ```bash + git add scripts/prepare-electron-runtime.ts scripts/verify-electron-artifact.ts scripts/assert-native-windows-build.ts scripts/bundled-node-version.json config/electron-builder.yml config/vitest/vitest.electron-runtime.config.ts package.json package-lock.json .gitignore test/unit/electron test/integration/electron + git add -u scripts/prepare-bundled-node.ts + git commit -m "build: package Rust backend in Electron" + ``` + +### Task 9: Make Containers, CI, and Release Artifacts Rust-Only + +**Files:** + +- Modify: `examples/docker/Dockerfile` +- Modify: `docker/cloud-run/Dockerfile` +- Modify: `docker/cloud-run/entrypoint.sh` +- Modify: `docker/cloud-run/test-durations.txt` +- Modify: `.github/workflows/rust-clippy.yml` +- Modify: `.github/workflows/typecheck-client.yml` +- Modify: `.github/workflows/electron-build.yml` +- Modify: `.github/workflows/electron-release.yml` +- Create: `test/unit/tooling/distribution-runtime.test.ts` +- Create: `scripts/verify-container-layout.sh` +- Create: `test/fixtures/distribution/rust-only/**` +- Create: `test/fixtures/distribution/node-server/**` + +**Interfaces:** + +- The example image is a Rust server + built client example and no longer claims + Node-only extension lifecycle support. Its final command is + `/app/freshell-server`; Node is present at runtime only when the staged Claude + sidecar/MCP client is included and is never the container entrypoint. +- The Cloud E2E image builds/copies `freshell-server`, `dist/client`, and + `dist/tools`; it does not compile/copy `dist/server` or install native build + prerequisites for `node-pty`. Until Task 10 removes the legacy dependencies + from the root lock, its Node tooling stage uses `npm ci --ignore-scripts` and a + declared removal/assertion step for the exact Task 10 backend-only dependency + directories before copying `node_modules`; the final image contains none of + them. The intermediate E2E image still contains the tracked legacy source so + the runtime-boundary test observes the same tree as local Vitest; Task 11 + rebuilds after Task 10 and proves that source is absent from final images. +- Required CI runs `cargo fmt`, clippy including real-transport feature lanes, + `cargo build -p freshell-server`, and `cargo test --workspace --locked` with + `FRESHELL_SERVER_BIN` set for the non-skipping Tauri smoke. Retained Vitest and + Electron tests have required jobs: `typecheck-client.yml` runs client + typecheck plus the nonempty default Vitest lane, whose config explicitly + excludes artifact-dependent integration trees; `rust-clippy.yml` owns Cargo + plus the prerequisite-owning source-runtime smoke; and `electron-build.yml` + runs Electron unit tests, stages the artifact, then runs the isolated + checkout-free Electron runtime lane on every matrix OS. +- Electron build/release matrix installs Rust 1.96.0, builds the native server, + verifies each unpacked artifact, runs the checkout-free authenticated runtime + acceptance (server-info, SPA asset, PTY creation/I/O, fake Claude, stdio MCP, + exact cleanup), and uploads only verified installers. Required PR checks own + this proof on `macos-15-intel`, `macos-latest`, `ubuntu-latest`, and + `windows-2022`; the plan does not add a branch-only dispatch path. + +- [ ] **Step 1: Write the failing behavioral test** + + Add `distribution-runtime.test.ts` to parse Dockerfiles/workflows and require + Rust entrypoints/build/test jobs, Electron `crates/**` path triggers, + the four-target required native acceptance, artifact verification, and absence + of Node-server build or artifact names. Require the typecheck workflow's + default lane to exclude artifact integrations, the Rust job to run the source + runtime wrapper, and Electron jobs to stage before the dedicated runtime lane. + Add `verify-container-layout.sh` + fixture tests that fail a staged `dist/server/index.js` and accept the + Rust/client/tools layout. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/tooling/distribution-runtime.test.ts --config config/vitest/vitest.config.ts + ``` + + Expected: FAIL because the example CMD is Node, Cloud Docker builds + `dist/server`, CI lacks workspace Cargo tests, and Electron workflows lack Rust + prerequisites/artifact verification. + +- [ ] **Step 3: Add the minimal implementation** + + Convert both container builds/entrypoints, remove server Vitest vocabulary and + `--passWithNoTests` from cloud execution, and make empty discovery a hard + failure. Make the cloud Node stage ignore lifecycle scripts, remove and assert + absence of the explicit backend-only dependency directories before runtime + copy; Task 10's lockfile pruning makes that transitional removal a no-op. Add + the Cargo test job and native Electron Rust setup/build/verify steps. Expand + Electron path filters to `crates/**`, `Cargo.toml`, `Cargo.lock`, tools, and + runtime scripts. Run Task 8's checkout-free acceptance against the unpacked + native artifact in every matrix job, including an authenticated PTY round trip + and exact cleanup. Run Task 6's source-runtime wrapper in the Rust job after + its explicit build; do not add Rust/artifact prerequisites to the default + typecheck-client Vitest job. Keep the permitted Node test/browser/MCP/Claude runtimes + explicit in comments and image checks. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:vitest -- run test/unit/tooling/distribution-runtime.test.ts --config config/vitest/vitest.config.ts + bash scripts/verify-container-layout.sh --fixture test/fixtures/distribution/rust-only + docker build --tag freshell-retire-node-server-v2-cloud --file docker/cloud-run/Dockerfile . + docker build --tag freshell-retire-node-server-v2-example --file examples/docker/Dockerfile . + docker image inspect freshell-retire-node-server-v2-cloud --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' + docker image inspect freshell-retire-node-server-v2-example --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-cloud -c 'test -x /app/target/release/freshell-server && test -f /app/dist/client/index.html && test -f /app/dist/tools/freshell-mcp/server.js && test ! -e /app/dist/server && test ! -e /app/node_modules/node-pty' + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-example -c 'test -x /app/freshell-server && test -f /app/dist/client/index.html && test ! -e /app/dist/server && test ! -e /app/node_modules' + ``` + + Expected: PASS; the example image command is `/app/freshell-server`; the cloud + image retains only its E2E shard entrypoint, whose Rust-only fixture contract is + asserted by `distribution-runtime.test.ts`. The two non-server container probes + find the required Rust/client/tool artifacts with no `dist/server`. Neither + probe starts Freshell or binds a port. + +- [ ] **Step 5: Refactor while green** + + Reuse the artifact forbidden/required-name list in container and Electron + verification, pin Rust/Node versions in one documented workflow location, and + make shell verifier diagnostics structured JSONL with `severity`, `event`, and + sorted path evidence. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "node dist/server|build:server|dist/server|server-node-modules|node-pty|vitest\.server|--passWithNoTests" examples/docker docker/cloud-run .github/workflows + cargo fmt --all --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo test --workspace --locked + ``` + + Expected: search returns no match and all local Rust checks PASS. The Tauri + smoke output names the explicit built server and contains no SKIP. + +- [ ] **Step 7: Commit the task** + + ```bash + git add examples/docker docker/cloud-run .github/workflows test/unit/tooling/distribution-runtime.test.ts scripts/verify-container-layout.sh test/fixtures/distribution + git commit -m "ci: enforce Rust-only backend artifacts" + ``` + +### Task 10: Delete the Legacy Node Backend, Tests, Scripts, and Dependencies + +**Files:** + +- Delete: `server/**` +- Delete: `test/server/**` +- Delete: remaining `test/unit/server/**` +- Delete: `test/integration/server/**` +- Delete: `test/integration/session-repair.test.ts` +- Delete: backend-only remainder of `test/unit/server/title-utils.test.ts` after + the shared `extractTitleFromMessage` subject is re-homed in Task 6 +- Modify: `test/unit/architecture/fresh-agent-only-runtime.test.ts` +- Delete: `test/helpers/coding-cli/fake-codex-launch-planner.ts` +- Delete: `test/fixtures/fresh-agent/claude/thread.ts` +- Delete: `scripts/{find-corrupted,repair-one,repair-all}.ts` +- Delete: `scripts/proofs/terminal-catchup-pty-metrics.ts` +- Delete: `port/oracle/interchange/*.mjs` +- Delete: `port/oracle/matrix/*.mjs` +- Delete: `port/oracle/rest-parity/sweep.mjs` +- Delete: `port/oracle/robustness/kill-probe.mjs` +- Delete: `port/oracle/indexer/{sd-probe.mjs,seed.sh}` +- Delete: `port/oracle/t3/{gen-summary.mjs,global-setup.target.ts,playwright.target.config.ts}` +- Modify: `package.json` +- Regenerate: `package-lock.json` +- Modify: `.gitignore` only for obsolete generated Node-server directories +- Modify: `scripts/retirement/runtime-boundary.ts` +- Modify: `test/unit/architecture/rust-only-server-runtime.test.ts` +- Create: `scripts/retirement/node-test-disposition.json` +- Create: `scripts/retirement/verify-node-test-disposition.ts` +- Create: `test/unit/architecture/node-test-disposition.test.ts` + +**Interfaces:** + +- The tracked `server/` directory does not exist. No package/config/script/test + compiles, emits, imports, or launches it. +- Root production dependencies remove Node-backend-only + `@ai-sdk/google`, root `@anthropic-ai/claude-agent-sdk`, `ai`, `chokidar`, + `cookie-parser`, `dotenv`, `express`, `express-rate-limit`, `glob`, `node-pty`, + `pino`, `rotating-file-stream`, and `is-port-reachable`; dev dependencies remove + `@types/cookie-parser`, `@types/express`, `@types/supertest`, `supertest`, + `superwstest`, and `pino-pretty`. Keep `extract-zip` and `tar` for reliable + cross-platform Electron runtime staging, `diff` for the client, and + `@modelcontextprotocol/sdk` for the retained MCP client. The + Claude SDK remains only in `crates/freshell-claude-sidecar/package*.json`. + Transitive packages required by the retained MCP SDK may remain in the lock; + the forbidden set is absent from the root's direct dependency ownership and + no retained entrypoint imports it as a Freshell backend. +- Deleted Node tests are not mechanically ported. Retained behavior stays covered + by current Rust crate tests, default Vitest, Rust Playwright, Electron tests, + and Tasks 1-9 regression tests. +- A test's directory does not decide its fate. Before deleting + `test/unit/server/**`, any subject whose implementation owner survives under + `shared/**`, `tools/**`, or another retained namespace is re-homed and kept; + the shared `title-utils` subject is the first explicit case. The disposition verifier + rejects treating a retained shared subject as obsolete merely because its old + test lived under `server/`. +- `node-test-disposition.json` is a committed deletion ledger for the complete + 346-file Task 5/6/10 candidate universe identified by the load-bearing review + before deletion. Every old test path + and every independently meaningful subject in a mixed test has a row with the + old path/title/subject, retained-or-deleted decision, exact surviving test, + required lane, selector, and latest receipt. Optional real-provider T2 checks + are marked supplemental and cannot satisfy a required replacement. Unknown, + duplicate, stale, or unresolved rows block deletion and the final gate. The + ledger also records the earlier Task 1 deletion of + `test/e2e/update-flow.test.ts` as an obsolete interactive-updater subject with + no replacement requirement. +- Runtime guard debt shrinks to active documentation-only items left for Task 11; + `unexpectedNodeBackend` stays empty. Its detector treats the exact + manifest-listed coordinator/fixture/probe listener rows from Task 1 as + sanctioned non-backend infrastructure; only an unlisted backend listener or + any listener that owns Freshell PTY/backend state is unexpected. + +- [ ] **Step 1: Write the failing behavioral test** + + Tighten the runtime/dependency test to require `server/` and every Node-server + test/config/script category absent, require the explicit forbidden dependency + set absent from root direct dependencies, and require zero imports into + `server/**`. Add a fixture that proves the allowed CLI/MCP/Claude Node packages + and their locked transitive dependencies do not satisfy a Node-backend + detector unless an unlisted entrypoint listens as a backend or owns backend + state; manifest-listed coordinator/fixture/probe listeners remain explicitly + allowed. Add the + disposition verifier with a synthetic mixed test whose second subject is + unresolved, a zero-test selector receipt, and a skipped optional T2 receipt; + all three must fail required replacement closure. Update + `fresh-agent-only-runtime.test.ts` expectations to remove `server` from the + required roots/allowances and prove every remaining scanned root exists. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts --config config/vitest/vitest.config.ts + ``` + + Expected: FAIL with concrete `server/**`, Node-test-tree, dependency, and legacy + maintenance-script debt entries. + +- [ ] **Step 3: Add the minimal implementation** + + Before deleting anything, generate and review the complete committed + disposition ledger from the closed Task 5/6/10 universe. Split mixed files by + title/subject, bind each retained subject to an exact surviving test/lane and a + positive-count receipt, mark obsolete Node-implementation subjects explicitly, + and resolve every row; the verifier refuses an unresolved or vacuous row or a + deleted test of retained shared behavior. Reconcile the Task 6 subject moves, + including the split `title-utils` test and tab-registry schema test, and verify + every other ledger-identified retained shared/tool subject was re-homed before + the blanket delete. + Update the fresh-agent architecture walk to scan only existing retained roots + and remove server-only allowances. Then + run a retained-fixture import scan and move any provider fixture still + consumed by Rust/E2E to `test/fixtures/**`; Task 6 already removed the + Node-runtime provider contracts while preserving the independent Amplifier + contracts. Then delete the exact legacy trees and scripts, prune the listed + dependencies/types, and regenerate the root lock with + `npm install --package-lock-only`. Do not delete shared contracts, + `dist/tools` sources, Electron, test fixtures used by Rust, or + `crates/freshell-claude-sidecar`. Do not edit historical plans/reports merely to + erase references. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + test ! -d server + npm install --package-lock-only + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts test/unit/architecture/node-test-disposition.test.ts --config config/vitest/vitest.config.ts + node --import tsx scripts/retirement/verify-node-test-disposition.ts + npm run typecheck + ``` + + Expected: all commands PASS; the disposition has zero unresolved/vacuous rows + and the runtime guard reports only active docs/process wording reserved for + Task 11, with no implementation/build/test dependency on Node backend code. + +- [ ] **Step 5: Refactor while green** + + Remove newly unreachable exclusions/path classifiers, collapse empty legacy + directories, and sort package entries. Replace stale implementation comments in + active source only when they imply a runnable Node path; keep useful historical + semantic provenance in Rust comments and committed port reports. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "from ['\"][^'\"]*server/|import\(['\"][^'\"]*server/|server/index\.(ts|js)|dist/server|tsconfig\.server|node-pty" src shared tools config scripts electron installers docker examples .github test/e2e-browser test/e2e-electron test/integration test/helpers --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' + node --import tsx scripts/retirement/verify-node-test-disposition.ts + cargo test -p freshell-codex --features real-transport --locked + cargo test -p freshell-opencode --features real-transport --locked + npm run build + FRESHELL_TEST_SUMMARY="legacy Node backend deleted" npm test + ``` + + Expected: search returns no active import/launch/artifact match; the disposition + ledger has zero unresolved rows; feature-gated transports, build, and broad + coordinated tests PASS with positive counts. + +- [ ] **Step 7: Commit the task** + + ```bash + git add -A + git diff --cached --check + git commit -m "refactor: delete legacy Node application server" + ``` + +### Task 11: Update Active Documentation, Repeat Gap Triage, and Prove the Cutover + +**Files:** + +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `.env.example` +- Modify: `docs/development/windows-electron-build.md` +- Modify: `docs/development/test-sandbox.md` +- Modify: `scripts/retirement/runtime-boundary.ts` +- Modify: `test/unit/architecture/rust-only-server-runtime.test.ts` +- Create outside the worktree during execution: + `/home/dan/code/freshell/.worktrees/.the-usual-logs/retire-node-server-v2/reports/final-node-feature-triage.md` +- Do not modify: `docs/index.html` +- Do not modify: `.kata.toml` unless a real Kata configuration change is independently required + +**Interfaces:** + +- README describes Rust server install/dev/build/start/serve, standalone Node + CLI/MCP clients, Electron's packaged app-bound Rust backend, the standalone + Rust systemd service, the isolated Claude sidecar, and accepted unavailable + features without advertising deterministic 404s or Electron daemon mode. +- AGENTS command/test/Electron/service guidance matches final scripts and keeps the + port-3001 approval rule. `.env.example` says Rust server and documents explicit + packaged MCP/Claude env only where operators can set them. Windows guide builds + native `freshell-server.exe` and verifies the installer; it no longer mentions + `conpty.node`/Node backend compilation. +- The sandbox guide retains its destructive-test safety contract but replaces the + obsolete `node-pty` rationale with current process-kill/config-corruption/restart + examples. +- Final runtime guard requires `manifestDrift=[]`, `legacyDebt=[]`, and + `unexpectedNodeBackend=[]`, scans active README/process/release paths, and + retains historical-plan exclusions. The committed test-disposition verifier + also requires zero unresolved or vacuous replacement rows. +- The external triage receipt records the final source/caller inventory and + Kata/GitHub/checklist owner searches. Expected result: every important residual + remains owned by #624/checklist or another listed issue, so no Kata is created. + +- [ ] **Step 1: Write the failing behavioral test** + + Tighten `rust-only-server-runtime.test.ts` to require all three arrays empty + based on executable/runtime manifest evidence. Do not add tests that only read + prose or configuration text; the final structural/document search and the + `git diff --exit-code` checks remain command-level gates in Steps 4 and 6. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts --config config/vitest/vitest.config.ts + ``` + + Expected: FAIL on current README/AGENTS/.env/Windows-guide legacy statements or + remaining temporary debt allowlist entries, not on historical plans. + +- [ ] **Step 3: Add the minimal implementation** + + Update the five active documents and remove the temporary debt list so the guard + requires zero. Then create the external triage receipt with exact command, + timestamp, commit, result, and owner sections. Re-run source/caller searches for + attachments, exec/diff/send, editor open, extension lifecycle/assets, raw/WS + browser forwarding, `/api/run`, paged turns, viewport/scrollback, + `codingcli.*`, incident dump, and the removed interactive precheck self-update + flow. For every reachable Rust-absent capability, + run targeted `kata search --workspace "$PWD" --lexical --limit 20`, + `kata list --workspace "$PWD" --json`, + `gh issue list --repo danshapiro/freshell --state all --limit 500 --search`, and + `rg` over the parity checklist/plans; record the output summary. The expected + conclusion is `no important untracked residual; no Kata filed`. + + Use this fixed final inventory/owner-search command set and record every command, + exit code, and summarized result in the receipt: + + ```bash + rg -n "/api/(fresh-agent/(attachments|exec|diff|send)|files/open|extensions/.*/(start|assets)|proxy/forward|run)|codingcli\.|getTerminalViewport|getTerminalScrollback|loadFreshAgent(ThreadTurns|TurnBody)|debug/fresh-agent|runUpdateCheck|shouldSkipUpdateCheck" src tools shared crates scripts README.md AGENTS.md + kata list --workspace "$PWD" --json + triage_terms=("fresh agent attachments" "fresh agent exec diff" "fresh agent send" "api run automation" "external editor reveal" "extension lifecycle assets" "browser proxy forwarding websocket" "session repair" "fresh agent paged turns" "terminal viewport scrollback" "codingcli websocket" "fresh agent incident" "interactive precheck self update") + for triage_term in "${triage_terms[@]}"; do + kata search --workspace "$PWD" --lexical --limit 20 "$triage_term" --agent + gh issue list --repo danshapiro/freshell --state all --limit 500 --search "$triage_term in:title,body" --json number,title,state,url + done + gh issue view 624 --repo danshapiro/freshell --json number,title,state,url,body + gh issue view 165 --repo danshapiro/freshell --json number,title,state,url,body + gh issue view 6 --repo danshapiro/freshell --json number,title,state,url,body + rg -n "AGENT-(09|11|13|20)|AUTO-(11|12|13)|BROWSER-0[2-4]|EXT-0[3-9]|FILE-04|SESSION-(11|16|21)|TERM-21" docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md + ``` + + The first source/caller search may return only deliberate unsupported-result + messages/tests represented in active source; the receipt classifies each match + and fails if it finds a request sender or advertised supported action. + + If and only if contrary implementation evidence identifies an important + Rust-absent capability that is not tracked by any of the three owner searches, + create one acceptance-sized Kata using priority 1, labels + `enhancement` and `rust-gap`, metadata + `source=retire-node-server-v2`. Derive the idempotency-key slug from the + lowercase ASCII capability name, collapse non-alphanumerics to single hyphens, + trim boundary hyphens, and truncate to 48 characters; concatenate + `freshell-retire-node-server-v2-`, that slug, and `-20260826`. Store its + triage/body receipts beside the final receipt, verify it with `kata show` plus + `kata events`, and verify `.kata.toml` remains unchanged. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts --config config/vitest/vitest.config.ts + git diff --exit-code origin/main -- docs/index.html .kata.toml + test -s /home/dan/code/freshell/.worktrees/.the-usual-logs/retire-node-server-v2/reports/final-node-feature-triage.md + ``` + + Expected: PASS; all guard arrays are empty, protected files match + `origin/main`, the receipt is nonempty and concludes no new Kata unless it names + and verifies one evidence-backed discovery. + +- [ ] **Step 5: Refactor while green** + + Deduplicate README/AGENTS command tables by linking contributor details from + README rather than copying them, normalize final scanner diagnostics, and remove + obsolete `legacy`, `original`, and `port` naming only from active commands and + config. Preserve historical plan/report provenance and the first run's worktree. + +- [ ] **Step 6: Run full impacted and non-vacuity verification** + + Run from the v2 worktree without contacting or depending on the live server on + port 3001: + + ```bash + npm run test:status + FRESHELL_TEST_SUMMARY="retire Node server: final Rust-only proof" npm run check + cargo fmt --all --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo clippy -p freshell-codex --features real-transport --all-targets --locked -- -D warnings + cargo clippy -p freshell-opencode --features real-transport --all-targets --locked -- -D warnings + cargo test --workspace --locked + cargo test -p freshell-codex --features real-transport --locked + cargo test -p freshell-opencode --features real-transport --locked + npm run lint + env -u FRESHELL_RUN_REAL_PROVIDER_CONTRACTS npm run test:oracle + npm run test:e2e:helpers + npm exec playwright -- test --config test/e2e-browser/playwright.config.ts --project=chromium --list + npm run test:e2e -- --project=chromium + npm run test:electron + npm run test:e2e:electron + npm run electron:build + npm run verify:electron-artifact + npm run test:electron:runtime + node --import tsx scripts/retirement/verify-node-test-disposition.ts + docker build --tag freshell-retire-node-server-v2-cloud --file docker/cloud-run/Dockerfile . + docker build --tag freshell-retire-node-server-v2-example --file examples/docker/Dockerfile . + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-cloud -c 'test -x /app/target/release/freshell-server && test -f /app/dist/client/index.html && test -f /app/dist/tools/freshell-mcp/server.js && test ! -e /app/dist/server && test ! -e /app/server && test ! -e /app/node_modules/node-pty' + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-example -c 'test -x /app/freshell-server && test -f /app/dist/client/index.html && test ! -e /app/dist/server && test ! -e /app/server && test ! -e /app/node_modules' + ! rg -n "dist/server|server/index\.(ts|js)|tsx watch server|tsconfig\.server|server-node-modules|node-pty|legacy-chromium" package.json config scripts run-rust-server.sh port/laptop-bootstrap tools electron installers docker examples .github test/e2e-browser test/e2e-electron README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' + test ! -d server + test ! -d dist/server + test ! -f tsconfig.server.json + test ! -f config/vitest/vitest.server.config.ts + git diff --exit-code origin/main -- docs/index.html .kata.toml + ``` + + Expected: all commands PASS; Playwright lists at least 308 tests in at least 86 + files and no legacy project; full configured E2E has nonzero executed tests and + zero unexplained required skips, while the explicitly local-only MCP QA spec + has a positive local receipt; optional real-provider T2 tests are reported as + supplemental rather than replacement coverage; Electron artifact works from a + checkout-free staged copy with a runnable Rust server and no forbidden path; + the disposition ledger has zero unresolved rows; rebuilt final container images contain no legacy source, + compiled Node server, or Node-backend-only native dependency; final `rg` has no output; + absence/protected-file checks pass. + Any selected destructive lifecycle suite runs via `scripts/sandbox-test.sh`, + never directly on the host. + + Native cross-platform acceptance is a required PR check, not a pre-PR dispatch. + After the final commit, push only this feature branch: + + ```bash + git push -u origin the-usual/retire-node-server-v2 + ``` + + Then stop and request the user's explicit approval to create the PR. Once + approved, the normal required PR matrix must be green on `macos-15-intel`, + `macos-latest`, `ubuntu-latest`, and `windows-2022`; each job reports native + `freshell-server[.exe]`, authenticated server-info/SPA/PTY acceptance, stdio + MCP/fake-Claude acceptance, exact cleanup, and no forbidden Node-server + artifact. The branch push itself creates no PR and performs no deployment. + +- [ ] **Step 7: Commit the task** + + ```bash + git add README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md scripts/retirement/runtime-boundary.ts scripts/retirement/runtime-surfaces.json test/unit/architecture/rust-only-server-runtime.test.ts + if ! git diff --quiet -- .kata.toml; then git add .kata.toml; fi + git commit -m "docs: declare the Rust-only backend" + ``` + + Expected final state: the worktree is clean after the commit; the external + triage receipt remains outside tracked worktree history; no PR exists; port + 3001 was never contacted or restarted; the first retirement run remains intact. diff --git a/docs/skills/testing.md b/docs/skills/testing.md index fc1bd9759..5b07d5ff4 100644 --- a/docs/skills/testing.md +++ b/docs/skills/testing.md @@ -7,14 +7,14 @@ | Command | Purpose | |---------|---------| | `npm run typecheck:client` | Cheap client-only compile gate; safe while prod is live | -| `npm test` | Coordinated full suite (`vitest run` plus `vitest run --config config/vitest/vitest.server.config.ts`) | +| `npm test` | Coordinated full suite: client Vitest, Rust source-runtime smoke, Cargo tests, and Electron tests | | `npm run test:all` | Alias for the same coordinated full suite | -| `npm run check` | Run `typecheck`, then the coordinated full suite | +| `npm run check` | Typecheck, then the coordinated full suite | | `npm run verify` | Run `build`, then the coordinated full suite | | `npm run test:unit` | Exact default-config `test/unit` workload | | `npm run test:client` | Exact default-config `test/unit/client` workload | -| `npm run test:integration` | Exact server-config `test/server` workload | -| `npm run test:server` | Watch-capable server Vitest command; only coordinates explicit broad `--run` | +| `npm run test:integration` | Exact Rust workspace integration-test workload | +| `npm run test:server` | Cargo-backed Rust `freshell-server` tests; only coordinates explicit broad `--run` | | `npm run test:coverage` | Exact default-config `vitest run --coverage` workload | | `npm run test:status` | Show the current holder, latest results, and any matching advisory baseline | | `npm run test:vitest -- ...` | Repo-owned direct Vitest path for focused passthrough work | @@ -23,8 +23,8 @@ - Broad repo-supported runs wait instead of failing fast when another coordinated run is active. - `test:unit` is the exact default-config `test/unit` workload. -- `test:integration` is the exact server-config `test/server` workload. -- `test:server` stays watch-capable by default and only coordinates explicit broad `--run`. +- `test:integration` runs the Rust workspace integration tests. +- `test:server` runs the Cargo-backed Rust `freshell-server` crate. Zero-argument and explicit broad `--run` invocations are coordinated; narrowed Cargo selectors are delegated. - prior successful baselines are advisory only. They never short-circuit an explicitly requested run. - use `npm run test:vitest -- ...` if you need a repo-owned direct Vitest escape hatch. Raw `npx vitest` is not a supported coordinated path. @@ -36,6 +36,15 @@ 4. Use the narrowest truthful public command you can. 5. If another holder is active, wait rather than killing a foreign process. +When production is live from the main checkout, the prebuild guard fails closed +before any artifact writes for `npm test` (through its source-runtime phase), +`npm run check`, `npm run test:source-runtime`, `npm run build`, and +`npm run verify`. Use `npm run typecheck:client` for a no-write check, or run +source-runtime/build verification from a linked worktree such as +`.worktrees/`. `npm run dev` and `npm run dev:server` create a secure +first-run `.env` token and install the locked Claude sidecar before starting +the Rust server. + ## Focused Examples ```bash @@ -43,6 +52,7 @@ npm run typecheck:client FRESHELL_TEST_SUMMARY="Verify coordinated full suite" npm test npm run test:server -- --help npm run test:server -- --run -npm run test:unit -- test/unit/server/coding-cli/utils.test.ts -npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/server/ws-protocol.test.ts +npm run test:unit -- test/unit/client/store/tabsPersistence.test.ts +npm run test:vitest -- run test/unit/tooling/run-standard-tests.test.ts --config config/vitest/vitest.config.ts +npm run test:source-runtime -- test/integration/tooling/source-runtime-rust.test.ts ``` diff --git a/electron/daemon/create-daemon-manager.ts b/electron/daemon/create-daemon-manager.ts deleted file mode 100644 index dbaacceea..000000000 --- a/electron/daemon/create-daemon-manager.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DaemonManager } from './daemon-manager.js' - -export async function createDaemonManager(resourcesPath?: string): Promise { - switch (process.platform) { - case 'darwin': { - const { LaunchdDaemonManager } = await import('./launchd.js') - return new LaunchdDaemonManager(resourcesPath) - } - case 'linux': { - const { SystemdDaemonManager } = await import('./systemd.js') - return new SystemdDaemonManager(resourcesPath) - } - case 'win32': { - const { WindowsServiceDaemonManager } = await import('./windows-service.js') - return new WindowsServiceDaemonManager(resourcesPath) - } - default: - throw new Error(`Unsupported platform: ${process.platform}`) - } -} diff --git a/electron/daemon/daemon-manager.ts b/electron/daemon/daemon-manager.ts deleted file mode 100644 index 8ec466320..000000000 --- a/electron/daemon/daemon-manager.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface DaemonStatus { - installed: boolean - running: boolean - pid?: number - uptime?: number // seconds - error?: string -} - -export interface DaemonPaths { - nodeBinary: string // bundled Node.js binary: {resourcesPath}/bundled-node/bin/node - serverEntry: string // server entry point: {resourcesPath}/server/index.js - serverNodeModules: string // server deps: {resourcesPath}/server-node-modules - nativeModules: string // recompiled native modules: {resourcesPath}/bundled-node/native-modules - configDir: string // ~/.freshell - logDir: string // ~/.freshell/logs -} - -// All paths above are real filesystem paths from extraResources. -// They are NOT inside the ASAR archive. The bundled Node.js binary -// is a vanilla Node.js process and cannot read from ASAR. - -export interface DaemonManager { - readonly platform: 'darwin' | 'linux' | 'win32' - - /** Register the OS service/agent (idempotent) */ - install(paths: DaemonPaths, port: number): Promise - - /** Remove the OS service/agent (idempotent) */ - uninstall(): Promise - - /** Start the service */ - start(): Promise - - /** Stop the service */ - stop(): Promise - - /** Query current status */ - status(): Promise - - /** Check if service definition exists */ - isInstalled(): Promise -} diff --git a/electron/daemon/launchd.ts b/electron/daemon/launchd.ts deleted file mode 100644 index af09dd39d..000000000 --- a/electron/daemon/launchd.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { execFile } from 'child_process' -import fsp from 'fs/promises' -import os from 'os' -import path from 'path' -import { fileURLToPath } from 'url' -import type { DaemonManager, DaemonPaths, DaemonStatus } from './daemon-manager.js' -import { resolveTemplatePath } from './template-path.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const SERVICE_LABEL = 'com.freshell.server' -const PLIST_FILENAME = `${SERVICE_LABEL}.plist` - -function getPlistPath(): string { - return path.join(os.homedir(), 'Library', 'LaunchAgents', PLIST_FILENAME) -} - -function execFilePromise(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - execFile(cmd, args, (error, stdout, stderr) => { - if (error) { - reject(Object.assign(error, { stdout, stderr })) - } else { - resolve({ stdout: stdout as string, stderr: stderr as string }) - } - }) - }) -} - -export class LaunchdDaemonManager implements DaemonManager { - readonly platform = 'darwin' as const - private readonly resourcesPath?: string - - constructor(resourcesPath?: string) { - this.resourcesPath = resourcesPath - } - - async install(paths: DaemonPaths, port: number): Promise { - const templatePath = resolveTemplatePath( - ['launchd', 'com.freshell.server.plist.template'], - __dirname, - this.resourcesPath, - ) - const template = await fsp.readFile(templatePath, 'utf-8') - - const nodePath = [paths.nativeModules, paths.serverNodeModules].join(':') - - const content = template - .replace(/\{\{NODE_BINARY\}\}/g, paths.nodeBinary) - .replace(/\{\{SERVER_ENTRY\}\}/g, paths.serverEntry) - .replace(/\{\{PORT\}\}/g, String(port)) - .replace(/\{\{NODE_PATH\}\}/g, nodePath) - .replace(/\{\{CONFIG_DIR\}\}/g, paths.configDir) - .replace(/\{\{LOG_DIR\}\}/g, paths.logDir) - - const plistDir = path.dirname(getPlistPath()) - await fsp.mkdir(plistDir, { recursive: true }) - await fsp.writeFile(getPlistPath(), content) - - await execFilePromise('launchctl', ['load', '-w', getPlistPath()]) - } - - async uninstall(): Promise { - try { - await execFilePromise('launchctl', ['unload', getPlistPath()]) - } catch { - // Ignore errors if not loaded - } - try { - await fsp.unlink(getPlistPath()) - } catch { - // Ignore if file doesn't exist - } - } - - async start(): Promise { - await execFilePromise('launchctl', ['start', SERVICE_LABEL]) - } - - async stop(): Promise { - await execFilePromise('launchctl', ['stop', SERVICE_LABEL]) - } - - async status(): Promise { - try { - const { stdout } = await execFilePromise('launchctl', ['list', SERVICE_LABEL]) - - const pidMatch = stdout.match(/"PID"\s*=\s*(\d+)/) - const running = pidMatch !== null - const pid = pidMatch ? parseInt(pidMatch[1], 10) : undefined - - return { - installed: true, - running, - pid, - } - } catch { - return { - installed: false, - running: false, - } - } - } - - async isInstalled(): Promise { - try { - await fsp.access(getPlistPath()) - return true - } catch { - return false - } - } -} diff --git a/electron/daemon/systemd.ts b/electron/daemon/systemd.ts deleted file mode 100644 index a6d6973e4..000000000 --- a/electron/daemon/systemd.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { execFile } from 'child_process' -import fsp from 'fs/promises' -import os from 'os' -import path from 'path' -import { fileURLToPath } from 'url' -import type { DaemonManager, DaemonPaths, DaemonStatus } from './daemon-manager.js' -import { resolveTemplatePath } from './template-path.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const SERVICE_NAME = 'freshell' -const UNIT_FILENAME = `${SERVICE_NAME}.service` - -function getUnitPath(): string { - return path.join(os.homedir(), '.config', 'systemd', 'user', UNIT_FILENAME) -} - -function execFilePromise(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - execFile(cmd, args, (error, stdout, stderr) => { - if (error) { - reject(Object.assign(error, { stdout, stderr })) - } else { - resolve({ stdout: stdout as string, stderr: stderr as string }) - } - }) - }) -} - -export class SystemdDaemonManager implements DaemonManager { - readonly platform = 'linux' as const - private readonly resourcesPath?: string - - constructor(resourcesPath?: string) { - this.resourcesPath = resourcesPath - } - - async install(paths: DaemonPaths, port: number): Promise { - const templatePath = resolveTemplatePath( - ['systemd', 'freshell.service.template'], - __dirname, - this.resourcesPath, - ) - const template = await fsp.readFile(templatePath, 'utf-8') - - const nodePath = [paths.nativeModules, paths.serverNodeModules].join(':') - - const content = template - .replace(/\{\{NODE_BINARY\}\}/g, paths.nodeBinary) - .replace(/\{\{SERVER_ENTRY\}\}/g, paths.serverEntry) - .replace(/\{\{PORT\}\}/g, String(port)) - .replace(/\{\{NODE_PATH\}\}/g, nodePath) - .replace(/\{\{CONFIG_DIR\}\}/g, paths.configDir) - .replace(/\{\{LOG_DIR\}\}/g, paths.logDir) - - const unitDir = path.dirname(getUnitPath()) - await fsp.mkdir(unitDir, { recursive: true }) - await fsp.writeFile(getUnitPath(), content) - - await execFilePromise('systemctl', ['--user', 'daemon-reload']) - await execFilePromise('systemctl', ['--user', 'enable', SERVICE_NAME]) - } - - async uninstall(): Promise { - try { - await execFilePromise('systemctl', ['--user', 'disable', SERVICE_NAME]) - } catch { - // Ignore if not enabled - } - try { - await execFilePromise('systemctl', ['--user', 'stop', SERVICE_NAME]) - } catch { - // Ignore if not running - } - try { - await fsp.unlink(getUnitPath()) - } catch { - // Ignore if file doesn't exist - } - try { - await execFilePromise('systemctl', ['--user', 'daemon-reload']) - } catch { - // Ignore - } - } - - async start(): Promise { - await execFilePromise('systemctl', ['--user', 'start', SERVICE_NAME]) - } - - async stop(): Promise { - await execFilePromise('systemctl', ['--user', 'stop', SERVICE_NAME]) - } - - async status(): Promise { - try { - const { stdout } = await execFilePromise('systemctl', [ - '--user', 'show', SERVICE_NAME, - '--property=ActiveState,MainPID,ExecMainStartTimestamp', - ]) - - const activeStateMatch = stdout.match(/ActiveState=(\w+)/) - const pidMatch = stdout.match(/MainPID=(\d+)/) - - const activeState = activeStateMatch?.[1] - const pid = pidMatch ? parseInt(pidMatch[1], 10) : undefined - const running = activeState === 'active' - - return { - installed: true, - running, - pid: running && pid && pid > 0 ? pid : undefined, - } - } catch { - return { - installed: false, - running: false, - } - } - } - - async isInstalled(): Promise { - try { - await fsp.access(getUnitPath()) - return true - } catch { - return false - } - } -} diff --git a/electron/daemon/template-path.ts b/electron/daemon/template-path.ts deleted file mode 100644 index 565561f36..000000000 --- a/electron/daemon/template-path.ts +++ /dev/null @@ -1,23 +0,0 @@ -import path from 'path' - -/** - * Resolves the path to an installer template file. - * - * In a packaged Electron app, templates are placed in extraResources under - * `{process.resourcesPath}/installers/...`. In development, they live relative - * to the source tree at `../../installers/...` from the daemon module directory. - * - * @param templateSubpath - Path segments under `installers/`, e.g. `['windows', 'freshell-task.xml.template']` - * @param moduleDir - The __dirname of the calling module (used for dev fallback) - * @param resourcesPath - process.resourcesPath in packaged Electron, undefined in dev - */ -export function resolveTemplatePath( - templateSubpath: string[], - moduleDir: string, - resourcesPath?: string, -): string { - if (resourcesPath) { - return path.join(resourcesPath, 'installers', ...templateSubpath) - } - return path.join(moduleDir, '..', '..', 'installers', ...templateSubpath) -} diff --git a/electron/daemon/windows-service.ts b/electron/daemon/windows-service.ts deleted file mode 100644 index b7882418d..000000000 --- a/electron/daemon/windows-service.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { execFile } from 'child_process' -import fsp from 'fs/promises' -import os from 'os' -import path from 'path' -import { fileURLToPath } from 'url' -import type { DaemonManager, DaemonPaths, DaemonStatus } from './daemon-manager.js' -import { resolveTemplatePath } from './template-path.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const TASK_NAME = 'Freshell Server' - -function getTaskXmlPath(): string { - return path.join(os.homedir(), '.freshell', 'freshell-task.xml') -} - -function execFilePromise(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - execFile(cmd, args, (error, stdout, stderr) => { - if (error) { - reject(Object.assign(error, { stdout, stderr })) - } else { - resolve({ stdout: stdout as string, stderr: stderr as string }) - } - }) - }) -} - -export class WindowsServiceDaemonManager implements DaemonManager { - readonly platform = 'win32' as const - private nodeBinaryPath?: string - private readonly resourcesPath?: string - - constructor(resourcesPath?: string) { - this.resourcesPath = resourcesPath - } - - async install(paths: DaemonPaths, port: number): Promise { - this.nodeBinaryPath = paths.nodeBinary - - const templatePath = resolveTemplatePath( - ['windows', 'freshell-task.xml.template'], - __dirname, - this.resourcesPath, - ) - const template = await fsp.readFile(templatePath, 'utf-8') - const nodePath = [paths.nativeModules, paths.serverNodeModules].join(';') - - const content = template - .replace(/\{\{NODE_BINARY\}\}/g, paths.nodeBinary) - .replace(/\{\{SERVER_ENTRY\}\}/g, paths.serverEntry) - .replace(/\{\{PORT\}\}/g, String(port)) - .replace(/\{\{NODE_PATH\}\}/g, nodePath) - .replace(/\{\{CONFIG_DIR\}\}/g, paths.configDir) - .replace(/\{\{LOG_DIR\}\}/g, paths.logDir) - - // Write the task XML to a known location - const xmlDir = path.dirname(getTaskXmlPath()) - await fsp.mkdir(xmlDir, { recursive: true }) - await fsp.writeFile(getTaskXmlPath(), content) - - // Create the scheduled task from the XML file - await execFilePromise('schtasks', [ - '/Create', - '/TN', TASK_NAME, - '/XML', getTaskXmlPath(), - '/F', // Force overwrite if exists (idempotent) - ]) - await this.ensureLeastPrivilege() - } - - async uninstall(): Promise { - try { - await execFilePromise('schtasks', [ - '/Delete', - '/TN', TASK_NAME, - '/F', - ]) - } catch { - // Ignore if not found - } - try { - await fsp.unlink(getTaskXmlPath()) - } catch { - // Ignore if file doesn't exist - } - } - - async start(): Promise { - await this.ensureLeastPrivilege() - await execFilePromise('schtasks', ['/Run', '/TN', TASK_NAME]) - } - - async stop(): Promise { - // Find the specific Freshell server process by matching the bundled node binary path. - // We must NOT kill all node.exe processes -- only the one running via our bundled binary. - try { - const { stdout } = await execFilePromise('wmic', [ - 'process', 'where', - `name='node.exe' and CommandLine like '%${(this.nodeBinaryPath ?? 'freshell').replace(/\\/g, '\\\\')}%'`, - 'get', 'ProcessId', - '/format:list', - ]) - - const pidMatch = stdout.match(/ProcessId=(\d+)/) - if (pidMatch) { - await execFilePromise('taskkill', ['/PID', pidMatch[1], '/F']) - } - } catch { - // Fallback: try to end the scheduled task run - try { - await execFilePromise('schtasks', ['/End', '/TN', TASK_NAME]) - } catch { - // Ignore if task is not running - } - } - } - - async status(): Promise { - try { - const { stdout } = await execFilePromise('schtasks', [ - '/Query', - '/TN', TASK_NAME, - '/FO', 'CSV', - ]) - - const lines = stdout.split('\r\n').filter(Boolean) - if (lines.length < 2) { - return { installed: false, running: false } - } - - const dataLine = lines[1] - const running = dataLine.includes('"Running"') - - return { - installed: true, - running, - } - } catch { - return { - installed: false, - running: false, - } - } - } - - async isInstalled(): Promise { - try { - await execFilePromise('schtasks', ['/Query', '/TN', TASK_NAME]) - return true - } catch { - return false - } - } - - private async ensureLeastPrivilege(): Promise { - await execFilePromise('schtasks', ['/Change', '/TN', TASK_NAME, '/RL', 'LIMITED']) - } -} diff --git a/electron/desktop-config.ts b/electron/desktop-config.ts index 6f41fa9bd..7823ec16f 100644 --- a/electron/desktop-config.ts +++ b/electron/desktop-config.ts @@ -1,9 +1,11 @@ import fsp from 'fs/promises' import os from 'os' import path from 'path' +import { z } from 'zod' import { DesktopConfigSchema, type DesktopConfig } from './types.js' const DESKTOP_CONFIG_FILENAME = 'desktop.json' +const LEGACY_SERVER_MODE = 'daemon' function getConfigPath(): string { return path.join(os.homedir(), '.freshell', DESKTOP_CONFIG_FILENAME) @@ -31,16 +33,52 @@ export async function readDesktopConfig(): Promise { try { const content = await fsp.readFile(configPath, 'utf-8') const parsed = JSON.parse(content) - const result = DesktopConfigSchema.safeParse(parsed) + const migrated = migratePersistedConfig(parsed) + const result = DesktopConfigSchema.safeParse(migrated.config) if (!result.success) { return null } + + if (migrated.changed) { + // Preserve fields introduced by newer/older desktop clients while + // changing only the retired mode. The schema result above still gives + // callers the validated current shape and defaults. + await writeDesktopConfig(migrated.config as DesktopConfig) + console.info(JSON.stringify({ + severity: 'info', + component: 'electron-desktop-config', + event: 'desktop_config_migrated', + from: 'daemon', + to: 'app-bound', + })) + } + return result.data } catch { return null } } +const PersistedConfigSchema = z.object({ + serverMode: z.enum([ + LEGACY_SERVER_MODE, + 'app-bound', + 'remote', + ]), +}).passthrough() + +function migratePersistedConfig(value: unknown): { config: unknown; changed: boolean } { + const persisted = PersistedConfigSchema.safeParse(value) + if (!persisted.success || persisted.data.serverMode !== LEGACY_SERVER_MODE) { + return { config: value, changed: false } + } + + return { + config: { ...persisted.data, serverMode: 'app-bound' }, + changed: true, + } +} + export async function writeDesktopConfig(config: DesktopConfig): Promise { const configDir = getConfigDir() await fsp.mkdir(configDir, { recursive: true }) diff --git a/electron/entry.ts b/electron/entry.ts index 62d6c36b0..4046d2726 100644 --- a/electron/entry.ts +++ b/electron/entry.ts @@ -21,7 +21,6 @@ const __dirname = path.dirname(__filename) import { readDesktopConfig, patchDesktopConfig } from './desktop-config.js' import { getDefaultDesktopConfig } from './desktop-config.js' -import { createDaemonManager } from './daemon/create-daemon-manager.js' import { createServerSpawner } from './server-spawner.js' import { createHotkeyManager } from './hotkey.js' import { createWindowStatePersistence } from './window-state.js' @@ -283,7 +282,6 @@ async function main(): Promise { // Create DI implementations const resourcesPath = isDev ? undefined : process.resourcesPath - const daemonManager = await createDaemonManager(resourcesPath) const serverSpawner = createServerSpawner() const hotkeyManager = createHotkeyManager(globalShortcut) const windowStatePersistence = createWindowStatePersistence() @@ -319,7 +317,6 @@ async function main(): Promise { const ctx: StartupContext = { desktopConfig, forcedLaunch, - daemonManager, serverSpawner, hotkeyManager, windowStatePersistence, @@ -391,6 +388,12 @@ async function main(): Promise { return undefined } }, + // Electron E2E fixtures set this only when they own every test port. It + // prevents the normal local-server discovery sweep from touching another + // developer's server while the fixture exercises an explicit launch. + discoverLaunchCandidates: process.env.FRESHELL_ELECTRON_TEST_NO_LOCAL_DISCOVERY === '1' + ? async () => [] + : undefined, createBrowserWindow: (options) => { return createRecoverableEntryWindow( options, @@ -517,8 +520,11 @@ async function main(): Promise { remoteToken: string globalHotkey: string }) => { + if (config.serverMode !== 'app-bound' && config.serverMode !== 'remote') { + throw new Error('Unsupported desktop server mode') + } await patchDesktopConfig({ - serverMode: config.serverMode as 'daemon' | 'app-bound' | 'remote', + serverMode: config.serverMode, port: config.port, remoteUrl: config.remoteUrl || undefined, remoteToken: config.remoteToken || undefined, diff --git a/electron/launch-policy.ts b/electron/launch-policy.ts index 587311239..af47a972f 100644 --- a/electron/launch-policy.ts +++ b/electron/launch-policy.ts @@ -72,7 +72,7 @@ export function chooseLaunchAction(options: ChooseLaunchActionOptions): LaunchAc return { type: 'auto-connect', candidate: candidates[0] } } - if (desktopConfig.serverMode === 'app-bound' || desktopConfig.serverMode === 'daemon') { + if (desktopConfig.serverMode === 'app-bound') { return { type: 'start-local' } } diff --git a/electron/main.ts b/electron/main.ts index 29b26fb1e..430aafcd4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -29,6 +29,8 @@ export async function initMainProcess(deps: MainProcessDeps): Promise { let mainWindow: any = null let isQuitting = false + let quitContinuationStarted = false + let serverStopInProgress: Promise | undefined await app.whenReady() @@ -46,10 +48,51 @@ export async function initMainProcess(deps: MainProcessDeps): Promise { }) } + // Calling app.quit() from a before-quit listener synchronously emits + // before-quit again in Electron. Mark the continuation before calling it so + // both rejected and synchronously-throwing stopServer implementations are + // safe from re-entering this listener. + const continueQuit = () => { + if (quitContinuationStarted) return + quitContinuationStarted = true + app.quit() + } + + const resumeQuitAfterServerStopFailure = (error: unknown) => { + serverStopInProgress = undefined + // Cleanup failure must not strand Electron in a half-quit state. We have + // already attempted the exact child; resume the quit while the + // structured error below preserves the failure for diagnosis. + console.error(JSON.stringify({ + severity: 'error', + component: 'electron-main', + event: 'server_stop_before_quit_failed', + error: error instanceof Error ? error.message : String(error), + })) + continueQuit() + } + // Cleanup on quit - app.on('before-quit', async () => { + app.on('before-quit', (event?: { preventDefault: () => void }) => { + // Electron does not await async event listeners. Prevent the first quit + // request, then explicitly resume it after the exact server child has + // stopped. The resumed app.quit() fires before-quit again; the guard lets + // that one through without stopping the server twice. + if (quitContinuationStarted) return + + event?.preventDefault() isQuitting = true - await deps.stopServer() + if (serverStopInProgress) return + + try { + serverStopInProgress = deps.stopServer() + .then(() => { + continueQuit() + }) + .catch(resumeQuitAfterServerStopFailure) + } catch (error) { + resumeQuitAfterServerStopFailure(error) + } }) // macOS: re-show window on activate diff --git a/electron/preload.ts b/electron/preload.ts index 779a2eac4..517ae2701 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -4,7 +4,7 @@ // registration is done via the registerPreloadApi function. export interface WizardSetupConfig { - serverMode: string + serverMode: 'app-bound' | 'remote' port: number remoteUrl: string remoteToken: string diff --git a/electron/server-spawner.ts b/electron/server-spawner.ts index 4837639ee..7defe22a8 100644 --- a/electron/server-spawner.ts +++ b/electron/server-spawner.ts @@ -3,188 +3,298 @@ import http from 'http' import fs from 'fs' import path from 'path' -export type ServerSpawnMode = - | { - mode: 'production' - nodeBinary: string - serverEntry: string - nativeModulesDir: string // recompiled native modules (node-pty) - serverNodeModulesDir: string // pruned production dependencies - } - | { mode: 'dev'; tsxPath: string; serverSourceEntry: string } +/** Runtime files the Rust server and its sanctioned Node clients need. */ +export interface ServerSpawnResources { + serverBinary: string + clientDir: string + claudeNodeBinary: string + claudeSidecarEntry: string + mcpNodeBinary: string + mcpEntry: string + homeDir: string + configDir: string + logDir: string +} export interface ServerSpawnerOptions { - spawn: ServerSpawnMode + resources: ServerSpawnResources port: number - envFile: string // path to .env - configDir: string // ~/.freshell - healthCheckTimeoutMs?: number // override for tests + /** The token used to authenticate the readiness server-info request. */ + authToken?: string + healthCheckTimeoutMs?: number +} + +export interface ServerStopOptions { + /** Time to wait for the Rust process to exit after SIGTERM. */ + gracefulTimeoutMs?: number + /** Time to wait for the Rust process to exit after SIGKILL. */ + forceTimeoutMs?: number } export interface ServerSpawner { - /** Spawn the server process. Resolves when /api/health responds. */ + /** Spawn the Rust server. Resolves after health and authenticated provenance checks. */ start(options: ServerSpawnerOptions): Promise - /** Kill the server process gracefully (SIGTERM, then SIGKILL after timeout). */ - stop(): Promise + /** Stop only the exact ChildProcess captured by start(), with bounded waits. */ + stop(options?: ServerStopOptions): Promise - /** Whether the server is currently running. */ + /** Whether the captured server child is currently running. */ isRunning(): boolean - /** The child process PID, if running. */ + /** The captured server child PID, if it is still owned. */ pid(): number | undefined } -export function createServerSpawner(): ServerSpawner { - let childProcess: ChildProcess | null = null - let running = false - /** Set to true when the spawned process exits (close or error). Checked during health check polling. */ - let processExited = false - /** Reference to the close/error handler registered during start(), so stop() can remove it. */ - let startCloseHandler: (() => void) | null = null +const DEFAULT_GRACEFUL_TIMEOUT_MS = 5_000 +const DEFAULT_FORCE_TIMEOUT_MS = 5_000 +const REQUEST_TIMEOUT_MS = 2_000 - async function pollHealthCheck(port: number, timeoutMs: number): Promise { - const startTime = Date.now() - let delay = 100 +interface HttpResponseBody { + statusCode?: number + body: string +} - while (Date.now() - startTime < timeoutMs) { - // If the child process exited before the health check succeeded, fail fast - if (processExited) { - throw new Error('Server process exited before health check succeeded') +function readAuthToken(configDir: string): string | undefined { + try { + const content = fs.readFileSync(path.join(configDir, '.env'), 'utf8') + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed.startsWith('AUTH_TOKEN=')) continue + const value = trimmed.slice('AUTH_TOKEN='.length).trim() + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1) } + return value + } + } catch { + // The Rust child will report the missing token. Keep this path quiet so a + // token never reaches the log when the config directory is unavailable. + } + return undefined +} - try { - await new Promise((resolve, reject) => { - const req = http.get(`http://localhost:${port}/api/health`, (res) => { - if (res.statusCode === 200) { - resolve() - } else { - reject(new Error(`Health check returned ${res.statusCode}`)) - } - res.resume() - }) - req.on('error', reject) - req.setTimeout(2000, () => { - req.destroy() - reject(new Error('Health check request timeout')) - }) +function requestHttpBody(url: string, authToken?: string): Promise { + return new Promise((resolve, reject) => { + const onResponse = (response: http.IncomingMessage) => { + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + }) + response.on('end', () => { + resolve({ + statusCode: response.statusCode, + body: Buffer.concat(chunks).toString('utf8'), }) - return // Success - } catch { - // Wait before retrying - await new Promise((resolve) => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, 5000) // Exponential backoff, cap at 5s - } + }) + response.on('error', reject) } - throw new Error(`Health check timed out after ${timeoutMs}ms`) + const request = authToken + ? http.get(url, { headers: { 'x-auth-token': authToken } }, onResponse) + : http.get(url, onResponse) + request.on('error', reject) + request.setTimeout(REQUEST_TIMEOUT_MS, () => { + request.destroy() + reject(new Error('Readiness request timed out')) + }) + }) +} + +async function pollHealthCheck(port: number, timeoutMs: number, processExited: () => boolean): Promise { + const startedAt = Date.now() + let delay = 100 + + while (Date.now() - startedAt < timeoutMs) { + if (processExited()) { + throw new Error('Server process exited before health check succeeded') + } + + try { + const response = await requestHttpBody(`http://localhost:${port}/api/health`) + if (response.statusCode === 200) return + throw new Error(`Health check returned ${response.statusCode}`) + } catch { + await new Promise((resolve) => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, 5_000) + } } + throw new Error(`Health check timed out after ${timeoutMs}ms`) +} + +async function verifyRustServerInfo( + port: number, + authToken: string | undefined, + processExited: () => boolean, +): Promise { + if (!authToken) { + throw new Error('Cannot verify Rust server-info without an AUTH_TOKEN') + } + if (processExited()) { + throw new Error('Server process exited before server-info verification succeeded') + } + + const response = await requestHttpBody(`http://localhost:${port}/api/server-info`, authToken) + if (response.statusCode !== 200) { + throw new Error(`Authenticated server-info check returned ${response.statusCode}`) + } + + let parsed: unknown + try { + parsed = JSON.parse(response.body) + } catch { + throw new Error('Authenticated server-info response was not valid JSON') + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('Authenticated server-info response was not an object') + } + const info = parsed as Record + if (info.runtime !== 'rust') { + throw new Error(`Rust server-info runtime must be "rust", received ${JSON.stringify(info.runtime)}`) + } + if (typeof info.commit !== 'string' || info.commit.length === 0) { + throw new Error('Rust server-info did not include build provenance (commit)') + } +} + +function childHasExited(child: ChildProcess): boolean { + return child.exitCode != null || child.signalCode != null +} + +export function createServerSpawner(): ServerSpawner { + let childProcess: ChildProcess | null = null + let running = false + let processExited = false + return { async start(options: ServerSpawnerOptions): Promise { - // Kill existing process if running (double-start idempotent) - if (childProcess && running) { + if (childProcess) { await this.stop() } - const { spawn: spawnMode, port, configDir } = options + const { resources, port } = options const timeoutMs = options.healthCheckTimeoutMs ?? 30_000 - - let cmd: string - let args: string[] - const env: Record = { - ...process.env as Record, + const inheritedEnv: Record = { ...process.env } + // Do not let Electron's Node-only module lookup/runtime mode leak into + // the standalone Rust process. Keep normal process values (PATH, HOME, + // and platform-specific variables) intact. + delete inheritedEnv.NODE_PATH + delete inheritedEnv.NODE_ENV + // AUTH_TOKEN must come from the app-bound config directory's `.env`. + // An inherited shell token would take precedence over dotenv loading + // and could make the browser's configured token fail authentication. + delete inheritedEnv.AUTH_TOKEN + const env: Record = { + ...inheritedEnv, PORT: String(port), + FRESHELL_HOME: resources.homeDir, + FRESHELL_CLIENT_DIR: resources.clientDir, + FRESHELL_CLAUDE_NODE: resources.claudeNodeBinary, + FRESHELL_CLAUDE_SIDECAR: resources.claudeSidecarEntry, + FRESHELL_MCP_NODE: resources.mcpNodeBinary, + FRESHELL_MCP_ENTRY: resources.mcpEntry, } - - if (spawnMode.mode === 'production') { - cmd = spawnMode.nodeBinary - args = [spawnMode.serverEntry] - env.NODE_ENV = 'production' - // native-modules first so recompiled node-pty wins over server-node-modules copy - env.NODE_PATH = [ - spawnMode.nativeModulesDir, - spawnMode.serverNodeModulesDir, - ].join(path.delimiter) - } else { - cmd = spawnMode.tsxPath - args = ['tsx', spawnMode.serverSourceEntry] - // Explicitly remove NODE_ENV for dev mode (process.env may have it set) - delete env.NODE_ENV - } - - // Ensure log directory exists - const logDir = path.join(configDir, 'logs') - try { - fs.mkdirSync(logDir, { recursive: true }) - } catch { - // Ignore - } - - childProcess = spawn(cmd, args, { + fs.mkdirSync(resources.logDir, { recursive: true }) + const spawned = spawn(resources.serverBinary, [], { env, - cwd: configDir, + cwd: resources.configDir, stdio: ['ignore', 'pipe', 'pipe'], detached: false, }) - + childProcess = spawned running = true processExited = false - startCloseHandler = () => { - running = false + const markExited = () => { processExited = true + running = false + if (childProcess === spawned) childProcess = null } + spawned.once('close', markExited) + spawned.once('error', markExited) - childProcess.on('close', startCloseHandler) - childProcess.on('error', startCloseHandler) - - // Pipe to log file try { - const logStream = fs.createWriteStream(path.join(logDir, 'server.log'), { flags: 'a' }) - childProcess.stdout?.pipe(logStream) - childProcess.stderr?.pipe(logStream) + const logStream = fs.createWriteStream(path.join(resources.logDir, 'server.log'), { flags: 'a' }) + spawned.stdout?.pipe(logStream) + spawned.stderr?.pipe(logStream) } catch { - // Ignore log errors + // Logging must not prevent the app-bound server from starting. } - await pollHealthCheck(port, timeoutMs) + await pollHealthCheck(port, timeoutMs, () => processExited) + const authToken = options.authToken ?? readAuthToken(resources.configDir) + await verifyRustServerInfo(port, authToken, () => processExited) }, - async stop(): Promise { - if (!childProcess) return - + async stop(options: ServerStopOptions = {}): Promise { const proc = childProcess - childProcess = null - - // Remove the close/error handlers registered during start() - // so they don't fire alongside the stop() handler below. - if (startCloseHandler) { - proc.removeListener('close', startCloseHandler) - proc.removeListener('error', startCloseHandler) - startCloseHandler = null + if (!proc) { + running = false + return } - return new Promise((resolve) => { - // SIGKILL fallback after 5s - const killTimeout = setTimeout(() => { + const gracefulTimeoutMs = options.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS + const forceTimeoutMs = options.forceTimeoutMs ?? DEFAULT_FORCE_TIMEOUT_MS + + await new Promise((resolve, reject) => { + let settled = false + let gracefulTimer: ReturnType | undefined + let forceTimer: ReturnType | undefined + + const finish = (error?: Error) => { + if (settled) return + settled = true + if (gracefulTimer) clearTimeout(gracefulTimer) + if (forceTimer) clearTimeout(forceTimer) + proc.removeListener('close', onExit) + proc.removeListener('error', onExit) + if (error) reject(error) + else resolve() + } + + const onExit = () => { + if (childProcess === proc) { + childProcess = null + running = false + processExited = true + } + finish() + } + + proc.once('close', onExit) + proc.once('error', onExit) + if (childHasExited(proc)) { + onExit() + return + } + + try { + proc.kill('SIGTERM') + } catch { + // The forced escalation below still targets this exact child. + } + + gracefulTimer = setTimeout(() => { + if (settled) return + if (childHasExited(proc)) { + onExit() + return + } try { proc.kill('SIGKILL') } catch { - // Ignore -- process may have already exited + // The second bounded deadline reports the inability to stop it. } - running = false - resolve() - }, 5000) - - // Use once() so this handler auto-removes after firing - proc.once('close', () => { - clearTimeout(killTimeout) - running = false - resolve() - }) - - proc.kill('SIGTERM') + forceTimer = setTimeout(() => { + if (settled) return + if (childHasExited(proc)) { + onExit() + return + } + finish(new Error(`Server process ${proc.pid ?? 'unknown'} did not exit after SIGKILL`)) + }, forceTimeoutMs) + }, gracefulTimeoutMs) }) }, diff --git a/electron/setup-wizard/wizard-logic.ts b/electron/setup-wizard/wizard-logic.ts index 978d010ec..a46b4cf7e 100644 --- a/electron/setup-wizard/wizard-logic.ts +++ b/electron/setup-wizard/wizard-logic.ts @@ -4,7 +4,8 @@ * which requires a single React instance (problematic in git worktrees). */ -export type ServerMode = 'daemon' | 'app-bound' | 'remote' +export const SERVER_MODES = ['app-bound', 'remote'] as const +export type ServerMode = typeof SERVER_MODES[number] export interface WizardConfig { serverMode: ServerMode @@ -60,7 +61,7 @@ export function canAdvance( if (serverMode === 'remote') { return validateUrl(remoteUrl) } - if (serverMode === 'daemon' || serverMode === 'app-bound') { + if (serverMode === 'app-bound') { return validatePort(port) } } diff --git a/electron/setup-wizard/wizard.tsx b/electron/setup-wizard/wizard.tsx index f2c2e83b4..193dfb784 100644 --- a/electron/setup-wizard/wizard.tsx +++ b/electron/setup-wizard/wizard.tsx @@ -44,7 +44,7 @@ export function Wizard({ onComplete }: WizardProps) { // Validate current step before proceeding if (step === 'configuration') { if (serverMode === 'remote' && !validateUrl(remoteUrl)) return - if ((serverMode === 'daemon' || serverMode === 'app-bound') && !validatePort(port)) return + if (serverMode === 'app-bound' && !validatePort(port)) return } if (currentStep < STEPS.length - 1) { setCurrentStep(currentStep + 1) @@ -124,25 +124,6 @@ export function Wizard({ onComplete }: WizardProps) { - -