diff --git a/.dockerignore b/.dockerignore index 6a371b9c9..d2b3a8b8d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ # Build artifacts (rebuilt in Docker — never copy from host) +.worktrees/ node_modules/ dist/ target/ @@ -7,6 +8,12 @@ playwright-report/ coverage/ .nyc_output/ +# Retired backend source/test roots must not enter the Rust-only source image. +/server/ +/test/server/ +/test/unit/server/ +/test/integration/server/ + # Version control and CI .git/ .github/ @@ -27,7 +34,6 @@ coverage/ docs/plans/ docs/development/ installers/ -electron/ examples/ # port/ stays excluded (7.4M oracle corpus belongs to the config-excluded # vitest.port.config.ts suites), EXCEPT the tiny 80K fixture dir the @@ -41,6 +47,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 + +# Only the root app sources; test/**/electron contains runtime evidence. +/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..8c3d27934 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -7,6 +7,12 @@ playwright-report/ coverage/ .nyc_output/ +# Retired backend source/test roots must not enter the Rust-only source image. +/server/ +/test/server/ +/test/unit/server/ +/test/integration/server/ + # Version control and CI .git/ .github/ @@ -28,7 +34,6 @@ coverage/ docs/plans/ docs/development/ installers/ -electron/ examples/ # port/ stays excluded (7.4M oracle corpus belongs to the config-excluded # vitest.port.config.ts suites), EXCEPT the tiny 80K fixture dir the @@ -42,6 +47,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 + +# Only the root app sources; test/**/electron contains runtime evidence. +/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..d78a4b9a8 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -5,42 +5,118 @@ on: tags: ['v*'] pull_request: paths: + - '.github/workflows/electron-build.yml' + - '.github/workflows/electron-release.yml' + - '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' + - 'config/vitest/vitest.electron.config.ts' + - 'test/unit/electron/**' + - 'test/integration/electron/**' + - 'scripts/electron-dev-prerequisites.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 + runtime: release/mac/Freshell.app/Contents/Resources + - os: macos-latest + installer: release/*.dmg + runtime: release/mac-arm64/Freshell.app/Contents/Resources + - os: ubuntu-latest + installer: |- + release/*.AppImage + release/*.deb + runtime: release/linux-unpacked/resources + - os: windows-2022 + installer: release/*.exe + runtime: release/win-unpacked/resources 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 + env: + FRESHELL_ELECTRON_RUNTIME_DIR: ${{ matrix.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..e76812744 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -4,33 +4,93 @@ 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 + runtime: release/mac/Freshell.app/Contents/Resources + - os: macos-latest + installer: release/*.dmg + runtime: release/mac-arm64/Freshell.app/Contents/Resources + - os: ubuntu-latest + installer: |- + release/*.AppImage + release/*.deb + runtime: release/linux-unpacked/resources + - os: windows-2022 + installer: release/*.exe + runtime: release/win-unpacked/resources 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 + env: + FRESHELL_ELECTRON_RUNTIME_DIR: ${{ matrix.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 a6daf1a70..eaf2bb9ca 100644 --- a/.github/workflows/rust-clippy.yml +++ b/.github/workflows/rust-clippy.yml @@ -39,20 +39,20 @@ 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 rust-test: runs-on: ubuntu-latest @@ -78,7 +78,7 @@ 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 # freshell-freshagent and freshell-ws tests spawn MCP servers that resolve tsx from node_modules. - uses: actions/setup-node@v4 @@ -100,6 +100,15 @@ jobs: FRESHELL_SERVER_BIN: ${{ github.workspace }}/target/debug/freshell-server run: cargo test --workspace --locked --no-fail-fast + # Keep the source-runtime and browser-selection contracts in the same + # required Rust validation lane as the workspace tests. Both checks are + # intentionally non-vacuous: they fail if the command selects no tests. + - name: Source-runtime smoke + run: npm run test:source-runtime + + - name: Browser selection non-vacuity + run: npm run test:e2e:helpers -- helpers/selection-nonvacuity.test.ts + # Dedicated Tauri smoke with --nocapture so the CI log visibly shows # "using server binary:" (confirming the test exercised the real binary, # not soft-skipped). The grep enforces non-vacuity: if the binary is ever 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 335a74ed0..f696d2fe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session organizer. It provides multi-tab terminal management with support for terminals and coding CLI's like Claude Code and Codex. Key features include session history browsing, AI-powered summaries (via Google Gemini), and remote access over LAN/VPN with token-based authentication. +## Active Repair Plan + +PR699 follow-up: [Rust-only integration repair plan](docs/superpowers/plans/2026-09-04-pr699-integration-repair.md). + ## Development Philosophy - We are working on an infinite schedule with infinite tokens. This is unusual! We do not have time pressure, and can do things correctly. - We use Red-Green-Refactor TDD for all changes but the most trivial (e.g. doc changes). We never skip the tests, and never skip the refactor. @@ -21,8 +25,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,8 +36,8 @@ 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`. -- Ambient proxy vars (`HTTP(S)_PROXY`, either case) and `FRESHELL_BIND_HOST` are stripped at vitest config load by `config/vitest/sanitize-test-env.ts` (imported first by every config EXCEPT the two real-provider smoke configs); local test runs do not need env pre-stripping. +- `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. +- Ambient proxy vars (HTTP(S)_PROXY, either case) and FRESHELL_BIND_HOST are stripped by config/vitest/sanitize-test-env.ts at Vitest config load, including source-runtime and packaged-runtime lanes. The exact FRESHELL_RUN_REAL_PROVIDER_CONTRACTS=1 escape hatch preserves proxy egress but still removes FRESHELL_BIND_HOST. ## 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 -- ""`). @@ -58,20 +62,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: @@ -118,31 +121,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 @@ -150,20 +158,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 ``` @@ -207,21 +215,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 @@ -241,13 +263,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). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy and marks the pane `stuck`, and the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions; the deadman never fabricates a turn-complete (no green/chime). `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 ef50799dd..d12fc0f10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1407,6 +1407,7 @@ name = "freshell-sessions" version = "0.1.0" dependencies = [ "chrono", + "freshell-platform", "libc", "notify", "regex", diff --git a/README.md b/README.md index 826b7aba8..f5710b37c 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. - **Host pressure dashboard pane** — CPU, memory, pressure, and I/O at a glance with near-zero overhead (metrics stream only while you're watching). Linux, WSL, and macOS only — not shown on Windows. @@ -45,124 +45,46 @@ 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. -## Desktop profiles (multiple instances) - -The desktop app normally runs one instance with one configuration. **Profiles** -let you run multiple independent desktop clients on the same machine at the -same time — for example one connected to your work server and one to a -personal server. - -Each named profile gets its own: - -- settings, window state, and logs (`~/.freshell-/`; the default profile - keeps using `~/.freshell/`) -- Electron storage dir (`…/Freshell-` in packaged builds, - `freshell-` in dev/unpackaged runs), so cookies and localStorage never - mix -- single-instance lock: launching the same profile twice focuses the running - window; different profiles run side by side - -### Defining profiles - -Create `~/.freshell/profiles.json`: - -```json -{ - "profiles": [ - { "id": "work", "label": "Work" }, - { "id": "home" } - ] -} -``` - -Rules: `id` is lowercase letters/digits/dashes starting with a letter or digit -(max 32 chars); `default` and `profile-picker` are reserved (the first means -the original un-namespaced environment; the second is the picker launcher's -own storage dir); `label` is optional display text. - -When at least one named profile is defined, launching the app without a -profile shows a picker (the default profile is always listed first; the built- -in default counts, so one named profile in the file already means "more than -one configured"). The picker is a small launcher: whichever profile you pick, -the app relaunches itself pinned to it — you'll see a quick restart, then the -app continues in the chosen profile. Pin a launch to a profile with -`--profile=` or `FRESHELL_PROFILE=`; named ids do not have to be -listed in `profiles.json` — an unlisted id simply starts with a fresh -configuration. - -### Notes and limitations - -- Global hotkey: the first instance to register an accelerator keeps it; - later instances log a warning (`global_hotkey_registration_failed`) and have - no hotkey. Give each profile a distinct hotkey in its own settings. -- App-bound servers: each profile spawns its own server pinned to that - profile's config dir (`FRESHELL_CONFIG_DIR`) and port — a named profile - never adopts another profile's already-running local server; choose a - distinct port per profile. Once named profiles exist (listed in - `profiles.json`, used from the command line, or previously run — including - a stray `~/.freshell-` backup dir, which shape-checks by name), the same - applies to the **Default** profile: it no longer auto-attaches to a - discovered local server, and if its configured port is held by a neighbor, - Freshell bumps to the next free port and saves that port into the profile's - settings (visible in the setup summary). An app-bound profile that finds - its OWN config dir's server already resident attaches to it instead of - double-spawning. -- Daemon services (`freshell.service`, `com.freshell.server`, - "Freshell Server" task) are machine-global single instances — daemon mode is - available only on the **Default** profile; named profiles fall back to the - chooser instead. -- Silent-install provisioning (`desktop.provision`) applies to the default - profile only. -- Auto-update relaunches the app without `--profile`: after an update, the - picker shows again (pick your profile back). -- Installing/upgrading on Windows terminates all running Freshell instances. -- Relaunching while a profile is running: on Linux/Windows, a launch without a - flag shows the picker again and choosing the running profile focuses its - window; launching with the same `--profile` as a running instance focuses - that window (the new process quits). On macOS, relaunching from Finder or - the Dock while ANY Freshell instance is running just activates the running - instance (the OS enforces this) and never shows the picker — use - `--profile=` flags or `FRESHELL_PROFILE` from a terminal, or Quit before - relaunching to get the picker. Two simultaneous flag-less launches race for - the picker's launcher slot: the first shows the picker; the second quietly - exits and brings the existing picker forward. -- Daemon-service caveat for the Node server: the shipped daemon templates have - always contained an (until now inert) `FRESHELL_CONFIG_DIR` environment - line; starting with this release the Node server honors it. If you - hand-generated a daemon unit from those templates with a non-default config - directory, the value now takes effect at next start (state relocates to that - directory): remove the line from your unit, or move your existing - `~/.freshell` contents into the directory it names. Units using the default - `~/.freshell` path are unaffected — and if your service's working directory - is not the config dir (systemd user units default to `$HOME`), the server - copies an existing `.env` from the old location into the config dir rather - than rotating your token. Rust-server installs never read this - variable. - ## 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. + ### Fresh agents -Freshclaude, Freshcodex, and Freshopencode share a chat interface with attachments, tool output, questions, and approval controls. Use `/model` or click the model name to choose a model and thinking level. Changes apply to your next message; the picker remembers recent choices for each project. +Freshclaude, Freshcodex, and Freshopencode share a chat interface with tool output, questions, and approval controls. Use `/model` or click the model name to choose a model and thinking level. Changes apply to your next message; the picker remembers recent choices for each project. You can queue follow-up messages while an agent works. They run one at a time, and the queue stays available if the session disconnects or ends. Expand the queue to read or cancel individual messages. Codex permission settings control when it asks for approval; “Never ask” does not change the session’s file or network access limits. @@ -222,9 +144,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` | @@ -235,8 +160,11 @@ Then unplug and replug the deck. Without the rule, the connection status shows " | `GEMINI_CMD` | No | Gemini CLI command override | | `KIMI_CMD` | No | Kimi CLI command override | | `AMPLIFIER_CMD` | No | Amplifier CLI command override | -| `FRESHELL_AUTO_RESUME_IDENTITY_GRACE_MS` | No | Comma-separated identity-grace recheck delays before a crashed agent pane settles (default: `2500,2500` — 5s total); set to empty to disable | | `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 @@ -258,23 +186,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 e4d3698d6..537f707b2 100644 --- a/config/electron-builder.yml +++ b/config/electron-builder.yml @@ -13,55 +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: - "**/*" - # Profile picker assets (loaded from the real filesystem before connecting) - - from: dist/profile-picker - to: profile-picker + - from: electron-runtime/node-client-runtime + to: node-client-runtime filter: - "**/*" - # Pruned server runtime dependencies (see prepare-bundled-node.ts Step 4) - - from: server-node-modules - to: server-node-modules + # 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) @@ -69,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/build-id.ts b/config/vite/build-id.ts new file mode 100644 index 000000000..aa5e5e35d --- /dev/null +++ b/config/vite/build-id.ts @@ -0,0 +1,15 @@ +import { execFileSync } from 'node:child_process' + +/** Match the Rust compile-time stamp; git-less bundles leave reload detection inert. */ +export function computeClientBuildId(cwd: string): string { + try { + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5_000, + }).toString().trim() + return /^[0-9a-f]{40}$/.test(sha) ? sha : 'unknown' + } catch { + return 'unknown' + } +} 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..af15ea673 100644 --- a/config/vite/vite.config.ts +++ b/config/vite/vite.config.ts @@ -3,33 +3,14 @@ import type { HttpProxy } from 'vite' 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' +import { computeClientBuildId } from './build-id.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const projectRoot = path.resolve(__dirname, '../..') -/** - * The client's build identity: the git commit the bundle was built from, - * matching the server-side stamps (`crates/freshell-ws/build.rs` / - * `server/build-id.ts` + `scripts/bake-server-build-id.mjs`). `"unknown"` - * fallback — the client's compare rule ignores `"unknown"` on both sides. - */ -function computeClientBuildId(): string { - try { - const sha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: projectRoot, - stdio: ['ignore', 'pipe', 'ignore'], - }) - .toString() - .trim() - return /^[0-9a-f]{40}$/.test(sha) ? sha : 'unknown' - } catch { - return 'unknown' - } -} - /** * Transport-level proxy failures that mean "the backend is down or restarting": * refused (not yet listening), reset/pipe (killed mid-request), timeout/host @@ -64,7 +45,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}` @@ -78,7 +60,7 @@ export default defineConfig(({ mode }) => { plugins: [react()], define: { __PERF_LOGGING__: JSON.stringify(env.PERF_LOGGING || ''), - __FRESHELL_BUILD_ID__: JSON.stringify(computeClientBuildId()), + __FRESHELL_BUILD_ID__: JSON.stringify(computeClientBuildId(projectRoot)), }, resolve: { alias: { @@ -93,7 +75,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/vite/vite.profile-picker.config.ts b/config/vite/vite.profile-picker.config.ts deleted file mode 100644 index b07074f5f..000000000 --- a/config/vite/vite.profile-picker.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import path from 'path' -import { fileURLToPath } from 'url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const projectRoot = path.resolve(__dirname, '../..') - -export default defineConfig({ - plugins: [react()], - root: path.resolve(projectRoot, 'electron/profile-picker'), - base: './', - build: { - outDir: path.resolve(projectRoot, 'dist/profile-picker'), - emptyOutDir: true, - sourcemap: true, - }, - server: { - port: 5179, - }, - resolve: { - alias: { - '@electron': path.resolve(projectRoot, './electron'), - }, - }, -}) 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 0d7e20ec3..000000000 --- a/config/vitest/vitest.codex-real-provider-smoke.config.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Deliberately NOT importing ./sanitize-test-env.js: this config's package -// script does not set FRESHELL_RUN_REAL_PROVIDER_CONTRACTS=1, and its tests -// spawn real provider CLIs that may need ambient proxy egress on some hosts. - -// 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 87a505bcf..b32cb6ec8 100644 --- a/config/vitest/vitest.config.ts +++ b/config/vitest/vitest.config.ts @@ -16,6 +16,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, @@ -31,24 +32,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..1550cbb28 --- /dev/null +++ b/config/vitest/vitest.electron-runtime.config.ts @@ -0,0 +1,29 @@ +import './sanitize-test-env.js' +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, + // The checkout-free runtime test runs in the canonical sandbox, whose PID + // limit is intentionally bounded. Vitest's default fork pool eagerly + // creates one worker per host CPU and can exhaust that limit before the + // acceptance test reports a result. + pool: 'threads', + testTimeout: 120_000, + hookTimeout: 120_000, + }, +}) diff --git a/config/vitest/vitest.electron.config.ts b/config/vitest/vitest.electron.config.ts index c5b78bbd9..f7bdcf464 100644 --- a/config/vitest/vitest.electron.config.ts +++ b/config/vitest/vitest.electron.config.ts @@ -20,6 +20,9 @@ export default defineConfig({ }, test: { environment: 'node', + // Keep Electron workers in-process. The canonical sandbox caps PIDs, and + // the default fork pool can exhaust that limit before it reports results. + pool: 'threads', include: [ 'test/unit/electron/**/*.test.ts', 'test/unit/electron/**/*.test.tsx', 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 6fda1baba..000000000 --- a/config/vitest/vitest.opencode-serve-real-provider-smoke.config.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Deliberately NOT importing ./sanitize-test-env.js: this config's package -// script does not set FRESHELL_RUN_REAL_PROVIDER_CONTRACTS=1, and its tests -// spawn real provider CLIs that may need ambient proxy egress on some hosts. - -// 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 f37326a59..000000000 --- a/config/vitest/vitest.oracle-t2.config.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Strip ambient shell env (proxies, FRESHELL_BIND_HOST) before anything else — see sanitize-test-env.ts. -import './sanitize-test-env.js' - -// 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 956f31cbb..cf1339e8c 100644 --- a/config/vitest/vitest.oracle.config.ts +++ b/config/vitest/vitest.oracle.config.ts @@ -17,16 +17,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..962a75b48 --- /dev/null +++ b/config/vitest/vitest.runtime.config.ts @@ -0,0 +1,34 @@ +import './sanitize-test-env.js' +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 559f0b13b..000000000 --- a/config/vitest/vitest.server.config.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Strip ambient shell env (proxies, FRESHELL_BIND_HOST) before anything else — see sanitize-test-env.ts. -import './sanitize-test-env.js' - -// 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-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 1219ca7c5..bfa21ee5d 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -5571,7 +5571,7 @@ async fn read_created( /// Resolve the sidecar entry (`index.mjs`). `FRESHELL_CLAUDE_SIDECAR` overrides; otherwise /// the vendored package sits beside this crate at `crates/freshell-claude-sidecar/index.mjs` /// (baked from `CARGO_MANIFEST_DIR` so it is cwd-independent). -fn sidecar_entry_path() -> PathBuf { +pub(crate) fn sidecar_entry_path() -> PathBuf { if let Ok(path) = std::env::var("FRESHELL_CLAUDE_SIDECAR") { if !path.is_empty() { return PathBuf::from(path); @@ -14139,20 +14139,16 @@ rl.on('line', (line) => { mark_compact_candidate(&in_turn, &turn_tracker); confirm_compact_candidate(&in_turn, &turn_tracker, true); - // Let the fixture `tee` fully drain the pipe before freezing — a - // partially-consumed pipe would park the fill loop short of the - // helper's full-buffer assertion (its 64KiB threshold assumes an empty - // pipe: armrace/armfail freeze before any handler write). - tokio::time::sleep(Duration::from_millis(300)).await; // Park C2's write mid-window. let pid = freeze_fixture_stdin(&st, "rb-armfail-gar").await; - let driver = { - let st = st.clone(); - tokio::spawn(async move { - st.handle_compact(compact_msg("rb-armfail-gar", None)).await; - }) - }; - tokio::time::sleep(Duration::from_millis(300)).await; + let driver = st.handle_compact(compact_msg("rb-armfail-gar", None)); + tokio::pin!(driver); + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut driver) + .await + .is_err(), + "C2's compact write is pending while the reader is stopped" + ); // C1's terminal edge folds mid-window: retires the promoted C1 — the // gate stays closed with C2's armed entry + S1 still owed. @@ -14169,10 +14165,9 @@ rl.on('line', (line) => { 0, "SIGKILL the fixture child — the parked write fails" ); - tokio::time::timeout(Duration::from_secs(15), driver) + tokio::time::timeout(Duration::from_secs(15), &mut driver) .await - .expect("the failed write resolves the handler") - .expect("the compact task joins"); + .expect("the failed write resolves the handler"); assert!( in_turn.load(std::sync::atomic::Ordering::SeqCst), "ep2-r2 F3: the failed arm's undo never releases the gate while S1 is owed" @@ -14247,7 +14242,7 @@ rl.on('line', (line) => { arm_turn_op(&s.in_turn, &s.turn_tracker, TrackedOp::Turn); } - /// ep1-r3 F3 rig: SIGSTOP the fixture's `tee` and FILL its stdin pipe, so + /// ep1-r3 F3 rig: SIGSTOP the fixture's reader and FILL its stdin pipe, so /// the next `write_line` parks INSIDE the write await (a deterministic, /// harness-pausable "mid-write" window) until the child resumes. Returns /// the child's pid for the later SIGCONT/SIGKILL. @@ -14261,31 +14256,111 @@ rl.on('line', (line) => { 0, "SIGSTOP the fixture child" ); - // A stopped reader never drains: fill the kernel pipe buffer until a - // write parks (the elbow timeout elapses) — the NEXT write_line parks - // INSIDE the write await. (ChildStdin has no userspace buffer, so a - // parked write means the KERNEL pipe is full; the per-iteration - // timeout IS the full-pipe signal — deterministic, no guessing.) + // A stopped reader never drains. Fill in chunks first, then exhaust + // any residual space one byte at a time: a blocked chunk alone does + // not prove that the smaller compact request cannot fit. ChildStdin + // has no userspace buffer, and a timed-out single-byte write proves + // backpressure without assuming the pipe's capacity or initial fill. + // `write` is cancellation-safe and reports partial writes; a cancelled + // `write_all` could instead hide bytes accepted before the timeout. use tokio::io::AsyncWriteExt as _; let junk = [b'x'; 4096]; - let mut filled = 0usize; - loop { - match tokio::time::timeout(Duration::from_millis(100), session.stdin.write_all(&junk)) - .await - { - Ok(Ok(())) => filled += junk.len(), - Ok(Err(e)) => panic!("the stdin fill failed: {e}"), - Err(_elapsed) => break, + for chunk in [&junk[..], &junk[..1]] { + loop { + match tokio::time::timeout(Duration::from_millis(100), session.stdin.write(chunk)) + .await + { + Ok(Ok(n)) if n > 0 => {} + result => { + assert!( + result.is_err(), + "the {}-byte fill must stop on backpressure: {result:?}", + chunk.len() + ); + break; + } + } } } - assert!( - filled >= 65536, - "the classic 64KiB pipe accepted a full buffer before refusing ({filled})" - ); drop(guard); pid } + /// A one-page pipe must provide the same parked compact-write window as + /// a larger pipe. Exercise the real fixture and handler, then verify that + /// the request reaches the reader only after it resumes. + #[cfg(target_os = "linux")] + #[tokio::test] + async fn a_small_fixture_pipe_blocks_compact_until_the_reader_resumes() { + use std::os::fd::AsRawFd as _; + + let (st, _rx) = state_with_bus(); + let stdin_log = + insert_rollback_fixture_session_no_probe(&st, "rb-small-pipe", "dur-small-pipe").await; + { + let guard = st.sessions.lock().await; + let session = guard.get("rb-small-pipe").expect("tracked session"); + // Only this test's empty, owned pipe changes. Linux rounds the + // request up to one page, including on hosts with larger pages. + let capacity = unsafe { libc::fcntl(session.stdin.as_raw_fd(), libc::F_SETPIPE_SZ, 1) }; + assert!( + capacity > 0, + "shrink the owned pipe: {}", + std::io::Error::last_os_error() + ); + assert_eq!(capacity as libc::c_long, unsafe { + libc::sysconf(libc::_SC_PAGESIZE) + }); + } + let pid = freeze_fixture_stdin(&st, "rb-small-pipe").await; + let (in_turn, turn_tracker) = busy_tracker_arcs(&st, "rb-small-pipe").await; + { + // Keep polling the SAME future after the timeout: cancellation + // must not restart the compact or submit a duplicate request. + let compact = st.handle_compact(compact_msg("rb-small-pipe", None)); + tokio::pin!(compact); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut compact) + .await + .is_err(), + "the real compact write stays pending while the reader is stopped" + ); + assert!(in_turn.load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!( + turn_tracker.lock().expect("turn tracker lock").running, + Some(TrackedOp::Compact), + "the compact reached its write await after arming the tracker" + ); + assert!( + st.sessions.try_lock().is_err(), + "the pending write holds the session lock" + ); + assert_eq!(std::fs::read(&stdin_log).unwrap_or_default(), b""); + assert_eq!(unsafe { libc::kill(pid as libc::pid_t, libc::SIGCONT) }, 0); + tokio::time::timeout(Duration::from_secs(15), &mut compact) + .await + .expect("the compact write completes once the owned reader resumes"); + } + + // Close the writer and reap this fixture before reading its complete + // byte log. kill_on_drop also owns cleanup if an earlier assert fails. + let mut session = st.sessions.lock().await.remove("rb-small-pipe").unwrap(); + drop(session.stdin); + assert!( + tokio::time::timeout(Duration::from_secs(15), session.child.wait()) + .await + .unwrap() + .unwrap() + .success() + ); + let received = std::fs::read_to_string(stdin_log).unwrap(); + assert_eq!( + serde_json::from_str::(received.trim_start_matches('x')).unwrap(), + json!({ "type": "send", "sessionId": "rb-small-pipe", "text": "/compact" }), + "the reader receives exactly one complete compact request after the fill bytes" + ); + } + /// ep1-r3 F3 CORE — the arm/await race: the stdout consumer folds terminal /// events WITHOUT the turn lock, so the queued compact's tracker MUST be /// armed BEFORE the sidecar write await — otherwise the prior turn's @@ -14311,13 +14386,14 @@ rl.on('line', (line) => { // The compact queues behind the running prior turn — and parks INSIDE // the write await (the stopped child never drains a full pipe). - let driver = { - let st = st.clone(); - tokio::spawn(async move { - st.handle_compact(compact_msg("rb-armrace", None)).await; - }) - }; - tokio::time::sleep(Duration::from_millis(300)).await; + let driver = st.handle_compact(compact_msg("rb-armrace", None)); + tokio::pin!(driver); + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut driver) + .await + .is_err(), + "the compact write is pending while the reader is stopped" + ); fold_terminal_edge(&in_turn, &turn_tracker); { let tracker = turn_tracker.lock().expect("turn tracker lock"); @@ -14343,10 +14419,9 @@ rl.on('line', (line) => { 0, "SIGCONT the fixture child" ); - tokio::time::timeout(Duration::from_secs(15), driver) + tokio::time::timeout(Duration::from_secs(15), &mut driver) .await - .expect("the parked compact write completes once the child resumes") - .expect("the compact task joins"); + .expect("the parked compact write completes once the child resumes"); assert!(in_turn.load(std::sync::atomic::Ordering::SeqCst)); assert_eq!( turn_tracker.lock().expect("turn tracker lock").queued.len(), @@ -14402,13 +14477,14 @@ rl.on('line', (line) => { // mid-window would deadlock the rig. let (in_turn, turn_tracker) = busy_tracker_arcs(&st, "rb-armfail").await; - let driver = { - let st = st.clone(); - tokio::spawn(async move { - st.handle_compact(compact_msg("rb-armfail", None)).await; - }) - }; - tokio::time::sleep(Duration::from_millis(300)).await; + let driver = st.handle_compact(compact_msg("rb-armfail", None)); + tokio::pin!(driver); + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut driver) + .await + .is_err(), + "the compact write is pending while the reader is stopped" + ); // The prior turn's terminal edge folds mid-window: retires the running // turn; the armed compact's queued entry survives (busy holds). fold_terminal_edge(&in_turn, &turn_tracker); @@ -14420,10 +14496,9 @@ rl.on('line', (line) => { 0, "SIGKILL the fixture child — the parked write fails" ); - tokio::time::timeout(Duration::from_secs(15), driver) + tokio::time::timeout(Duration::from_secs(15), &mut driver) .await - .expect("the failed write resolves the handler") - .expect("the compact task joins"); + .expect("the failed write resolves the handler"); // The frame is LOUD (the compact failure surfaces as INTERNAL_ERROR). let frame = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; diff --git a/crates/freshell-freshagent/src/model_capabilities.rs b/crates/freshell-freshagent/src/model_capabilities.rs index 23eb5623d..5ee0aa48c 100644 --- a/crates/freshell-freshagent/src/model_capabilities.rs +++ b/crates/freshell-freshagent/src/model_capabilities.rs @@ -17,7 +17,6 @@ //! agent turn or modifies an existing session. use std::collections::HashMap; -use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; @@ -164,10 +163,7 @@ fn claude_probe_error(message: impl Into) -> CapabilityError { impl ModelCatalogProbe for ClaudeCatalogProbe { fn probe<'a>(&'a self, _cwd: Option<&'a str>) -> BoxFuture<'a, CatalogOut> { Box::pin(async move { - let entry = PathBuf::from(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../freshell-claude-sidecar/model-catalog.mjs" - )); + let entry = crate::claude::sidecar_entry_path().with_file_name("model-catalog.mjs"); let node = std::env::var("FRESHELL_CLAUDE_NODE").unwrap_or_else(|_| "node".into()); let mut command = tokio::process::Command::new(node); command diff --git a/crates/freshell-freshagent/src/model_capabilities_tests.rs b/crates/freshell-freshagent/src/model_capabilities_tests.rs index 050498fac..efca765f0 100644 --- a/crates/freshell-freshagent/src/model_capabilities_tests.rs +++ b/crates/freshell-freshagent/src/model_capabilities_tests.rs @@ -8,6 +8,7 @@ use axum::body::Body; use axum::http::Request; use serde_json::json; use std::collections::VecDeque; +use std::path::PathBuf; use tokio::sync::Notify; use tower::util::ServiceExt; @@ -372,6 +373,61 @@ fn claude_catalog_keeps_live_effort_choices_and_deduplicates_models() { assert!(normalize_claude_catalog(json!([{"displayName": "No id"}])).is_err()); } +/// The installed Electron runtime relocates the sidecar away from the source +/// checkout. A relative SDK seam must therefore be resolved by the copied +/// model-catalog helper, not by the source-tree helper baked into the binary. +#[tokio::test] +async fn claude_catalog_probe_uses_the_configured_sidecar_directory() { + let _guard = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; + let directory = tempfile::tempdir().expect("temporary sidecar directory"); + let sidecar_entry = directory.path().join("index.mjs"); + let catalog_entry = directory.path().join("model-catalog.mjs"); + let sdk_name = "task-b-relocated-sdk.mjs"; + let sdk_entry = directory.path().join(sdk_name); + + std::fs::write(&sidecar_entry, "export {}\n").expect("write sidecar entry"); + std::fs::copy( + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../freshell-claude-sidecar/model-catalog.mjs" + )), + &catalog_entry, + ) + .expect("copy model catalog helper"); + std::fs::write( + &sdk_entry, + r#" +export function query() { + return { + supportedModels: async () => [{ + value: 'task-b-relocated-model', + displayName: 'Task B Relocated Model', + supportedEffortLevels: ['medium'], + supportsAdaptiveThinking: true, + }], + close: async () => {}, + } +} +"#, + ) + .expect("write fake SDK module"); + + std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &sidecar_entry); + std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); + std::env::set_var("FRESHELL_CLAUDE_SDK_QUERY_MODULE", format!("./{sdk_name}")); + let result = ClaudeCatalogProbe.probe(None).await; + std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); + std::env::remove_var("FRESHELL_CLAUDE_NODE"); + std::env::remove_var("FRESHELL_CLAUDE_SDK_QUERY_MODULE"); + + let models = result.expect("configured sidecar catalog should be probed"); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "task-b-relocated-model"); + assert_eq!(models[0].display_name, "Task B Relocated Model"); + assert_eq!(models[0].supported_effort_levels, vec!["medium"]); + assert!(models[0].supports_adaptive_thinking); +} + // ── route level (model-capabilities-router.ts ports) ───────────────────────── fn app_with_probe(probe: Arc) -> Router { diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 2f0f731c6..1e20d1042 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -3197,13 +3197,29 @@ mod tests { #[tokio::test] async fn create_host_stats_tab_attaches_host_stats_pane_content_and_no_terminal() { let state = state_with_registry(); + let registry = state.terminal_registry.clone().unwrap(); + assert!(registry.inventory().is_empty()); let mut rx = state.broadcast_tx.subscribe(); - let (status, body) = - post(app(state), "/api/tabs", json!({ "hostStats": true }), true).await; + + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({ "hostStats": true }), + true, + ) + .await; assert_eq!(status, StatusCode::OK); assert!(body["data"]["tabId"].as_str().is_some()); - assert!(body["data"]["paneId"].as_str().is_some()); + let pane_id = body["data"]["paneId"].as_str().expect("created pane id"); assert!(body["data"].get("terminalId").is_none()); + assert!(registry.inventory().is_empty()); + + let pane = state + .layout + .get_pane_snapshot(pane_id) + .expect("stored pane"); + assert_eq!(pane.kind.as_deref(), Some("host-stats")); + assert!(pane.terminal_id.is_none()); let frame = rx.recv().await.expect("ui.command frame broadcast"); let msg: Value = serde_json::from_str(&frame).unwrap(); diff --git a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs index 4ade9152f..fc9ca6ca3 100644 --- a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs +++ b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs @@ -10,10 +10,10 @@ //! This test spawns the REAL `index.mjs` source with `node` and drives its stdin //! directly. The `@anthropic-ai/claude-agent-sdk` dependency is vendored via //! `npm install` into the sidecar package's own node_modules and is NOT present -//! in a plain checkout/CI, so the test copies the real sidecar modules (`index.mjs` -//! and its Task 1 sibling `permission-channel.mjs`) VERBATIM into a temp dir with a +//! in a plain checkout/CI, so the test copies the real sidecar entrypoint and its +//! local helper modules VERBATIM into a temp dir with a //! stub `node_modules/@anthropic-ai/claude-agent-sdk` that satisfies only the -//! top-level `import { query }` (the interrupt-dispatch path under test never calls +//! top-level SDK import (the interrupt-dispatch path under test never calls //! `query()`; the stub throws if it is called). Only module RESOLUTION is redirected //! — every dispatched line of JS is the real source, read at test time. //! @@ -30,7 +30,7 @@ use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::Duration; -/// Stub SDK entry: satisfies `import { query } from '@anthropic-ai/claude-agent-sdk'` +/// Stub SDK entry: supplies `query` for the sidecar's SDK import /// without the vendored dependency. The interrupt-dispatch path never calls it. const STUB_SDK_INDEX: &str = "export function query() {\n throw new Error('test stub: query() must not be called by the interrupt-dispatch test')\n}\n"; @@ -42,9 +42,8 @@ const STUB_SDK_PACKAGE_JSON: &str = r#"{ } "#; -/// Read one real sidecar module verbatim (`index.mjs` or its Task 1 sibling -/// `permission-channel.mjs`, which `index.mjs` imports by relative path — both must -/// be present in the staged dir for ESM resolution to succeed). +/// Read one real sidecar module verbatim. The entrypoint and every local helper +/// it imports must be present in the staged dir for ESM resolution to succeed. fn real_sidecar_source(module: &str) -> String { let path = format!( "{}/../freshell-claude-sidecar/{module}", @@ -57,21 +56,14 @@ fn real_sidecar_source(module: &str) -> String { #[test] fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { let dir = tempfile::tempdir().expect("create temp dir"); - std::fs::write( - dir.path().join("index.mjs"), - real_sidecar_source("index.mjs"), - ) - .expect("copy real index.mjs verbatim"); - std::fs::write( - dir.path().join("permission-channel.mjs"), - real_sidecar_source("permission-channel.mjs"), - ) - .expect("copy real permission-channel.mjs verbatim"); - std::fs::write( - dir.path().join("session-settings.mjs"), - real_sidecar_source("session-settings.mjs"), - ) - .expect("copy real session-settings.mjs verbatim"); + for module in [ + "index.mjs", + "permission-channel.mjs", + "session-settings.mjs", + ] { + std::fs::write(dir.path().join(module), real_sidecar_source(module)) + .unwrap_or_else(|e| panic!("copy real {module} verbatim: {e}")); + } let sdk_dir = dir .path() .join("node_modules/@anthropic-ai/claude-agent-sdk"); 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..b1e66be50 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,35 @@ 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()?, + }) + } + + fn is_windows_host(&self) -> bool { + false + } + + fn convert_to_unix_path(&self, _windows_path: &str) -> Result { + Err(McpInjectError::new("WSL path conversion is unavailable.")) + } + + fn wsl_env(&self) -> Option { + None + } } /// The live runtime (see the module-level U1 decision). @@ -107,36 +137,140 @@ impl McpRuntime for RealMcpRuntime { convert_to_windows_path_live(linux_path) } + fn is_windows_host(&self) -> bool { + cfg!(windows) + } + + fn convert_to_unix_path(&self, windows_path: &str) -> Result { + let program = std::env::var("WSL_EXE").unwrap_or_else(|_| "wsl.exe".to_string()); + let mut args = Vec::new(); + if let Ok(distro) = std::env::var("WSL_DISTRO") { + if !distro.is_empty() { + args.extend(["-d".to_string(), distro]); + } + } + args.extend(["--exec", "wslpath", "-u", windows_path].map(str::to_string)); + run_path_conversion( + &program, + &args.iter().map(String::as_str).collect::>(), + ) + .ok_or_else(|| { + McpInjectError::new(format!( + "Unable to convert MCP path for WSL: {windows_path}" + )) + }) + } + + fn wsl_env(&self) -> Option { + std::env::var("WSLENV").ok() + } + 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(), - )]); - } - // `require.resolve('tsx')` resolves the package export "." → - // `./dist/loader.mjs` (rev 2 pin vs node_modules/tsx/package.json). - let tsx = repo_root.join("node_modules/tsx/dist/loader.mjs"); - if !tsx.is_file() { - return Err(McpInjectError::new( - "Unable to resolve MCP dependency \"tsx\". Ensure project dependencies are installed.", - )); + return Ok(McpServerCommand { + command: McpServerArg::Literal("node".to_string()), + args: vec![McpServerArg::Path(built.to_string_lossy().into_owned())], + }); } - Ok(vec![ + let search_path = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + source_mcp_server_command(&repo_root, self.is_windows_host(), &search_path) + } +} + +fn mcp_loader_spec(loader: &Path) -> String { + // A UNC server is the file URL authority, not part of its pathname. + // Reuse the existing path encoder only for the share-relative pathname. + let normalized = loader.to_string_lossy().replace('\\', "/"); + if let Some((server, path)) = normalized + .strip_prefix("//") + .and_then(|unc| unc.split_once('/')) + { + let pathname = format!("/{path}"); + let encoded = crate::opencode_plugin::plugin_file_spec(Path::new(&pathname)); + let encoded_path = encoded + .strip_prefix("file://") + .expect("file URL has a scheme"); + return format!("file://{server}{encoded_path}"); + } + crate::opencode_plugin::plugin_file_spec(loader) +} + +fn source_mcp_server_command( + repo_root: &Path, + is_windows_host: bool, + search_path: &[PathBuf], +) -> Result { + let tsx = repo_root.join("node_modules/tsx/dist/loader.mjs"); + if !tsx.is_file() { + return Err(McpInjectError::new( + "Unable to resolve MCP dependency \"tsx\". Ensure project dependencies are installed.", + )); + } + // tsx includes a platform-specific esbuild binary. A Windows checkout's + // loader must run under Windows Node even when the provider lives in WSL. + let command = if is_windows_host { + let executable = search_path + .iter() + .map(|directory| directory.join("node.exe")) + .find(|candidate| candidate.is_file()) + .ok_or_else(|| { + McpInjectError::new( + "Unable to find Windows node.exe on PATH for the MCP TypeScript loader.", + ) + })?; + let executable = std::path::absolute(executable) + .map_err(|error| McpInjectError::new(error.to_string()))?; + McpServerArg::Path(executable.to_string_lossy().into_owned()) + } else { + McpServerArg::Literal("node".to_string()) + }; + // Node's ESM --import accepts Windows absolute imports as file URLs, not + // bare drive paths. This URL must survive provider-side path conversion. + let loader = if is_windows_host { + McpServerArg::Literal(mcp_loader_spec(&tsx)) + } else { + McpServerArg::Path(tsx.to_string_lossy().into_owned()) + }; + Ok(McpServerCommand { + command, + args: vec![ McpServerArg::Literal("--import".to_string()), - McpServerArg::Path(tsx.to_string_lossy().into_owned()), + loader, McpServerArg::Path( repo_root - .join("server/mcp/server.ts") + .join("tools/freshell-mcp/server.ts") .to_string_lossy() .into_owned(), ), - ]) - } + ], + }) } /// `findRepoRoot` (`cw:21-32`): walk up (max 5) looking for a `package.json` @@ -164,62 +298,168 @@ 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 { + run_path_conversion(program, &["-w", linux_path]).unwrap_or_else(|| linux_path.to_string()) +} + +fn run_path_conversion(program: &str, args: &[&str]) -> Option { + 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") - .arg("-w") - .arg(linux_path) + let child = Command::new(program) + .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn(); - let Ok(child) = child else { - return linux_path.to_string(); + let Ok(mut child) = child else { + return None; }; - 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 None; + }; + 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 None; } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = join_reader_until(reader, deadline); + return None; + } + } + }; + let Some(Ok(output)) = join_reader_until(reader, deadline) else { + return None; + }; + + if status.success() { + let converted = String::from_utf8_lossy(&output).trim().to_string(); + if converted.is_empty() { + None + } else { + Some(converted) } - // Failure or timeout (the reader thread reaps the child either way). - _ => linux_path.to_string(), + } else { + None } } -/// `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( +fn provider_path( rt: &dyn McpRuntime, target: ProviderTarget, -) -> Result, 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 - } - } + value: &str, +) -> Result { + if target == ProviderTarget::Unix && rt.is_windows_host() { + rt.convert_to_unix_path(value) + } else if target == ProviderTarget::Windows && rt.is_wsl_environment() { + Ok(rt.convert_to_windows_path(value)) + } else { + Ok(value.to_string()) + } +} + +fn mcp_wsl_env(existing: Option) -> String { + const CONTEXT: [&str; 4] = [ + "FRESHELL_URL", + "FRESHELL_TOKEN", + "FRESHELL_TAB_ID", + "FRESHELL_PANE_ID", + ]; + let mut entries = existing + .as_deref() + .unwrap_or_default() + .split(':') + .filter(|entry| { + !entry.is_empty() && !CONTEXT.contains(&entry.split('/').next().unwrap_or_default()) }) - .collect()) + .map(str::to_string) + .collect::>(); + entries.extend(CONTEXT.map(str::to_string)); + entries.join(":") +} + +/// 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<(String, Vec), McpInjectError> { + let command = rt.server_command()?; + // A packaged Windows Node remains a Windows process even when a WSL + // provider launches it. Translate only the executable for Linux exec; + // Node's own arguments must stay Windows paths. WSLENV carries endpoint, + // auth, and pane context across that second process boundary. + if target == ProviderTarget::Unix && rt.is_windows_host() { + if let McpServerArg::Path(executable) = &command.command { + if executable.as_bytes().get(1) == Some(&b':') || executable.starts_with("\\\\") { + let mut args = vec![ + format!("WSLENV={}", mcp_wsl_env(rt.wsl_env())), + rt.convert_to_unix_path(executable)?, + ]; + args.extend(command.args.into_iter().map(|arg| match arg { + McpServerArg::Literal(value) | McpServerArg::Path(value) => value, + })); + return Ok(("env".to_string(), args)); + } + } + } + let convert = |arg: McpServerArg| -> Result { + match arg { + McpServerArg::Literal(value) => Ok(value), + McpServerArg::Path(value) => provider_path(rt, target, &value), + } + }; + Ok(( + convert(command.command)?, + command + .args + .into_iter() + .map(convert) + .collect::, _>>()?, + )) } /// `tomlEscape` (`cw:142-144`): wrap in `"` with `\` → `\\` and `"` → `\"`. @@ -231,6 +471,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 +484,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,20 +534,18 @@ 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, } } }); - write_json_0600(&file_path, &config)?; let path_str = file_path.to_string_lossy().into_owned(); - if target == ProviderTarget::Windows && rt.is_wsl_environment() { - return Ok(rt.convert_to_windows_path(&path_str)); - } + let path_str = provider_path(rt, target, &path_str)?; + write_json_0600(&file_path, &config)?; Ok(path_str) } @@ -477,12 +724,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()) @@ -539,7 +786,7 @@ pub fn generate_mcp_injection( cwd: Option<&str>, target: ProviderTarget, ) -> Result { - match mode { + let mut injection = match mode { "claude" => { let file_path = write_mcp_config_file(rt, terminal_id, target)?; Ok(McpInjection { @@ -548,9 +795,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(), }) } @@ -568,8 +815,20 @@ pub fn generate_mcp_injection( }) } "opencode" => opencode_inject(rt, cwd, target), - _ => Ok(McpInjection::default()), + _ => return Ok(McpInjection::default()), + }?; + if target == ProviderTarget::Unix && rt.is_windows_host() { + // wsl.exe does not automatically forward arbitrary Windows environment + // variables. Pass context and any provider-specific config selectors + // into the WSL provider; selectors are already translated above. + let mut shared = mcp_wsl_env(rt.wsl_env()); + for name in injection.env.keys() { + shared.push(':'); + shared.push_str(name); + } + injection.env.insert("WSLENV".to_string(), shared); } + Ok(injection) } /// `cleanupMcpConfig` (`cw:429-448`): best-effort tmp-file unlink (claude/ diff --git a/crates/freshell-platform/src/mcp_inject_tests.rs b/crates/freshell-platform/src/mcp_inject_tests.rs index 6c3780d67..b7d9cc315 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,328 @@ fn fake_rt(tmp: &Path, wsl: bool) -> FakeRt { } } +struct WindowsPackagedRt { + tmp: PathBuf, + command: McpServerArg, + conversion_fails: bool, +} + +impl McpRuntime for WindowsPackagedRt { + fn tmp_dir(&self) -> PathBuf { + self.tmp.clone() + } + fn is_wsl_environment(&self) -> bool { + false + } + fn is_windows_host(&self) -> bool { + true + } + fn convert_to_windows_path(&self, path: &str) -> String { + path.to_string() + } + fn convert_to_unix_path(&self, path: &str) -> Result { + if self.conversion_fails { + return Err(McpInjectError::new("WSL conversion failed")); + } + if path.starts_with("C:\\") { + Ok(path.replace("C:\\", "/mnt/c/").replace('\\', "/")) + } else { + Ok(format!("/wsl{path}")) + } + } + fn wsl_env(&self) -> Option { + Some("USERPROFILE/p:FRESHELL_TOKEN/u".to_string()) + } + fn server_command_args(&self) -> Result, McpInjectError> { + Ok(vec![McpServerArg::Path( + "C:\\Freshell Runtime\\tools\\server.js".to_string(), + )]) + } + fn server_command(&self) -> Result { + Ok(McpServerCommand { + command: self.command.clone(), + args: self.server_command_args()?, + }) + } +} + +#[test] +fn windows_packaged_mcp_runs_from_wsl_without_translating_windows_node_arguments() { + let scratch = Scratch::new("windows-packaged-wsl"); + let rt = WindowsPackagedRt { + tmp: scratch.path().to_path_buf(), + command: McpServerArg::Path("C:\\Freshell Runtime\\node.exe".to_string()), + conversion_fails: false, + }; + let (command, args) = build_mcp_server_command(&rt, ProviderTarget::Unix).unwrap(); + assert_eq!(command, "env"); + assert_eq!( + args, + vec![ + "WSLENV=USERPROFILE/p:FRESHELL_URL:FRESHELL_TOKEN:FRESHELL_TAB_ID:FRESHELL_PANE_ID", + "/mnt/c/Freshell Runtime/node.exe", + "C:\\Freshell Runtime\\tools\\server.js", + ] + ); + + let (native_command, native_args) = + build_mcp_server_command(&rt, ProviderTarget::Windows).unwrap(); + assert_eq!(native_command, "C:\\Freshell Runtime\\node.exe"); + assert_eq!(native_args, vec!["C:\\Freshell Runtime\\tools\\server.js"]); +} + +#[test] +fn windows_built_mcp_using_unix_node_translates_its_script_path() { + let scratch = Scratch::new("windows-source-wsl"); + let rt = WindowsPackagedRt { + tmp: scratch.path().to_path_buf(), + command: McpServerArg::Literal("node".to_string()), + conversion_fails: false, + }; + assert_eq!( + build_mcp_server_command(&rt, ProviderTarget::Unix).unwrap(), + ( + "node".to_string(), + vec!["/mnt/c/Freshell Runtime/tools/server.js".to_string()], + ) + ); +} + +#[test] +fn source_mcp_uses_native_windows_node_for_host_installed_tsx() { + let scratch = Scratch::new("windows-source-native-node"); + let loader = scratch.path().join("node_modules/tsx/dist/loader.mjs"); + std::fs::create_dir_all(loader.parent().unwrap()).unwrap(); + std::fs::write(&loader, "export {};").unwrap(); + let node_dir = scratch.path().join("Windows Node Runtime"); + std::fs::create_dir_all(&node_dir).unwrap(); + let native_node = node_dir.join("node.exe"); + std::fs::write(&native_node, "native Windows node fixture").unwrap(); + + let selected = source_mcp_server_command(scratch.path(), true, &[node_dir]).unwrap(); + assert_eq!( + selected.command, + McpServerArg::Path(native_node.to_string_lossy().into_owned()) + ); + assert_eq!( + selected.args, + vec![ + McpServerArg::Literal("--import".to_string()), + McpServerArg::Literal(crate::opencode_plugin::plugin_file_spec(&loader)), + McpServerArg::Path( + scratch + .path() + .join("tools/freshell-mcp/server.ts") + .to_string_lossy() + .into_owned() + ), + ] + ); +} + +#[test] +fn windows_unc_mcp_loader_urls_keep_the_server_as_the_authority() { + for (path, expected) in [ + ( + r"\\server\share\repo\loader.mjs", + "file://server/share/repo/loader.mjs", + ), + ( + r"\\server\share\My Project #1\loader.mjs", + "file://server/share/My%20Project%20%231/loader.mjs", + ), + ( + "//server/share/repo/loader.mjs", + "file://server/share/repo/loader.mjs", + ), + ] { + assert_eq!(mcp_loader_spec(Path::new(path)), expected); + } +} + +#[test] +fn local_mcp_loader_urls_preserve_drive_and_posix_paths() { + for (path, expected) in [ + ( + r"C:\Program Files\Freshell\loader.mjs", + "file:///C:/Program%20Files/Freshell/loader.mjs", + ), + ( + "/tmp/My Project #1/loader.mjs", + "file:///tmp/My%20Project%20%231/loader.mjs", + ), + ] { + assert_eq!(mcp_loader_spec(Path::new(path)), expected); + } +} + +#[test] +fn windows_source_mcp_refuses_to_mix_unix_node_with_windows_dependencies() { + let scratch = Scratch::new("windows-source-no-native-node"); + let loader = scratch.path().join("node_modules/tsx/dist/loader.mjs"); + std::fs::create_dir_all(loader.parent().unwrap()).unwrap(); + std::fs::write(loader, "export {};").unwrap(); + assert!(source_mcp_server_command(scratch.path(), true, &[]).is_err()); + assert_eq!( + source_mcp_server_command(scratch.path(), false, &[]) + .unwrap() + .command, + McpServerArg::Literal("node".to_string()) + ); +} + +#[test] +fn windows_to_wsl_mcp_refuses_failed_path_conversion() { + let scratch = Scratch::new("windows-wsl-conversion-error"); + let rt = WindowsPackagedRt { + tmp: scratch.path().to_path_buf(), + command: McpServerArg::Path("C:\\Freshell Runtime\\node.exe".to_string()), + conversion_fails: true, + }; + assert!(build_mcp_server_command(&rt, ProviderTarget::Unix).is_err()); +} + +#[test] +fn windows_to_wsl_json_providers_receive_readable_config_paths_and_packaged_commands() { + let scratch = Scratch::new("windows-wsl-json-providers"); + let rt = WindowsPackagedRt { + tmp: scratch.path().to_path_buf(), + command: McpServerArg::Path("C:\\Freshell Runtime\\node.exe".to_string()), + conversion_fails: false, + }; + for mode in ["claude", "gemini", "kimi"] { + let injection = + generate_mcp_injection(&rt, mode, mode, None, ProviderTarget::Unix).unwrap(); + let native_path = tmp_file_path(&rt, mode); + let expected_path = rt + .convert_to_unix_path(native_path.to_str().unwrap()) + .unwrap(); + let provider_path = if mode == "gemini" { + &injection.env["GEMINI_CLI_SYSTEM_DEFAULTS_PATH"] + } else { + &injection.args[1] + }; + assert_eq!( + provider_path, &expected_path, + "{mode} must read its config inside WSL" + ); + let config: serde_json::Value = + serde_json::from_slice(&std::fs::read(native_path).unwrap()).unwrap(); + assert_eq!(config["mcpServers"]["freshell"]["command"], "env"); + assert_eq!( + config["mcpServers"]["freshell"]["args"][1], + "/mnt/c/Freshell Runtime/node.exe" + ); + assert_eq!( + config["mcpServers"]["freshell"]["args"][2], + "C:\\Freshell Runtime\\tools\\server.js" + ); + let forwarded = injection.env["WSLENV"].split(':').collect::>(); + for name in [ + "FRESHELL_URL", + "FRESHELL_TOKEN", + "FRESHELL_TAB_ID", + "FRESHELL_PANE_ID", + ] { + assert!( + forwarded.contains(&name), + "{mode} must receive {name} from the Windows server" + ); + } + if mode == "gemini" { + assert!(forwarded.contains(&"GEMINI_CLI_SYSTEM_DEFAULTS_PATH")); + } + } +} + +#[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 +409,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 +467,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,31 +479,23 @@ 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\"]" ); } -/// G-W1's injection half — native-Windows host + `target='unix'` (the WSL -/// branch): NO conversion gate fires (`isWslEnvironment()` false), so the -/// HOST-FORM (Windows) paths ride into the unix-target args verbatim — -/// faithful reference wart (spec §2.6). +/// Codex's inline TOML must carry paths its WSL-side Node can read. #[test] -fn g_w1_native_windows_host_unix_target_keeps_windows_paths() { +fn codex_native_windows_host_unix_target_uses_wsl_script_paths() { let scratch = Scratch::new("gw1"); - let rt = FakeRt { + let rt = WindowsPackagedRt { tmp: scratch.path().to_path_buf(), - wsl: false, // native Windows host: isWslEnvironment() is false - args: vec![ - McpServerArg::Literal("--import".to_string()), - McpServerArg::Path("C:\\repo\\node_modules\\tsx\\dist\\loader.mjs".to_string()), - McpServerArg::Path("C:\\repo\\server\\mcp\\server.ts".to_string()), - ], + command: McpServerArg::Literal("node".to_string()), + conversion_fails: false, }; let inj = generate_mcp_injection(&rt, "codex", "term1", None, ProviderTarget::Unix).unwrap(); assert_eq!( inj.args[3], - "mcp_servers.freshell.args=[\"C:\\\\repo\\\\node_modules\\\\tsx\\\\dist\\\\loader.mjs\", \"C:\\\\repo\\\\server\\\\mcp\\\\server.ts\"]" - .replace("args=[\"C", "args=[\"--import\", \"C") + "mcp_servers.freshell.args=[\"/mnt/c/Freshell Runtime/tools/server.js\"]" ); } @@ -258,7 +572,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/common.rs b/crates/freshell-protocol/src/common.rs index 02d286ad5..a5af31851 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/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/host_stats.rs b/crates/freshell-server/src/host_stats.rs index 370d79045..f11fd865a 100644 --- a/crates/freshell-server/src/host_stats.rs +++ b/crates/freshell-server/src/host_stats.rs @@ -2614,3 +2614,7 @@ mod tests { assert_eq!(snap.manual, Some(recovered.manual)); } } + +#[cfg(test)] +#[path = "host_stats_collection_tests.rs"] +mod collection_tests; diff --git a/crates/freshell-server/src/host_stats_collection_tests.rs b/crates/freshell-server/src/host_stats_collection_tests.rs new file mode 100644 index 000000000..e2294217f --- /dev/null +++ b/crates/freshell-server/src/host_stats_collection_tests.rs @@ -0,0 +1,246 @@ +use super::*; +use std::path::Path; + +fn write_fixture(root: &Path, relative: &str, text: &str) { + let file = root.join(relative); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(file, text).unwrap(); +} + +fn fixture_collector(root: &Path) -> HostStatsCollectorService { + HostStatsCollectorService::new( + HostStatsCollectorConfig { + proc_root: root.join("proc"), + sys_root: root.join("sys"), + ..Default::default() + }, + freshell_terminal::TerminalRegistry::new(), + HostStatsInterestRegistry::default(), + Instant::now(), + ) +} + +#[test] +fn cpu_rates_use_deltas_for_aggregate_steal_and_each_core() { + let root = tempfile::tempdir().unwrap(); + let proc_root = root.path().join("proc"); + write_fixture( + &proc_root, + "stat", + "cpu 100 0 0 900 0 0 0 0\n\ + cpu0 25 0 0 225 0 0 0 0\n\ + cpu1 25 0 0 225 0 0 0 0\n\ + cpu2 25 0 0 225 0 0 0 0\n\ + cpu3 25 0 0 225 0 0 0 0\n", + ); + let collector = fixture_collector(root.path()); + let first = collector.ctx.read_cpu_section(1_000); + assert!(first.available); + assert_eq!(first.usage_pct, 0.0); + assert_eq!(first.steal_pct, Some(0.0)); + assert_eq!(first.per_core_pct, vec![0.0; 4]); + + write_fixture( + &proc_root, + "stat", + "cpu 280 0 0 1700 0 0 0 20\n\ + cpu0 100 0 0 400 0 0 0 0\n\ + cpu1 100 0 0 400 0 0 0 0\n\ + cpu2 100 0 0 400 0 0 0 0\n\ + cpu3 100 0 0 400 0 0 0 0\n", + ); + let next = collector.ctx.read_cpu_section(3_000); + assert!(next.available); + assert_eq!(next.usage_pct, 20.0); + assert_eq!(next.steal_pct, Some(2.0)); + assert_eq!(next.per_core_pct, vec![30.0; 4]); +} + +#[test] +fn paging_rates_convert_page_deltas_over_elapsed_seconds() { + let root = tempfile::tempdir().unwrap(); + let proc_root = root.path().join("proc"); + write_fixture( + &proc_root, + "vmstat", + "pswpin 100\npswpout 40\npgmajfault 50\noom_kill 2\n", + ); + let collector = fixture_collector(root.path()); + let first = collector.ctx.read_paging_section(1_000); + assert!(first.available); + assert_eq!(first.swap_in_kbps, 0.0); + assert_eq!(first.swap_out_kbps, 0.0); + assert_eq!(first.maj_faults_per_sec, 0.0); + assert_eq!(first.oom_kills_delta, 0); + assert_eq!(first.oom_kills_total, 2); + + write_fixture( + &proc_root, + "vmstat", + "pswpin 108\npswpout 44\npgmajfault 70\noom_kill 5\n", + ); + let next = collector.ctx.read_paging_section(3_000); + assert!(next.available); + assert_eq!(next.swap_in_kbps, 16.0); + assert_eq!(next.swap_out_kbps, 8.0); + assert_eq!(next.maj_faults_per_sec, 10.0); + assert_eq!(next.oom_kills_delta, 3); + assert_eq!(next.oom_kills_total, 5); +} + +#[test] +fn disk_rates_convert_sectors_and_compute_utilization_and_await() { + let root = tempfile::tempdir().unwrap(); + let proc_root = root.path().join("proc"); + write_fixture( + &proc_root, + "diskstats", + "8 0 sda 1000 0 100000 4000 2000 0 400000 8000 0 500 0\n", + ); + let collector = fixture_collector(root.path()); + let first = collector.ctx.read_disk_io_section(5_000); + assert!(first.available); + assert_eq!(first.read_bps, 0.0); + assert_eq!(first.write_bps, 0.0); + assert_eq!(first.util_pct, None); + assert_eq!(first.weighted_await_ms, None); + + write_fixture( + &proc_root, + "diskstats", + "8 0 sda 1100 0 151200 6000 2400 0 502400 10000 0 1500 0\n", + ); + let next = collector.ctx.read_disk_io_section(10_000); + assert!(next.available); + assert_eq!(next.read_bps, 5_242_880.0); + assert_eq!(next.write_bps, 10_485_760.0); + assert_eq!(next.util_pct, Some(20.0)); + assert_eq!(next.weighted_await_ms, Some(8.0)); +} + +#[test] +fn network_rates_keep_error_and_drop_totals_and_deltas_distinct() { + let root = tempfile::tempdir().unwrap(); + let proc_root = root.path().join("proc"); + write_fixture( + &proc_root, + "net/dev", + "eth0: 1000000 0 3 2 0 0 0 0 500000 0 1 4 0 0 0 0\n", + ); + let collector = fixture_collector(root.path()); + let first = collector.ctx.read_network_section(5_000); + assert!(first.available); + assert_eq!(first.rx_bps, 0.0); + assert_eq!(first.tx_bps, 0.0); + assert_eq!(first.rx_errors_delta, 0); + assert_eq!(first.tx_errors_delta, 0); + assert_eq!(first.rx_dropped_delta, 0); + assert_eq!(first.tx_dropped_delta, 0); + + write_fixture( + &proc_root, + "net/dev", + "eth0: 1500000 0 5 3 0 0 0 0 600000 0 3 5 0 0 0 0\n", + ); + let next = collector.ctx.read_network_section(10_000); + assert!(next.available); + assert_eq!(next.rx_bps, 100_000.0); + assert_eq!(next.tx_bps, 20_000.0); + assert_eq!(next.rx_errors_total, 5); + assert_eq!(next.tx_errors_total, 3); + assert_eq!(next.rx_dropped_total, 3); + assert_eq!(next.tx_dropped_total, 5); + assert_eq!(next.rx_errors_delta, 2); + assert_eq!(next.tx_errors_delta, 2); + assert_eq!(next.rx_dropped_delta, 1); + assert_eq!(next.tx_dropped_delta, 1); +} + +fn write_host_memory(root: &Path) { + write_fixture( + &root.join("proc"), + "meminfo", + "MemTotal: 64000000 kB\n\ + MemAvailable: 32000000 kB\n\ + SwapTotal: 8000000 kB\n\ + SwapFree: 8000000 kB\n", + ); +} + +#[test] +fn finite_cgroup_memory_wins_without_mixing_host_totals() { + let root = tempfile::tempdir().unwrap(); + write_host_memory(root.path()); + write_fixture( + &root.path().join("proc"), + "self/cgroup", + "0::/freshell-test\n", + ); + let cgroup = root.path().join("sys/fs/cgroup/freshell-test"); + write_fixture(&cgroup, "memory.max", "8000000000\n"); + write_fixture(&cgroup, "memory.current", "500000000\n"); + + let memory = fixture_collector(root.path()).ctx.read_memory_section(); + assert!(memory.available); + assert_eq!(memory.source, "cgroup"); + assert_eq!(memory.total_bytes, 8_000_000_000); + assert_eq!(memory.used_bytes, 500_000_000); + assert_eq!(memory.available_bytes, 7_500_000_000); + assert_eq!(memory.cgroup_limit_bytes, Some(8_000_000_000)); + assert_eq!(memory.swap_total_bytes, Some(8_000_000 * 1024)); + assert_eq!(memory.swap_used_bytes, Some(0)); +} + +#[test] +fn unlimited_cgroup_memory_uses_host_used_and_available_values() { + let root = tempfile::tempdir().unwrap(); + write_host_memory(root.path()); + write_fixture( + &root.path().join("proc"), + "self/cgroup", + "0::/freshell-test\n", + ); + let cgroup = root.path().join("sys/fs/cgroup/freshell-test"); + write_fixture(&cgroup, "memory.max", "max\n"); + write_fixture(&cgroup, "memory.current", "500000000\n"); + + let memory = fixture_collector(root.path()).ctx.read_memory_section(); + assert!(memory.available); + assert_eq!(memory.source, "host"); + assert_eq!(memory.total_bytes, 64_000_000 * 1024); + assert_eq!(memory.used_bytes, 32_000_000 * 1024); + assert_eq!(memory.available_bytes, 32_000_000 * 1024); + assert_eq!(memory.cgroup_limit_bytes, None); + assert_eq!(memory.swap_total_bytes, Some(8_000_000 * 1024)); + assert_eq!(memory.swap_used_bytes, Some(0)); +} + +#[test] +fn absent_cgroup_memory_uses_host_meminfo() { + let root = tempfile::tempdir().unwrap(); + write_host_memory(root.path()); + + let memory = fixture_collector(root.path()).ctx.read_memory_section(); + assert!(memory.available); + assert_eq!(memory.source, "host"); + assert_eq!(memory.total_bytes, 64_000_000 * 1024); + assert_eq!(memory.used_bytes, 32_000_000 * 1024); + assert_eq!(memory.available_bytes, 32_000_000 * 1024); + assert_eq!(memory.cgroup_limit_bytes, None); + assert_eq!(memory.swap_total_bytes, Some(8_000_000 * 1024)); + assert_eq!(memory.swap_used_bytes, Some(0)); +} + +#[test] +fn missing_memory_sources_produce_an_unavailable_full_shape() { + let root = tempfile::tempdir().unwrap(); + let memory = fixture_collector(root.path()).ctx.read_memory_section(); + + assert!(!memory.available); + assert_eq!(memory.total_bytes, 0); + assert_eq!(memory.used_bytes, 0); + assert_eq!(memory.available_bytes, 0); + assert_eq!(memory.cgroup_limit_bytes, None); + assert_eq!(memory.swap_total_bytes, None); + assert_eq!(memory.swap_used_bytes, None); +} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 5273ce6c7..028fbb3e2 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -1895,10 +1895,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 23499a842..7e8a76132 100644 --- a/crates/freshell-server/src/session_directory.rs +++ b/crates/freshell-server/src/session_directory.rs @@ -218,6 +218,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)); @@ -1340,6 +1349,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(), @@ -1370,6 +1421,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..9850104c6 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 @@ -56,7 +60,6 @@ uuid = { version = "1", features = ["v4"] } [target.'cfg(unix)'.dev-dependencies] # amplifier_stub's rollback-on-partial-write test needs a deterministic, -# thread-scoped (not process-global) permission-denied write failure: -# `unshare(CLONE_FS)` + `umask()`. Same version freshell-ws already pins -# directly for `pane_ledger`'s `flock(2)`. +# child-process-scoped permission-denied write failure using `umask()`. +# Same version freshell-ws already pins directly for `pane_ledger`'s `flock(2)`. libc = "0.2" diff --git a/crates/freshell-sessions/src/amplifier_stub.rs b/crates/freshell-sessions/src/amplifier_stub.rs index 03449dfc9..f00f9576e 100644 --- a/crates/freshell-sessions/src/amplifier_stub.rs +++ b/crates/freshell-sessions/src/amplifier_stub.rs @@ -625,36 +625,86 @@ mod tests { assert_eq!(meta2["freshell_terminal_id"], "term-1"); } + #[cfg(unix)] #[test] fn ensure_session_rolls_back_the_directory_on_a_partial_write_failure() { - // FIX (council-mandated rollback): a partial write failure (e.g. - // ENOSPC/permissions) after create_dir_all succeeded must not leave - // a metadata-less directory behind -- `stub_is_unused` conservatively - // KEEPS an unparseable/missing metadata.json forever (never - // GC-able), and a LATER `ensure_session` call for the same id would - // silently ADOPT such a half-written dir via the bare - // `candidate.is_dir()` "found" check above, treating broker litter - // as a legitimate session. - // - // Injection: this function's "found" check treats ANY pre-existing - // directory at the session leaf as legitimate (see the test above), - // so the write failure can only be injected via the mode the LEAF - // gets at creation time -- not via any pre-arranged file/dir at that - // exact path. We pre-create every ancestor NORMALLY (writable) up to - // (not including) the leaf, then run just the `ensure_session` call - // on a DEDICATED thread with `unshare(CLONE_FS)` + a restrictive - // umask: `unshare(CLONE_FS)` gives that one thread its own private - // fs_struct (root/cwd/umask) per `man 2 unshare`, so the umask flip - // cannot leak into the process-wide umask and flake unrelated - // concurrent tests. umask 0o222 makes the freshly-created leaf - // directory mode 0o555 (r-xr-xr-x): create_dir_all still succeeds - // (mkdir only needs write+execute on the PARENT, which stays - // normal), but writing metadata.json into the new leaf fails - // (EACCES -- the leaf itself now lacks the write bit), while the - // leaf remains readable+executable so the rollback's own - // `remove_dir_all` (which must read_dir an empty leaf before - // rmdir-ing it) can still succeed. - let home = unique_temp_home("rollback"); + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::time::{Duration, Instant}; + + const CHILD_HOME: &str = "FRESHELL_AMPLIFIER_ROLLBACK_TEST_HOME"; + const COMPLETED: &str = "rollback-assertions-completed"; + + fn wait_for_child( + child: &mut Child, + timeout: Duration, + ) -> std::io::Result> { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + // umask is process-global. Re-execute only this test in an owned + // child so the permission injection cannot affect concurrent tests. + // Unlike unshare(CLONE_FS), this works under the standard sandbox's + // syscall restrictions and on other Unix platforms. + let home = match std::env::var_os(CHILD_HOME) { + Some(home) => PathBuf::from(home), + None => { + let home = unique_temp_home("rollback"); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "amplifier_stub::tests::ensure_session_rolls_back_the_directory_on_a_partial_write_failure", + "--test-threads=1", + "--nocapture", + ]) + .env(CHILD_HOME, &home) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("start the isolated rollback test"); + let result = wait_for_child(&mut child, Duration::from_secs(30)); + let status = match result { + Ok(Some(status)) => status, + failed => { + // Kill/reap only the child we own. Neither a stalled + // test nor cleanup may leave an unbounded wait. + let kill_result = child.kill(); + let reaped = wait_for_child(&mut child, Duration::from_secs(5)); + assert!( + matches!(reaped, Ok(Some(_))), + "rollback child cleanup failed: kill={kill_result:?}, reap={reaped:?}" + ); + panic!("rollback child did not finish: {failed:?}"); + } + }; + assert!(status.success(), "isolated rollback test failed: {status}"); + // A stale --exact selector must not turn zero child tests + // into a passing parent test. + assert!( + home.join(COMPLETED).is_file(), + "rollback assertions did not run" + ); + std::fs::remove_dir_all(&home).unwrap(); + return; + } + }; + + // A failed write must not leave a metadata-less session directory: + // GC would keep it, and a later ensure_session would adopt it. + // Pre-create only the writable ancestors so ensure_session must + // create the leaf itself. In this child, umask 0o222 gives the new + // leaf mode 0o555: mkdir succeeds using the writable parent, but + // writing metadata.json fails with EACCES. The leaf stays readable + // and searchable, so remove_dir_all can roll it back via its parent. let cwd_dir = home.join("workdir"); std::fs::create_dir_all(&cwd_dir).unwrap(); let canonical = std::fs::canonicalize(&cwd_dir).unwrap(); @@ -669,37 +719,17 @@ mod tests { "precondition: the session leaf must not pre-exist" ); - let home_for_thread = home.clone(); - let cwd_str = cwd_dir.to_str().unwrap().to_string(); - let session_id_owned = session_id.to_string(); - let result = std::thread::spawn(move || { - // SAFETY: unshare(CLONE_FS) only detaches THIS thread's - // fs_struct (root/cwd/umask) from the rest of the process, per - // `man 2 unshare`; it takes no pointers and cannot violate - // memory safety. Scoped to this one throwaway test thread, - // which exits immediately after, so no isolation is left - // dangling either. - let rc = unsafe { libc::unshare(libc::CLONE_FS) }; - assert_eq!( - rc, - 0, - "unshare(CLONE_FS) failed: {}", - std::io::Error::last_os_error() - ); - // SAFETY: umask() only reads/writes this (now-private) thread's - // umask and returns the prior value; no pointers involved. - let prior = unsafe { libc::umask(0o222) }; - let outcome = ensure_session( - &home_for_thread, - &session_id_owned, - &cwd_str, - "term-rollback", - ); - unsafe { libc::umask(prior) }; - outcome - }) - .join() - .expect("rollback-injection thread panicked"); + // SAFETY: umask takes no pointers. This child runs only this test, + // and its umask cannot change the parent or concurrent test processes. + let prior = unsafe { libc::umask(0o222) }; + let result = ensure_session( + &home, + session_id, + cwd_dir.to_str().unwrap(), + "term-rollback", + ); + // SAFETY: restore the child's original mask before testing the retry. + unsafe { libc::umask(prior) }; let err = result.expect_err("a write into a mode-555 leaf must fail"); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); @@ -718,6 +748,7 @@ mod tests { assert!(expected_dir.join("metadata.json").is_file()); assert!(expected_dir.join("transcript.jsonl").is_file()); assert!(expected_dir.join("events.jsonl").is_file()); + std::fs::write(home.join(COMPLETED), "").unwrap(); } #[test] 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 d706f2815..d26f05349 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 1c7405e36..1347c94e9 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -788,12 +788,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(), @@ -1825,9 +1825,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/freshagent_session_lease.rs b/crates/freshell-ws/tests/freshagent_session_lease.rs index 1a43d8e26..a32342203 100644 --- a/crates/freshell-ws/tests/freshagent_session_lease.rs +++ b/crates/freshell-ws/tests/freshagent_session_lease.rs @@ -75,6 +75,7 @@ impl Drop for IsolatedCodexEnv { #[test] fn isolated_codex_env_restores_every_mutated_variable_during_unwind() { + let _lock = LEASE_ENV_LOCK.blocking_lock(); let original: Vec<_> = ISOLATED_CODEX_ENV_KEYS .iter() .map(std::env::var_os) @@ -83,7 +84,6 @@ fn isolated_codex_env_restores_every_mutated_variable_during_unwind() { let mut installed_home = None; let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _lock = LEASE_ENV_LOCK.blocking_lock(); let _env = IsolatedCodexEnv::install(); installed_opt_in = std::env::var_os("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES"); installed_home = std::env::var_os("CODEX_HOME").map(std::path::PathBuf::from); 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/crates/freshell-ws/tests/restore_spawn_gate.rs b/crates/freshell-ws/tests/restore_spawn_gate.rs index c2ad0228e..e1ba56263 100644 --- a/crates/freshell-ws/tests/restore_spawn_gate.rs +++ b/crates/freshell-ws/tests/restore_spawn_gate.rs @@ -5,6 +5,10 @@ //! `restore == Some(true)` creates -- the restart-storm fleet the gate //! exists for -- are gated. REAL axum server + REAL tokio-tungstenite //! client, the session_identity_frames.rs harness convention. +//! +//! Each test uses two Tokio workers for cross-thread scheduling. Sizing every +//! test runtime to the host's CPU count leaves less of the shared PID budget +//! for the real storm's PTY children, reader threads, and waiter threads. mod common; @@ -343,7 +347,7 @@ fn create_frame(request_id: &str, restore: bool) -> String { } } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn third_non_restore_create_in_window_is_rate_limited() { let cfg = CreateProtectConfig { rate_limit: 2, @@ -372,7 +376,7 @@ async fn third_non_restore_create_in_window_is_rate_limited() { ); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn restore_creates_are_gated_and_non_restore_bypass() { // RESTORE-ONLY gate scope (user decision, PR #552): interactive // (non-restore) creates are latency-visible one-at-a-time human actions @@ -440,7 +444,7 @@ async fn restore_creates_are_gated_and_non_restore_bypass() { ); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn restore_creates_queue_behind_held_permit_and_both_settle() { // Deterministic rework of the former settled-hold race: the TEST holds // the gate's single permit while both restore creates arrive, so "the @@ -513,7 +517,7 @@ async fn restore_creates_queue_behind_held_permit_and_both_settle() { assert_eq!(registry.kill_all(), 2); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn gated_create_racing_shutdown_leaves_no_live_pty() { // A10 (V3, FALSIFIED): main's registry.kill_all() snapshots the id set // ONCE (registry.rs:889-892) with no re-sweep; a detached gated create @@ -546,7 +550,7 @@ async fn gated_create_racing_shutdown_leaves_no_live_pty() { ); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn queued_restore_create_is_abandoned_on_disconnect_without_spawning() { // Zero-permit gate + long timeout: the restore create parks in the queue. let cfg = CreateProtectConfig { @@ -594,7 +598,7 @@ async fn queued_restore_create_is_abandoned_on_disconnect_without_spawning() { assert_eq!(registry.kill_all(), 0, "no PTY may have been spawned"); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn queued_restore_creates_drain_without_spawning_on_shutdown() { let cfg = CreateProtectConfig { spawn_timeout_ms: 30_000, @@ -644,7 +648,7 @@ async fn queued_restore_creates_drain_without_spawning_on_shutdown() { assert_eq!(registry.kill_all(), 0, "no PTY may have been spawned"); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn restore_storm_drains_bounded_with_per_terminal_ordering() { // N restore creates > gate limit: every create must settle with its own // requestId, exactly once, with no duplicate PTYs; and no terminal may @@ -721,7 +725,7 @@ async fn restore_storm_drains_bounded_with_per_terminal_ordering() { assert_eq!(registry.kill_all(), N, "exactly N PTYs, no duplicates"); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn same_requestid_resend_returns_existing_terminal() { // A20: the frozen client re-sends terminal.create with the SAME // requestId on reconnect (TerminalView.tsx:4227-4262; ws-client.ts @@ -749,7 +753,7 @@ async fn same_requestid_resend_returns_existing_terminal() { assert_eq!(registry.kill_all(), 1, "exactly one PTY for one requestId"); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn duplicate_while_queued_does_not_double_spawn() { // Zero-permit gate + long timeout: the first create parks in the gate // queue; a duplicate arriving meanwhile must be swallowed by the @@ -785,7 +789,7 @@ async fn duplicate_while_queued_does_not_double_spawn() { assert_eq!(registry.kill_all(), 0, "no PTY spawned for either copy"); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn resend_on_new_connection_returns_same_terminal() { // The A20 reconnect shape end-to-end: the frozen client re-sends an // unanswered create with the SAME requestId on a NEW connection @@ -814,7 +818,7 @@ async fn resend_on_new_connection_returns_same_terminal() { assert_eq!(registry.kill_all(), 1, "exactly one PTY for one requestId"); } -#[tokio::test(flavor = "multi_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn resend_on_new_connection_never_swallowed_while_inflight() { // The A2 wedge guard: a duplicate landing while the original is in // flight must NEVER be silently dropped -- the original's reply goes to diff --git a/docker/cloud-run/Dockerfile b/docker/cloud-run/Dockerfile index 84e033b8b..4fcd7847d 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,120 +28,86 @@ 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). --chmod=755 is load-bearing: COPY preserves -# the context file's mode, and a umask-0077 checkout bakes 0700 (no group/other -# READ). `RUN chmod +x` only ORs execute bits (0700|0111 = 0711), and a script -# must be READABLE to execute — under USER node (uid 1000) that produced -# "bash: .../e2e-entrypoint.sh: Permission denied" at container start (the -# runtime stage below). Mirrors docker/sandbox/Dockerfile's COPY --chmod=755. +# COPY preserves checkout permissions; use an explicit mode because a private +# checkout can otherwise bake a 0700 script that USER node cannot read. COPY --chmod=755 docker/cloud-run/entrypoint.sh /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. +# Verify that the runtime user can execute and read the shell entrypoint. +RUN test -x /usr/local/bin/e2e-entrypoint.sh \ + && bash -n /usr/local/bin/e2e-entrypoint.sh + 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/index.html b/docs/index.html index 70d5313f1..685f2288c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -857,7 +857,7 @@

Task Board

~/code/freshell
-
Freshcodex and Freshclaude share model selection, attachments, questions, and approvals. Model and thinking changes apply to your next message. /undo rolls conversation turns back without changing your files; Freshclaude and Freshopencode also support /redo.
+
Freshcodex and Freshclaude share model selection, questions, and approvals. Model and thinking changes apply to your next message. /undo rolls conversation turns back without changing your files; Freshclaude and Freshopencode also support /redo.
Diffs
src/components/fresh-agent/FreshAgentView.tsx
You
What layout should Fresh clients use on desktop and phone?
diff --git a/docs/plans/2026-08-26-electron-multi-profile.md b/docs/plans/2026-08-26-electron-multi-profile.md deleted file mode 100644 index 8d80ac978..000000000 --- a/docs/plans/2026-08-26-electron-multi-profile.md +++ /dev/null @@ -1,3180 +0,0 @@ -# Electron Multi-Profile Support Implementation Plan - -> **For agentic workers:** Execute this plan task by task with a fresh -> implementer and a specification-plus-quality review after every task. Track -> progress with the checkbox steps below. - -**Goal:** Two or more Freshell desktop clients can run on one machine at the -same time, each pinned to its own profile (own settings, storage, window -state, possibly different servers), with a picker at launch when a text-file -registry defines more than one choice. - -**Architecture:** A new pure `electron/profile.ts` resolves the active profile -from `--profile=` / `FRESHELL_PROFILE` (precedence: argv > env > picker > -default) and derives per-profile paths: config dir `~/.freshell-` and -Electron userData `/-` (the default profile keeps -today's exact paths). `entry.ts` resolves the profile AND the registry at -module top. Three launcher shapes are possible: - -1. **Explicit** (`--profile`/`FRESHELL_PROFILE`, valid): the process namespaced - userData for the named profile (default keeps today's userData untouched), - acquires the per-profile instance lock once `whenReady` resolves, and boots - normally. The lock is userData-keyed (empirically verified on Electron - 33.4.11), so profiles run side by side. -2. **Picker** (no explicit profile AND the registry names ≥1 profile): the - process namepaces userData to a dedicated **launcher** dir - (`/-profile-picker`) — NEVER the default userData. This - is load-bearing: without it, a picker launch while a Default instance is - resident would have two browser processes sharing one Chromium userData - dir, which Chromium's process-singleton exists to prevent (storage - corruption hazard). The launcher takes its own picker-scoped instance lock - (so a racing flag-less launch focuses the resident picker instead of - stacking duplicate pickers), shows only the picker window, and on ANY - choice — Default included — calls `app.relaunch({ args: [...stripProfileArgs(argv), '--profile='] })` - and exits. The relaunched process is then an EXPLICIT launch (shape 1), - so the chosen profile's lock is acquired in a process whose userData - belongs to exactly that profile. -3. **Plain default** (no explicit profile, no registry profiles): identical - to today's boot — default userData, default lock, no picker. - -A machine-global registry `~/.freshell/profiles.json` (zod-validated) lists -named profiles (the choice set is `[Default, ...profiles]`; "more than one -profile" per the request means the registry makes the choice set exceed one -entry, i.e. ≥1 named profile). App-bound spawned servers receive -`FRESHELL_CONFIG_DIR` whose support is added to `server/freshell-home.ts`, -and the server-side audit (Task 4) routes every profile-scoped state path -through `getFreshellConfigDir` while leaving genuinely machine-level state -(firewall/WSL port bookkeeping, checkout/project-scoped files) deliberately -shared. - -**Tech Stack:** Electron (main process ESM/NodeNext), React 18 + Vite (picker -renderer), Zod, Vitest, Playwright `_electron`. - -## User Request - -> Run two electron clients on the same machine pointed at different servers: fix the blockers (single-instance lock, namespaced userData + config dir per profile) plus a profile picker at launch when more than one profile is configured in a text file. Implement with the-usual. - -## Global Constraints - -- **Backward-compat invariant:** with no `--profile`, no `FRESHELL_PROFILE`, - and no registry file, behavior is identical to today: same paths - (`~/.freshell`, default Electron userData), same boot flow, same windows. The - default profile never calls `app.setPath('userData', ...)` at all. -- **Lock timing policy (load-bearing finding LB-02 + plan-review round 3):** - EVERY browser process holds exactly one userData-keyed instance lock from - `whenReady()` onward — no lock-free picker phase. The one-profile-in-file - threshold is **registry names ≥1 named profile**: Default is an - always-configured choice, so one named entry already makes the configured - choice set exceed one (matching the User Request's "more than one profile is - configured"); every plan/test/README line uses this same threshold. A - non-explicit launch meeting it becomes a **picker launcher**: it sets its - userData to a dedicated launcher dir (`/-profile-picker`, - NEVER the default userData — sharing a Chromium userData between the - launcher and a resident Default instance is a storage-corruption hazard), - acquires the picker-scoped lock, and shows only the picker. A second - flag-less launch is turned away at the picker lock and the resident picker - focuses via `second-instance`. ANY confirmed choice — Default included — - relaunches the app with an explicit `--profile=` and exits, so the - profile's own lock is only ever taken in a process whose userData belongs - to that profile; a choice of a running profile degrades to - focus-the-resident via the normal explicit-duplicate path. -- **Resident surfacing fix:** the resident's `second-instance` handler must - `show()` a tray-hidden window before `focus()` (today it only restores a - minimized window, so a turned-away launch over a tray-hidden Default is a - silent no-op). This fix is required for the turned-away-launch UX and is - done in Task 3. -- Profile id grammar: `^[a-z0-9][a-z0-9-]{0,31}$`; id `default` is reserved - (means today's un-namespaced environment). The registry is machine-global - and always lives at `~/.freshell/profiles.json` (never inside a profile dir). -- An explicit named profile id need NOT be present in the registry; unlisted - ids simply start with a fresh, empty profile. The picker lists `Default` - first, then the registry entries. -- Server code is NodeNext/ESM; relative imports must include `.js` extensions. - New electron modules follow the DI convention (logic in `electron/*.js` - modules, unit-testable without importing `electron`), zod at every file/IPC - boundary. -- Picker UI is accessible: semantic buttons with visible names, heading, and - `role="alert"` for errors (repo a11y requirements). -- Focused test commands go through the repo-owned path: - `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts --run` - for electron tests, and `npm run test:vitest -- run ` for - auto-routed client/server unit tests. Electron tests always run locally. -- Picker dev-server port: **5179** (5173/5174/5175 are taken by the main app, - wizard, and launch chooser; 5176 is referenced by demo tooling). -- Do not restart or kill the self-hosted Freshell server. Never use broad - pkill patterns. Do not touch daemon service definitions (daemon mode stays - a machine-global singleton; documented as a limitation). -- Conventional commit messages; focused commits per task. -- `AGENTS.md`/`docs/index.html` do not describe desktop-app internals; no - changes needed there. End-user documentation goes in `README.md`. - ---- - -### Task 1: Config-dir seam in `desktop-config` and `window-state` - -`desktop-config.ts` currently hardcodes `~/.freshell` in module-private -helpers and serializes ALL writes through one module-level mutex. Profiles -require: an optional per-call `configDir` override (default unchanged) and a -per-directory mutex so two profiles never block each other. `window-state.ts` -must pass a `configDir` through. Existing tests (which mock `os.homedir` and -call without the new arg) must keep passing UNCHANGED. - -**Files:** -- Modify: `electron/desktop-config.ts` (whole-file rewrite shown below) -- Modify: `electron/window-state.ts` (`createWindowStatePersistence(configDir?)`) -- Test: `test/unit/electron/desktop-config.profiles.test.ts` (new) -- Test: `test/unit/electron/window-state.configdir.test.ts` (new) - -**Interfaces:** -- Consumes: `electron/types.ts` `DesktopConfigSchema` (unchanged). -- Produces: `readDesktopConfig(configDir?)`, `writeDesktopConfig(config, configDir?)`, - `patchDesktopConfig(patch, configDir?)`, `_resetMutexForTesting()`; - `createWindowStatePersistence(configDir?)` — all defaults identical to today. - -- [ ] **Step 1: Write the failing behavioral test** - -SAFETY: these tests must mock `os.homedir` (same `vi.hoisted` + `vi.mock('os')` -pattern as the existing `test/unit/electron/desktop-config.test.ts:8-22`). -Without the mock, a red-phase run (production code ignoring the new arg) would -write to the REAL `~/.freshell/desktop.json` on the dev machine. The mock -keeps every fallback path inside a per-test temp dir. - -```ts -// test/unit/electron/desktop-config.profiles.test.ts -import fsp from 'fs/promises' -import os from 'os' -import path from 'path' -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' - -const mockState = vi.hoisted(() => ({ homeDir: '' })) -vi.mock('os', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - default: { ...actual, homedir: () => mockState.homeDir }, - homedir: () => mockState.homeDir, - } -}) - -import { - getDefaultDesktopConfig, - patchDesktopConfig, - readDesktopConfig, - writeDesktopConfig, - _resetMutexForTesting, -} from '../../../electron/desktop-config.js' - -// NOTE: `os.tmpdir()` still resolves to the REAL temp dir because the mock -// spreads `importOriginal` — only `homedir()` is overridden. - -describe('desktop-config with explicit configDir', () => { - let homeDir: string - let dirA: string - let dirB: string - - beforeEach(async () => { - homeDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dc-home-')) - mockState.homeDir = homeDir - dirA = path.join(homeDir, '.freshell-work') - dirB = path.join(homeDir, '.freshell-home') - _resetMutexForTesting() - }) - - afterEach(async () => { - await fsp.rm(homeDir, { recursive: true, force: true }) - _resetMutexForTesting() - }) - - it('readDesktopConfig returns null when the given dir has no config', async () => { - expect(await readDesktopConfig(dirA)).toBeNull() - }) - - it('write then read roundtrips inside the given dir only', async () => { - const config = { - ...getDefaultDesktopConfig(), - setupCompleted: true, - serverMode: 'remote' as const, - remoteUrl: 'http://a.example', - } - await writeDesktopConfig(config, dirA) - expect(await readDesktopConfig(dirA)).toEqual(config) - expect(await readDesktopConfig(dirB)).toBeNull() - }) - - it('patched state stays within its own directory', async () => { - await patchDesktopConfig({ globalHotkey: 'CommandOrControl+1' }, dirA) - await patchDesktopConfig({ globalHotkey: 'CommandOrControl+2' }, dirB) - expect((await readDesktopConfig(dirA))?.globalHotkey).toBe('CommandOrControl+1') - expect((await readDesktopConfig(dirB))?.globalHotkey).toBe('CommandOrControl+2') - }) - - it('concurrent patches on the same dir are serialized and both apply', async () => { - await Promise.all([ - patchDesktopConfig({ serverMode: 'remote', remoteUrl: 'http://a.example' }, dirA), - patchDesktopConfig({ globalHotkey: 'CommandOrControl+9' }, dirA), - ]) - const config = await readDesktopConfig(dirA) - expect(config?.serverMode).toBe('remote') - expect(config?.globalHotkey).toBe('CommandOrControl+9') - }) -}) -``` - -`window-state.configdir.test.ts` uses the same mock pattern. Note the worktree -`os` mock MUST preserve `tmpdir()` (real temp dir) — the spread of -`importOriginal` above does that: - -```ts -// test/unit/electron/window-state.configdir.test.ts -import fsp from 'fs/promises' -import os from 'os' -import path from 'path' -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' - -const mockState = vi.hoisted(() => ({ homeDir: '' })) -vi.mock('os', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - default: { ...actual, homedir: () => mockState.homeDir }, - homedir: () => mockState.homeDir, - } -}) - -import { createWindowStatePersistence } from '../../../electron/window-state.js' -import { readDesktopConfig, _resetMutexForTesting } from '../../../electron/desktop-config.js' - -describe('window-state with explicit configDir', () => { - let homeDir: string - let dir: string - - beforeEach(async () => { - homeDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ws-profile-')) - mockState.homeDir = homeDir - dir = path.join(homeDir, '.freshell-work') - _resetMutexForTesting() - }) - - afterEach(async () => { - await fsp.rm(homeDir, { recursive: true, force: true }) - _resetMutexForTesting() - }) - - it('loads defaults when the dir has no config', async () => { - const persistence = createWindowStatePersistence(dir) - expect(await persistence.load()).toEqual({ width: 1200, height: 800, maximized: false }) - }) - - it('saves window state into the given dir', async () => { - const persistence = createWindowStatePersistence(dir) - await persistence.save({ x: 1, y: 2, width: 1000, height: 700, maximized: true }) - const config = await readDesktopConfig(dir) - expect(config?.windowState).toEqual({ x: 1, y: 2, width: 1000, height: 700, maximized: true }) - }) -}) -``` - -Red-phase expectation detail: with the mock installed, a NO-param call falls -back to `/.freshell` (a temp dir), so the red failure is an -assertion mismatch inside temp space — never a write outside the sandbox. - -- [ ] **Step 2: Run the tests and verify the intended failure** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/desktop-config.profiles.test.ts test/unit/electron/window-state.configdir.test.ts --run` - -Expected: FAIL because the current signatures have no `configDir`/`dir` -parameter — calls fall back to the (mocked, temp) default `~/.freshell`, so -the cross-directory isolation assertions fail (e.g. dirB unexpectedly sees -dirA's config). The TypeScript call sites also surface excess-argument errors -once typechecked. - -- [ ] **Step 3: Add the minimal production implementation** - -Rewrite `electron/desktop-config.ts` as: - -```ts -import fsp from 'fs/promises' -import os from 'os' -import path from 'path' -import { DesktopConfigSchema, type DesktopConfig } from './types.js' - -const DESKTOP_CONFIG_FILENAME = 'desktop.json' - -function defaultConfigDir(): string { - return path.join(os.homedir(), '.freshell') -} - -function resolveConfigDir(configDir?: string): string { - return configDir ?? defaultConfigDir() -} - -function getConfigPath(configDir?: string): string { - return path.join(resolveConfigDir(configDir), DESKTOP_CONFIG_FILENAME) -} - -export function getDefaultDesktopConfig(): DesktopConfig { - return { - serverMode: 'app-bound', - port: 3001, - knownServers: [], - alwaysAskOnLaunch: false, - globalHotkey: 'CommandOrControl+`', - startOnLogin: false, - minimizeToTray: true, - setupCompleted: false, - } -} - -export async function readDesktopConfig(configDir?: string): Promise { - const configPath = getConfigPath(configDir) - try { - const content = await fsp.readFile(configPath, 'utf-8') - const parsed = JSON.parse(content) - const result = DesktopConfigSchema.safeParse(parsed) - if (!result.success) { - return null - } - return result.data - } catch { - return null - } -} - -export async function writeDesktopConfig(config: DesktopConfig, configDir?: string): Promise { - const dir = resolveConfigDir(configDir) - await fsp.mkdir(dir, { recursive: true }) - - const configPath = getConfigPath(dir) - const tmpPath = configPath + '.tmp' - await fsp.writeFile(tmpPath, JSON.stringify(config, null, 2)) - await fsp.rename(tmpPath, configPath) -} - -// Per-directory mutex chains so two profiles' writes never serialize against -// each other while writes on the SAME directory stay ordered. -const mutexChains = new Map>() - -export async function patchDesktopConfig( - patch: Partial, - configDir?: string, -): Promise { - const dir = resolveConfigDir(configDir) - let result: DesktopConfig - - // Chain onto the existing mutex for THIS directory so concurrent calls on - // the same dir run sequentially. - const work = (mutexChains.get(dir) ?? Promise.resolve()).then(async () => { - const existing = await readDesktopConfig(dir) - const base = existing ?? getDefaultDesktopConfig() - const merged = { ...base, ...patch } - const validated = DesktopConfigSchema.parse(merged) - await writeDesktopConfig(validated, dir) - result = validated - }) - - // Update the chain — subsequent calls wait for this one to finish. - mutexChains.set(dir, work.catch(() => {})) - - await work - return result! -} - -/** - * Reset the internal mutex chains. Only for use in tests to ensure - * inter-test isolation — the module-level mutex map holds references from - * prior calls, which can leak state between test files. - */ -export function _resetMutexForTesting(): void { - mutexChains.clear() -} -``` - -And change `electron/window-state.ts` to thread the config dir: - -```ts -import { readDesktopConfig, patchDesktopConfig } from './desktop-config.js' - -export interface WindowState { - x?: number - y?: number - width: number - height: number - maximized: boolean -} - -export interface WindowStatePersistence { - /** Load persisted state, returning defaults if not found */ - load(): Promise - - /** Save current window state */ - save(state: { x: number; y: number; width: number; height: number; maximized: boolean }): Promise -} - -const DEFAULTS: WindowState = { - width: 1200, - height: 800, - maximized: false, -} - -export function createWindowStatePersistence(configDir?: string): WindowStatePersistence { - return { - async load(): Promise { - const config = await readDesktopConfig(configDir) - if (!config?.windowState) { - return { ...DEFAULTS } - } - return { - x: config.windowState.x, - y: config.windowState.y, - width: config.windowState.width ?? DEFAULTS.width, - height: config.windowState.height ?? DEFAULTS.height, - maximized: config.windowState.maximized ?? DEFAULTS.maximized, - } - }, - - async save(state: { x: number; y: number; width: number; height: number; maximized: boolean }): Promise { - await patchDesktopConfig({ windowState: state }, configDir) - }, - } -} -``` - -- [ ] **Step 4: Run the focused tests** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/desktop-config.profiles.test.ts test/unit/electron/window-state.configdir.test.ts --run` - -Expected: PASS - -- [ ] **Step 5: Refactor while green** - -None — the change is a pure cross-cutting parameter thread (desktop-config keeps -its structure; window-state keeps every line except `configDir` pass-through). - -- [ ] **Step 6: Run impacted-test verification** - -Impacted: every existing desktop-config / window-state consumer test. - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/desktop-config.test.ts test/unit/electron/window-state.test.ts test/unit/electron/desktop-provisioning.test.ts test/unit/electron/launch-choice-handler.test.ts test/unit/electron/startup.test.ts --run` - -Expected: PASS with no modifications to those existing tests (the new params -are optional and defaults are unchanged). - -- [ ] **Step 7: Commit the task** - -```bash -git add electron/desktop-config.ts electron/window-state.ts \ - test/unit/electron/desktop-config.profiles.test.ts \ - test/unit/electron/window-state.configdir.test.ts \ - docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "refactor(electron): thread configDir through desktop-config and window-state" -``` - ---- - -### Task 2: Profile resolution core (`electron/profile.ts`) - -Pure, DI-friendly module: no `electron` import, no I/O except via an injected -reader. This is the contract every later task consumes. First run of the new -test must fail because the module does not exist yet. - -**Files:** -- Create: `electron/profile.ts` -- Modify: `server/bootstrap.ts` (as-built: config-dir .env anchor + migration) — see **As-built changes** -- Create: `server/env-load.ts` (as-built: dotenv load at module-eval anchor) — see **As-built changes** -- Modify: `server/get-network-host.ts` (as-built: anchor-aware dotenv) — see **As-built changes** -- Modify: `server/freshell-home.ts` (as-built: shared `resolveEnvAnchorDir`) — see **As-built changes** -- Modify: `server/index.ts` (as-built: dotenv load moved into env-load.ts; bootstrap + env-load are the first two imports) — see **As-built changes** -- Test: `test/unit/electron/profile.test.ts` - -**Interfaces:** -- Consumes: nothing repo-internal (only `path`, `zod`). -- Produces: `DEFAULT_PROFILE_ID`, `PICKER_USERDATA_ID`, `PROFILE_ID_PATTERN`, - `ProfileEntry`, `ProfilesRegistrySchema`, `RegistryReadResult`, - `ProfileSelection`, `ProfileSelectionResult`, `parseProfileArg(argv)`, - `stripProfileArgs(argv)`, `resolveProfileSelection(argv, env)`, - `configDirForProfile(id, homedir)`, `userDataDirForProfile(id, appName, appDataDir)`, - `userDataDirForPicker(appName, appDataDir)`, `registryPathForHome(homedir)`, - `readProfilesRegistry(path, readFile)`, `shouldShowProfilePicker(selection, registry)`, - `buildPickerEntries(registry)`, `resolveBootShape(argv, env, registry, appName, appDataDir, homedir)`, - `BootShape`. - -- [ ] **Step 1: Write the failing behavioral test** - -```ts -// test/unit/electron/profile.test.ts -import os from 'os' -import path from 'path' -import { describe, it, expect } from 'vitest' -import { - DEFAULT_PROFILE_ID, - buildPickerEntries, - configDirForProfile, - parseProfileArg, - readProfilesRegistry, - registryPathForHome, - resolveBootShape, - resolveProfileSelection, - shouldShowProfilePicker, - stripProfileArgs, - userDataDirForPicker, - userDataDirForProfile, -} from '../../../electron/profile.js' - -describe('parseProfileArg', () => { - it('parses --profile=', () => { - expect(parseProfileArg(['app', '--profile=work'])).toBe('work') - }) - it('parses --profile ', () => { - expect(parseProfileArg(['app', '--profile', 'work'])).toBe('work') - }) - it('returns undefined when --profile has a flag-like or missing value', () => { - expect(parseProfileArg(['app', '--profile', '--other'])).toBeUndefined() - expect(parseProfileArg(['app', '--profile'])).toBeUndefined() - }) - it('returns undefined when absent', () => { - expect(parseProfileArg(['app'])).toBeUndefined() - }) -}) - -describe('stripProfileArgs', () => { - it('removes both --profile forms and keeps everything else', () => { - expect(stripProfileArgs(['--profile=work', '--foo', '--profile', 'home', 'bar'])) - .toEqual(['--foo', 'bar']) - }) - it('drops a trailing bare --profile', () => { - expect(stripProfileArgs(['--foo', '--profile'])).toEqual(['--foo']) - }) - it('keeps a flag that follows bare --profile (no value was consumed)', () => { - // Mirrors parseProfileArg: `--profile --other` took no value, so --other - // must survive stripping (it belongs to the relaunched process). - expect(stripProfileArgs(['--profile', '--other', 'x'])).toEqual(['--other', 'x']) - }) -}) - -describe('resolveProfileSelection', () => { - it('defaults to the default profile, non-explicit', () => { - expect(resolveProfileSelection(['app'], {})).toEqual({ - selection: { id: DEFAULT_PROFILE_ID, explicit: false, source: 'default' }, - }) - }) - it('argv wins over env', () => { - const r = resolveProfileSelection(['app', '--profile=work'], { FRESHELL_PROFILE: 'home' }) - expect(r.selection).toEqual({ id: 'work', explicit: true, source: 'argv' }) - }) - it('uses FRESHELL_PROFILE when argv is absent', () => { - const r = resolveProfileSelection(['app'], { FRESHELL_PROFILE: 'home' }) - expect(r.selection).toEqual({ id: 'home', explicit: true, source: 'env' }) - }) - it('treats empty FRESHELL_PROFILE as absent', () => { - expect(resolveProfileSelection(['app'], { FRESHELL_PROFILE: ' ' }).selection.id) - .toBe(DEFAULT_PROFILE_ID) - }) - it('an explicit "default" suppresses the picker', () => { - const r = resolveProfileSelection(['app', '--profile=default'], {}) - expect(r.selection).toEqual({ id: 'default', explicit: true, source: 'argv' }) - expect(r.error).toBeUndefined() - }) - it('invalid ids fall back to default with an error', () => { - const r = resolveProfileSelection(['app', '--profile=../evil'], {}) - expect(r.selection).toEqual({ id: DEFAULT_PROFILE_ID, explicit: false, source: 'default' }) - expect(r.error).toContain('../evil') - }) - it('the reserved picker id falls back to default with an error', () => { - const r = resolveProfileSelection(['app', '--profile=profile-picker'], {}) - expect(r.selection.id).toBe(DEFAULT_PROFILE_ID) - expect(r.selection.explicit).toBe(false) - expect(r.error).toContain('profile-picker') - }) -}) - -describe('path derivation', () => { - it('default profile keeps ~/.freshell and Electron-default userData', () => { - expect(configDirForProfile('default', '/home/u')).toBe(path.join('/home/u', '.freshell')) - expect(userDataDirForProfile('default', 'Freshell', '/app/data')).toBeUndefined() - }) - it('named profiles get sibling dirs', () => { - expect(configDirForProfile('work', '/home/u')).toBe(path.join('/home/u', '.freshell-work')) - expect(userDataDirForProfile('work', 'Freshell', '/app/data')) - .toBe(path.join('/app/data', 'Freshell-work')) - }) - it('registry always lives in the default config dir', () => { - expect(registryPathForHome('/home/u')).toBe(path.join('/home/u', '.freshell', 'profiles.json')) - }) -}) - -describe('readProfilesRegistry', () => { - const missing = () => undefined - const withFile = (content: string) => (_p: string) => content - - it('missing file means no profiles and no error', () => { - expect(readProfilesRegistry('/x/profiles.json', missing)).toEqual({ profiles: [] }) - }) - it('invalid JSON is reported and ignored', () => { - const r = readProfilesRegistry('/x/profiles.json', withFile('nope {{{')) - expect(r.profiles).toEqual([]) - expect(r.error).toContain('not valid JSON') - }) - it('a reader that throws (unreadable file) is reported and ignored, not fatal', () => { - const r = readProfilesRegistry('/x/profiles.json', () => { throw new Error('EACCES: permission denied') }) - expect(r.profiles).toEqual([]) - expect(r.error).toContain('could not be read') - expect(r.error).toContain('EACCES') - }) - it('schema violations are reported and ignored', () => { - for (const bad of [ - { profiles: [{ id: 'BAD ID' }] }, - { profiles: [{ id: 'default' }] }, - { profiles: [{ id: 'a' }, { id: 'a' }] }, - { profiles: [] }, - ]) { - const r = readProfilesRegistry('/x/profiles.json', withFile(JSON.stringify(bad))) - expect(r.profiles).toEqual([]) - expect(r.error).toBeTruthy() - } - }) - it('accepts a valid registry', () => { - const r = readProfilesRegistry('/x/profiles.json', - withFile(JSON.stringify({ profiles: [{ id: 'work', label: 'Work' }, { id: 'home' }] }))) - expect(r.error).toBeUndefined() - expect(r.profiles).toEqual([{ id: 'work', label: 'Work' }, { id: 'home' }]) - }) -}) - -describe('picker predicates', () => { - const registry = { profiles: [{ id: 'work' as const }] } - it('shows only when selection is not explicit and registry is non-empty', () => { - expect(shouldShowProfilePicker({ id: 'default', explicit: false, source: 'default' }, registry)).toBe(true) - expect(shouldShowProfilePicker({ id: 'work', explicit: true, source: 'argv' }, registry)).toBe(false) - expect(shouldShowProfilePicker({ id: 'default', explicit: false, source: 'default' }, { profiles: [] })).toBe(false) - }) - it('buildPickerEntries lists Default first and falls back to the id as label', () => { - expect(buildPickerEntries({ profiles: [{ id: 'work', label: 'Work' }, { id: 'home' }] })) - .toEqual([ - { id: 'default', label: 'Default' }, - { id: 'work', label: 'Work' }, - { id: 'home', label: 'home' }, - ]) - }) -}) - -describe('resolveBootShape', () => { - const REG = { profiles: [{ id: 'work' as const }] } - const NO_REGISTRY = { profiles: [] as const } - it('explicit named profile: namespaced userData + namespaced config dir', () => { - expect(resolveBootShape(['app', '--profile=work'], {}, REG, 'Freshell', '/app/data', '/home/u')) - .toEqual({ - kind: 'explicit', - profileId: 'work', - userDataDir: path.join('/app/data', 'Freshell-work'), - configDir: path.join('/home/u', '.freshell-work'), - }) - }) - it('explicit default: untouched userData + default config dir, no picker', () => { - expect(resolveBootShape(['app', '--profile=default'], {}, REG, 'Freshell', '/app/data', '/home/u')) - .toEqual({ - kind: 'explicit', - profileId: 'default', - userDataDir: undefined, - configDir: path.join('/home/u', '.freshell'), - }) - }) - it('flag-less launch with a non-empty registry becomes a picker launcher on its OWN userData', () => { - const shape = resolveBootShape(['app'], {}, REG, 'Freshell', '/app/data', '/home/u') - expect(shape).toEqual({ - kind: 'picker', - profileId: 'default', - userDataDir: path.join('/app/data', 'Freshell-profile-picker'), - configDir: path.join('/home/u', '.freshell'), - }) - // The picker userData must never equal a real profile's dir. - expect(shape.userDataDir).not.toBe(userDataDirForProfile('work', 'Freshell', '/app/data')) - }) - it('flag-less launch with an empty registry is the plain default boot', () => { - expect(resolveBootShape(['app'], {}, NO_REGISTRY, 'Freshell', '/app/data', '/home/u')) - .toEqual({ - kind: 'default', - profileId: 'default', - configDir: path.join('/home/u', '.freshell'), - }) - }) - it('explicitly requesting the reserved picker id falls back to default with an error', () => { - const shape = resolveBootShape(['app', '--profile=profile-picker'], {}, REG, 'Freshell', '/app/data', '/home/u') - expect(shape.kind).toBe('default') - expect(shape.profileId).toBe('default') - expect(shape.error).toContain('profile-picker') - }) - it('an invalid explicit id falls back to default (no picker) with the reason preserved', () => { - const shape = resolveBootShape(['app', '--profile=../evil'], {}, REG, 'Freshell', '/app/data', '/home/u') - expect(shape.kind).toBe('default') - expect(shape.userDataDir).toBeUndefined() - expect(shape.error).toContain('../evil') - }) -}) -``` - -- [ ] **Step 2: Run the test and verify the intended failure** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/profile.test.ts --run` - -Expected: FAIL because `../../../electron/profile.js` cannot be resolved (module -does not exist) — not because of a test-file syntax error. - -- [ ] **Step 3: Add the minimal production implementation** - -```ts -// electron/profile.ts -import path from 'path' -import { z } from 'zod' - -export const DEFAULT_PROFILE_ID = 'default' - -/** - * The profile-picker launcher reserves this id: a flag-less launch that is - * about to show the picker namespaces its userData to - * `/-profile-picker` so the picker process never shares a - * Chromium userData dir with a resident Default (or named) instance. - */ -export const PICKER_USERDATA_ID = 'profile-picker' - -/** - * Profile ids become directory names on every supported OS, so keep them - * conservative: lowercase kebab-case, no path separators or dots (so no - * '..' traversal), bounded length. - */ -export const PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/ - -export const ProfileEntrySchema = z.object({ - id: z.string().regex(PROFILE_ID_PATTERN), - label: z.string().trim().min(1).max(64).optional(), -}) - -export const ProfilesRegistrySchema = z.object({ - profiles: z.array(ProfileEntrySchema).min(1), -}).superRefine((value, ctx) => { - const seen = new Set() - for (const entry of value.profiles) { - if (entry.id === DEFAULT_PROFILE_ID) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `'${DEFAULT_PROFILE_ID}' is a reserved profile id` }) - } - if (entry.id === PICKER_USERDATA_ID) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `'${PICKER_USERDATA_ID}' is a reserved profile id` }) - } - if (seen.has(entry.id)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate profile id '${entry.id}'` }) - } - seen.add(entry.id) - } -}) - -export type ProfileEntry = z.infer - -/** - * Contract note — the built-in Default profile is ALWAYS part of the choice - * set, so "more than one profile is configured" (per the User Request wording) - * is satisfied as soon as the registry names ≥1 named profile: the effective - * choices are `[Default, ...registry.profiles]`. This keeps the registry file - * minimal (named profiles only) and matches the picker UX. - */ - -export type ProfileSource = 'argv' | 'env' | 'default' - -export interface ProfileSelection { - id: string - explicit: boolean - source: ProfileSource -} - -export interface ProfileSelectionResult { - selection: ProfileSelection - /** Set when an explicitly requested id was invalid and default was substituted. */ - error?: string -} - -/** Extract `--profile=` or `--profile ` from a raw argv slice. */ -export function parseProfileArg(argv: string[]): string | undefined { - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--profile') { - const next = argv[i + 1] - if (next && !next.startsWith('--')) return next - return undefined - } - if (arg.startsWith('--profile=')) return arg.slice('--profile='.length) - } - return undefined -} - -/** Remove every `--profile=` / `--profile ` pair from an argv slice. - * Mirrors parseProfileArg exactly: `--profile` only consumes the next token - * when it is a non-flag value; `--profile --other` drops just `--profile` - * (since no value was taken) and keeps `--other`. */ -export function stripProfileArgs(argv: string[]): string[] { - const out: string[] = [] - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg.startsWith('--profile=')) continue - if (arg === '--profile') { - const next = argv[i + 1] - if (next !== undefined && !next.startsWith('--')) i++ // consumed a real value - continue - } - out.push(arg) - } - return out -} - -/** - * Resolve the active profile. Precedence: `--profile` argv > `FRESHELL_PROFILE` - * env > (picker, by returning non-explicit default) > default. - */ -export function resolveProfileSelection( - argv: string[], - env: NodeJS.ProcessEnv, -): ProfileSelectionResult { - const fromArgv = parseProfileArg(argv) - const fromEnv = env.FRESHELL_PROFILE?.trim() - const raw = fromArgv ?? (fromEnv ? fromEnv : undefined) - const source: ProfileSource = fromArgv !== undefined ? 'argv' : raw !== undefined ? 'env' : 'default' - if (raw === undefined) { - return { selection: { id: DEFAULT_PROFILE_ID, explicit: false, source: 'default' } } - } - if (raw === DEFAULT_PROFILE_ID) { - return { selection: { id: DEFAULT_PROFILE_ID, explicit: true, source } } - } - if (raw === PICKER_USERDATA_ID) { - return { - selection: { id: DEFAULT_PROFILE_ID, explicit: false, source: 'default' }, - error: `Profile id '${PICKER_USERDATA_ID}' is reserved for the picker launcher; using the default profile.`, - } - } - if (!PROFILE_ID_PATTERN.test(raw)) { - return { - selection: { id: DEFAULT_PROFILE_ID, explicit: false, source: 'default' }, - error: `Invalid profile id '${raw}' (must match ${PROFILE_ID_PATTERN}); using the default profile.`, - } - } - return { selection: { id: raw, explicit: true, source } } -} - -/** Profile config dir: `~/.freshell` for default, `~/.freshell-` for named. */ -export function configDirForProfile(id: string, homedir: string): string { - if (id === DEFAULT_PROFILE_ID) return path.join(homedir, '.freshell') - return path.join(homedir, `.freshell-${id}`) -} - -/** - * userData dir for a named profile. Returns undefined for the default - * profile, meaning "leave Electron's default userData untouched". - */ -export function userDataDirForProfile( - id: string, - appName: string, - appDataDir: string, -): string | undefined { - if (id === DEFAULT_PROFILE_ID) return undefined - return path.join(appDataDir, `${appName}-${id}`) -} - -/** - * userData dir for the ephemeral profile-picker launcher process. It MUST NOT - * be the default profile's userData: when a Default instance is resident, a - * picker launch that reused Default's userData would put two browser processes - * on one Chromium profile dir (process-singleton violation, storage hazard). - * The picker's own userData also re-keys the instance lock, giving one picker - * at a time with `second-instance` focusing the resident picker. - */ -export function userDataDirForPicker(appName: string, appDataDir: string): string { - return path.join(appDataDir, `${appName}-${PICKER_USERDATA_ID}`) -} - -/** The registry is machine-global and always lives in the default config dir. */ -export function registryPathForHome(homedir: string): string { - return path.join(homedir, '.freshell', 'profiles.json') -} - -export interface RegistryReadResult { - profiles: ProfileEntry[] - /** Set when a file existed but was unusable; profiles are then empty. */ - error?: string -} - -/** - * Read and validate the profile registry. A missing file is normal (no - * profiles configured); a present-but-invalid file is an error the caller - * should surface (log) while booting the default profile. - */ -export function readProfilesRegistry( - registryPath: string, - readFile: (p: string) => string | undefined, -): RegistryReadResult { - let content: string | undefined - try { - content = readFile(registryPath) - } catch (err) { - // Exists-but-unreadable (EACCES, a directory named profiles.json, a TOCTOU - // race between existsSync and readFileSync in the caller's reader): warn - // and fall back to the default profile, exactly like an invalid registry. - return { profiles: [], error: `Profile registry at ${registryPath} could not be read (${err instanceof Error ? err.message : String(err)}); ignoring it.` } - } - if (content === undefined) return { profiles: [] } - let parsedJson: unknown - try { - parsedJson = JSON.parse(content) - } catch { - return { profiles: [], error: `Profile registry at ${registryPath} is not valid JSON; ignoring it.` } - } - const parsed = ProfilesRegistrySchema.safeParse(parsedJson) - if (!parsed.success) { - return { profiles: [], error: `Profile registry at ${registryPath} is invalid; ignoring it.` } - } - return { profiles: parsed.data.profiles } -} - -/** The picker appears when the choice set (default + named) has >1 entries. */ -export function shouldShowProfilePicker( - selection: ProfileSelection, - registry: RegistryReadResult, -): boolean { - return !selection.explicit && registry.profiles.length >= 1 -} - -/** - * The full module-top boot decision for entry.ts. One of: - * - 'picker': flag-less launch with ≥1 named profiles in the registry — - * userData is namespacespaced to the launcher dir and the boot shows ONLY - * the picker (configDir stays the default profile dir, since the registry - * and the launcher's diagnostic logs live there). - * - 'explicit': argv/env named a valid profile — namespace userData (except - * default) and boot that profile. - * - 'default': everything else — today's boot, zero behavior change. - */ -export interface BootShape { - kind: 'picker' | 'explicit' | 'default' - profileId: string - userDataDir?: string - configDir: string - /** Set when an explicit request was invalid and default was substituted; - * entry.ts logs it (warn) so the fallback is visible. */ - error?: string -} - -export function resolveBootShape( - argv: string[], - env: NodeJS.ProcessEnv, - registry: RegistryReadResult, - appName: string, - appDataDir: string, - homedir: string, -): BootShape { - const { selection, error } = resolveProfileSelection(argv, env) - // An explicitly requested but INVALID profile must NOT surface the picker: - // the resolver already fell back to default; honor that and surface the - // reason via `error`. - if (error) { - return { - kind: 'default', - profileId: DEFAULT_PROFILE_ID, - configDir: configDirForProfile(DEFAULT_PROFILE_ID, homedir), - error, - } - } - if (selection.explicit) { - return { - kind: 'explicit', - profileId: selection.id, - userDataDir: userDataDirForProfile(selection.id, appName, appDataDir), - configDir: configDirForProfile(selection.id, homedir), - } - } - if (shouldShowProfilePicker(selection, registry)) { - // The picker launcher is not itself a profile session: it logs to the - // default config dir but parks its userData in its own dir. - return { - kind: 'picker', - profileId: DEFAULT_PROFILE_ID, - userDataDir: userDataDirForPicker(appName, appDataDir), - configDir: configDirForProfile(DEFAULT_PROFILE_ID, homedir), - } - } - return { - kind: 'default', - profileId: DEFAULT_PROFILE_ID, - configDir: configDirForProfile(DEFAULT_PROFILE_ID, homedir), - } -} - -export interface PickerEntry { - id: string - label: string -} - -/** Picker entries: the default profile first, then the registry in file order. */ -export function buildPickerEntries(registry: RegistryReadResult): PickerEntry[] { - return [ - { id: DEFAULT_PROFILE_ID, label: 'Default' }, - ...registry.profiles.map((p) => ({ id: p.id, label: p.label ?? p.id })), - ] -} -``` - -- [ ] **Step 4: Run the focused test** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/profile.test.ts --run` - -Expected: PASS - -- [ ] **Step 5: Refactor while green** - -No refactor expected; the module is already flat and single-purpose. - -- [ ] **Step 6: Run impacted-test verification** - -New module, no existing consumers. Impacted set = the whole electron unit -suite (cheap, guards against config/alias breakage). - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts --run` - -Expected: PASS - -- [ ] **Step 7: Commit the task** - -```bash -git add electron/profile.ts test/unit/electron/profile.test.ts docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "feat(electron): add profile resolution module" -``` - ---- - -### Task 3: Lock split, hotkey failure logging, tray tooltip, spawn env, resident surfacing fix - -Five small DI-module changes that profiles depend on. Default-path behavior -is unchanged for each except the `second-instance` surfacing fix (a deliberate -bug fix — see below). Note: `main.ts` today requests the single-instance -lock at the very END of boot (`entry.ts:662`); Task 5 will acquire it right -after `whenReady()` via the new `acquireInstanceLock` (in whichever userData -the module-top boot shape selected), fixing a pre-existing race where a -second instance booted fully (including server spawn) before being turned -away. - -**Files:** -- Modify: `electron/main.ts` (new `acquireInstanceLock`; `initMainProcess` stops requesting the lock; `second-instance` handler shows tray-hidden windows) -- Modify: `electron/tray.ts` (optional tooltip override) -- Modify: `electron/server-spawner.ts` (exported `buildSpawnEnv`, `FRESHELL_CONFIG_DIR` in spawn env) -- Modify: `electron/startup.ts` (warn-log on hotkey registration failure) -- Test: `test/unit/electron/main.test.ts` (update) -- Test: `test/unit/electron/tray.test.ts` (add one case) -- Test: `test/unit/electron/server-spawner-env.test.ts` (new) -- Test: `test/unit/electron/startup.test.ts` (add one case) - -**Interfaces:** -- Consumes: `ElectronApp` (main.ts), `TrayApi`/`MenuApi` (tray.ts), existing `StartupContext.mainProcessLogger` (startup.ts). -- Produces: `acquireInstanceLock(app): boolean`; `createTray(..., appearance?: TrayAppearance)`; - `buildSpawnEnv(baseEnv, port, configDir): Record`. - -- [ ] **Step 1: Write the failing behavioral tests** - -`test/unit/electron/main.test.ts` — update the import and replace the -lock-failure test of `initMainProcess` with tests of the new function: - -```ts -import { initMainProcess, acquireInstanceLock, type ElectronApp, type MainProcessDeps } from '../../../electron/main.js' -``` - -Remove the existing `'quits when single instance lock fails'` test (lines -43-48) and add: - -```ts -describe('acquireInstanceLock', () => { - it('returns true without quitting when the lock is acquired', () => { - const app = createMockApp() - expect(acquireInstanceLock(app)).toBe(true) - expect(app.quit).not.toHaveBeenCalled() - }) - - it('quits and returns false when another instance holds the lock', () => { - const app = createMockApp() - ;(app.requestSingleInstanceLock as ReturnType).mockReturnValue(false) - expect(acquireInstanceLock(app)).toBe(false) - expect(app.quit).toHaveBeenCalled() - }) - - it('invokes onDenied BEFORE quitting (so entry.ts can lift the wizard-phase will-quit veto)', () => { - const app = createMockApp() - ;(app.requestSingleInstanceLock as ReturnType).mockReturnValue(false) - const onDenied = vi.fn() - expect(acquireInstanceLock(app, onDenied)).toBe(false) - expect(onDenied.mock.invocationCallOrder[0]) - .toBeLessThan((app.quit as ReturnType).mock.invocationCallOrder[0]) - }) -}) -``` - -Also add a resident-surfacing regression test — the mock app is an -`EventEmitter`, so the registered `second-instance` handler fires via `emit`: - -```ts -it('shows a hidden main window before focusing it on second-instance', async () => { - await initMainProcess(deps) - - app.emit('second-instance') - - expect(mockWindow.show).toHaveBeenCalled() - expect(mockWindow.focus).toHaveBeenCalled() - expect(mockWindow.show.mock.invocationCallOrder[0]) - .toBeLessThan(mockWindow.focus.mock.invocationCallOrder[0]) -}) - -it('does not double-register second-instance when an early canonical handler exists', async () => { - // entry.ts installs its own canonical handler in main() before any window - // creation; initMainProcess must defer to it. - app.on('second-instance', () => {}) - await initMainProcess(deps) - expect(app.listenerCount('second-instance')).toBe(1) -}) -``` - -(A tray-hidden Default window is hidden, not minimized: today the handler only -`restore()`s minimized windows, so `focus()` on a hidden window is a silent -no-op and a turned-away same-profile launch does nothing visible.) - -`test/unit/electron/tray.test.ts` — add inside the existing describe: - -```ts -it('uses a profile-aware tooltip when provided', () => { - createTray(MockTray, mockMenu, '/path/to/icon.png', options, { tooltip: 'Freshell (work)' }) - expect(mockTrayInstance.setToolTip).toHaveBeenCalledWith('Freshell (work)') -}) -``` - -`test/unit/electron/server-spawner-env.test.ts` — new: - -```ts -import { describe, it, expect } from 'vitest' -import { buildSpawnEnv } from '../../../electron/server-spawner.js' - -const CONFIG_DIR = '/home/user/.freshell-work' - -describe('buildSpawnEnv', () => { - it('inherits the base environment', () => { - const env = buildSpawnEnv({ PATH: '/bin', CUSTOM: 'x' }, 3001, CONFIG_DIR) - expect(env.PATH).toBe('/bin') - expect(env.CUSTOM).toBe('x') - }) - - it('pins PORT to the spawn port', () => { - expect(buildSpawnEnv({ PORT: '9999' }, 3001, CONFIG_DIR).PORT).toBe('3001') - }) - - it('pins FRESHELL_CONFIG_DIR to the profile config dir, overriding any inherited value', () => { - expect(buildSpawnEnv({ FRESHELL_CONFIG_DIR: '/elsewhere' }, 3001, CONFIG_DIR).FRESHELL_CONFIG_DIR) - .toBe(CONFIG_DIR) - }) -}) -``` - -`test/unit/electron/startup.test.ts` — add one case. Mirror the file's -existing main-window test scaffolding (`createDefaultContext()` with a -completed remote-mode `desktopConfig`); adapt to the real helper names if -they differ: - -```ts -it('logs a warning when the global hotkey registration fails', async () => { - const ctx = createDefaultContext() - // createDefaultContext() does not provide mainProcessLogger — attach one and - // keep a direct mock reference (the optional-chain in production code means - // "no logger" is legal, so the test must supply one explicitly). - const mainProcessLogger = { log: vi.fn() } - ;(ctx as { mainProcessLogger?: { log: ReturnType } }).mainProcessLogger = mainProcessLogger - ;(ctx.hotkeyManager.register as ReturnType).mockReturnValue(false) - - const result = await runStartup(ctx) - - expect(result.type).toBe('main') - expect(mainProcessLogger.log).toHaveBeenCalledWith( - expect.objectContaining({ - severity: 'warn', - event: 'global_hotkey_registration_failed', - accelerator: ctx.desktopConfig.globalHotkey, - }), - ) -}) -``` - -- [ ] **Step 2: Run the tests and verify the intended failures** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/main.test.ts test/unit/electron/tray.test.ts test/unit/electron/server-spawner-env.test.ts test/unit/electron/startup.test.ts --run` - -Expected: FAIL because (a) `acquireInstanceLock` is not exported; (b) -`createTray` takes only 4 args and the tooltip stays `'Freshell'`; (c) -`buildSpawnEnv` is not exported (module has no such symbol); (d) no -`global_hotkey_registration_failed` log call exists. - -- [ ] **Step 3: Add the minimal production implementation** - -`electron/main.ts` — replace the lock block at lines 23-28 with nothing, add -the export, and document the contract: - -```ts -/** - * Acquire the single-instance lock for this process's userData dir. When - * entry.ts has namespaced userData per profile, each profile holds its own - * lock. Call BEFORE any boot side effects (provisioning, server spawn). - * Returns true when the lock is held; on failure the app quits and this - * returns false. `onDenied` (optional) runs immediately BEFORE app.quit() — - * entry.ts uses it to lift the wizard-phase `will-quit` veto for the denied - * duplicate, which never enters the wizard. - */ -export function acquireInstanceLock(app: ElectronApp, onDenied?: () => void): boolean { - const gotLock = app.requestSingleInstanceLock() - if (!gotLock) { - onDenied?.() - app.quit() - return false - } - return true -} -``` - -`initMainProcess` drops its lock block; its header comment gains: "The caller -must hold the instance lock already (see `acquireInstanceLock`)." - -Also in `initMainProcess`, fix the `second-instance` handler to surface a -hidden (tray-resident) window, not just a minimized one — and register it only -when no `second-instance` listener exists yet (entry.ts installs a canonical -early handler; see Task 5): - -```ts - // Second instance: surface and focus the existing window. Skipped if entry - // already installed a canonical early handler. - if (app.listenerCount('second-instance') === 0) { - app.on('second-instance', () => { - if (mainWindow) { - if (mainWindow.isMinimized?.()) { - mainWindow.restore?.() - } - mainWindow.show?.() - mainWindow.focus?.() - } - }) - } -``` - -(Pre-existing bug, exposed by per-profile turn-away semantics: with -`minimizeToTray: true` the resident window is hidden — `focus()` alone does -nothing visible. `listenerCount` comes free on the EventEmitter interface the -tests mock; add it to the `ElectronApp` interface too.) - -`electron/tray.ts`: - -```ts -export interface TrayAppearance { - /** Tooltip override; defaults to 'Freshell'. */ - tooltip?: string -} - -export function createTray( - TrayConstructor: TrayApi, - Menu: MenuApi, - iconPath: string, - options: TrayOptions, - appearance: TrayAppearance = {}, -): TrayInstance { - const tray = new TrayConstructor(iconPath) - tray.setToolTip(appearance.tooltip ?? 'Freshell') - // ...rest unchanged... -} -``` - -`electron/server-spawner.ts` — add the exported helper and use it in -`start()`: - -```ts -/** Environment for a spawned server: inherits ours, pinned to the spawn port - * and to THIS process's Freshell config dir (profile-aware). */ -export function buildSpawnEnv( - baseEnv: NodeJS.ProcessEnv, - port: number, - configDir: string, -): Record { - return { - ...(baseEnv as Record), - PORT: String(port), - FRESHELL_CONFIG_DIR: configDir, - } -} -``` - -Inside `start()`, replace the inline env object (`const env: Record = { ...process.env as..., PORT: ... }`) with: - -```ts -const env = buildSpawnEnv(process.env, port, configDir) -``` - -`electron/startup.ts` — capture the registration result and log on failure: - -```ts -const hotkeyRegistered = ctx.hotkeyManager.register(ctx.desktopConfig.globalHotkey, () => { - if (window.isVisible() && window.isFocused()) { - window.hide() - } else { - window.show() - window.focus() - } -}) -if (!hotkeyRegistered) { - ctx.mainProcessLogger?.log({ - severity: 'warn', - event: 'global_hotkey_registration_failed', - accelerator: ctx.desktopConfig.globalHotkey, - }) -} -``` - -- [ ] **Step 4: Run the focused tests** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/main.test.ts test/unit/electron/tray.test.ts test/unit/electron/server-spawner-env.test.ts test/unit/electron/startup.test.ts --run` - -Expected: PASS - -- [ ] **Step 5: Refactor while green** - -None expected; the changes are minimal and local. - -- [ ] **Step 6: Run impacted-test verification** - -Impacted: the entire electron unit suite (initMainProcess signature/behavior -change; shared tray/startup/server-spawner modules). NOTE: `entry.ts` still -calls `initMainProcess` at boot end and no longer acquires the lock anywhere — -between this task and Task 5 the packaged app would not quit same-instance -duplicates. That is exactly why these ship on one branch together; the unit -suite is unaffected because `entry.ts` is not unit-tested by design. - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts --run` - -Expected: PASS - -- [ ] **Step 7: Commit the task** - -```bash -git add electron/main.ts electron/tray.ts electron/server-spawner.ts electron/startup.ts \ - test/unit/electron/main.test.ts test/unit/electron/tray.test.ts \ - test/unit/electron/server-spawner-env.test.ts test/unit/electron/startup.test.ts \ - docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "refactor(electron): split instance-lock acquisition, warn on hotkey failure, profile-aware spawn env and tray tooltip" -``` - ---- - -### Task 4: Server honors `FRESHELL_CONFIG_DIR` - -App-bound profiles must not share server-side state. The server resolves its -config dir via `server/freshell-home.ts` (`FRESHELL_HOME` + `/.freshell`) — -which cannot express `~/.freshell-`. The daemon templates already set a -`FRESHELL_CONFIG_DIR` env var that nothing reads; this task makes it real. -Task 3 already pins the env var on spawned servers, so after this task an -app-bound profile's server writes config.json/logs/tabs-registry/etc. into -the profile's config dir. - -**Files:** -- Modify: `server/freshell-home.ts` -- Modify: `server/logger.ts` (three `resolve*LogPath` fns re-routed, `FRESHELL_LOG_DIR` precedence kept) -- Modify: `server/coding-cli/codex-app-server/durability-store.ts` (re-routed, override precedence kept) -- Modify: `server/coding-cli/codex-app-server/runtime.ts` (import-time const → call-time getter, `FRESHELL_CODEX_SIDECAR_DIR` precedence kept) -- Modify: `server/fresh-agent-extras-router.ts` (attachments + checkpoint shadow repo dirs re-routed) -- Modify: `server/fresh-agent/recovery-store.ts` (constructor default re-routed; lazy singleton accepted as-is) -- Test: `test/unit/server/freshell-home.test.ts` (new) -- Test: one behavioral test per re-routed consumer (extend that consumer's existing test file; each pins profile-dir routing + override precedence) - -**Interfaces:** -- Consumes: `process.env.FRESHELL_CONFIG_DIR` (absolute or relative path). -- Produces: `getFreshellConfigDir(env?)` honors the override; `runtime.ts`'s - exported `DEFAULT_CODEX_SIDECAR_METADATA_DIR` const becomes an exported - call-time getter (e.g. `defaultCodexSidecarMetadataDir()`) — its sole - consumer (`:358`) is updated. All other public signatures unchanged. - -- [ ] **Step 1: Write the failing behavioral test** - -```ts -// test/unit/server/freshell-home.test.ts -import os from 'os' -import path from 'path' -import { describe, it, expect } from 'vitest' -import { getFreshellHomeDir, getFreshellConfigDir } from '../../../server/freshell-home.js' - -describe('getFreshellHomeDir', () => { - it('honors FRESHELL_HOME', () => { - expect(getFreshellHomeDir({ FRESHELL_HOME: '/tmp/fx-home' })).toBe(path.resolve('/tmp/fx-home')) - }) - it('falls back to the OS homedir', () => { - expect(getFreshellHomeDir({})).toBe(os.homedir()) - }) -}) - -describe('getFreshellConfigDir', () => { - it('defaults to ~/.freshell', () => { - expect(getFreshellConfigDir({})).toBe(path.join(os.homedir(), '.freshell')) - }) - it('joins FRESHELL_HOME with .freshell', () => { - expect(getFreshellConfigDir({ FRESHELL_HOME: '/tmp/fx-home' })) - .toBe(path.join(path.resolve('/tmp/fx-home'), '.freshell')) - }) - it('honors FRESHELL_CONFIG_DIR verbatim over FRESHELL_HOME', () => { - expect(getFreshellConfigDir({ FRESHELL_HOME: '/tmp/fx-home', FRESHELL_CONFIG_DIR: '/tmp/fx-work' })) - .toBe('/tmp/fx-work') - }) - it('resolves a relative FRESHELL_CONFIG_DIR to absolute', () => { - expect(getFreshellConfigDir({ FRESHELL_CONFIG_DIR: 'relative/dir' })) - .toBe(path.resolve('relative/dir')) - }) - it('ignores a blank FRESHELL_CONFIG_DIR', () => { - expect(getFreshellConfigDir({ FRESHELL_CONFIG_DIR: ' ' })) - .toBe(path.join(os.homedir(), '.freshell')) - }) -}) -``` - -- [ ] **Step 2: Run the test and verify the intended failure** - -Run: `npm run test:vitest -- run test/unit/server/freshell-home.test.ts` - -Expected: FAIL because `FRESHELL_CONFIG_DIR` is ignored today (the three -override cases return `.../.freshell` instead). - -- [ ] **Step 3: Add the minimal production implementation** - -```ts -// server/freshell-home.ts -import os from 'os' -import path from 'path' - -export function getFreshellHomeDir(env: NodeJS.ProcessEnv = process.env): string { - const override = env.FRESHELL_HOME?.trim() - if (override) return path.resolve(override) - return os.homedir() -} - -/** - * The Freshell config dir (~/.freshell by default). - * - * Resolution order: - * 1. FRESHELL_CONFIG_DIR — explicit full override. This is how the Electron - * app's named profiles (`~/.freshell-`) and the daemon service - * templates pin state; FRESHELL_HOME cannot express those paths because - * it is the PARENT of '.freshell'. - * 2. FRESHELL_HOME (or the OS homedir) + '/.freshell'. - */ -export function getFreshellConfigDir(env: NodeJS.ProcessEnv = process.env): string { - const configOverride = env.FRESHELL_CONFIG_DIR?.trim() - if (configOverride) return path.resolve(configOverride) - return path.join(getFreshellHomeDir(env), '.freshell') -} -``` - -Then route the consumer set through the helper, from the PRE-ENUMERATED table -below (load-bearing validation LB-01 replaced ad-hoc auditing; the validators' -file:line evidence is in the run-log directory of the main checkout, at -`/.worktrees/.the-usual-logs/electron-multi-profile/reports/load-bearing-validator-lb-01.md` -(i.e. the `.worktrees/.the-usual-logs/` directory; these run artifacts live -OUTSIDE the git worktree and outside git history). - -**a) Re-route through `getFreshellConfigDir()` (call-time) — profile-scoped:** - -| Site | Change | -|---|---| -| `server/logger.ts:104-105,119-120,138-139` (three `resolve*LogPath` fns) | Route the default through `getFreshellConfigDir` (join `/logs/`), KEEPING each `FRESHELL_LOG_DIR` override's precedence unchanged (behavior-preserving when neither var is set). This is an ACCEPTANCE CRITERION of this task — the README's "logs per profile" promise (Task 9) is false without it. Do NOT apply the leave-deliberately hatch here. | -| `server/coding-cli/codex-app-server/durability-store.ts:26-27` | Default `defaultCodexDurabilityStoreDir()` to `path.join(getFreshellConfigDir(), 'codex-durability')`, keeping `FRESHELL_CODEX_DURABILITY_DIR` precedence. | -| `server/coding-cli/codex-app-server/runtime.ts:226` (consumed `:358` via `defaultMetadataDir()`) | Restructure the import-time exported const into a call-time getter (e.g. `defaultCodexSidecarMetadataDir()`), keeping `FRESHELL_CODEX_SIDECAR_DIR` precedence; update the sole consumer. | -| `server/fresh-agent-extras-router.ts:21` (attachments) | Route through `getFreshellConfigDir()`. | -| `server/fresh-agent-extras-router.ts:77` (checkpoint shadow repos) | Route through `getFreshellConfigDir()`. DECISION: profile-scoped (splitting is the lesser evil — the shadow repos track per-client edit sessions). | -| `server/fresh-agent/recovery-store.ts:59` (+ lazy singleton `:152-154`) | Route the constructor default through `getFreshellConfigDir()`. Accept the lazy-singleton binding ("env honored if set before first `get()`" — true for env-at-launch, which is the only way spawn env reaches the server). | - -**b) Leave machine-global deliberately (note in commit message):** - -| Site | Why machine-global | -|---|---| -| `server/network-manager.ts:67-69` (Windows firewall ports file) | One machine = one firewall; two files = split-brain port bookkeeping. ALSO: this site's `FRESHELL_HOME`-direct shape (no `/.freshell` suffix) diverges from the helper — do NOT mechanically re-route it (that would change behavior for `FRESHELL_HOME`-only deployments). Leave entirely as-is. | -| `server/wsl-port-forward.ts:60` (WSL port-forwards file) | Same one-machine rationale (one WSL VM). | -| `server/index.ts:296` (checkout-scoped extensions dir) | Deliberately cwd/project-scoped, not home state. | -| `server/mcp/config-writer.ts:163,167` (per-project MCP sidecar) | Deliberately project-scoped. | -| `server/config-store.ts:236` | Hardcoded `~/.freshell` in a user-facing warning string — cosmetic drift only, not a resolution site; leave. | - -> **As-built reconciliation (post-review):** `config-store.ts:236` was -> revisited by the delta review (Minor: the hint targeted `~/.freshell` paths -> even under a named profile) and the later independent review (Minor: quote -> the rendered paths). The final text logs computed -> `backupPath()`/`configPath()` with shell quoting: -> ``mv "" ""``. The exact-file staging list below is -> accordingly amended to include `server/config-store.ts`. - -Already-clean call-time consumers (no changes; was the plan's old list): -`bootstrap.ts:168`, `tabs-registry/store.ts:314`, `instance-id.ts:9`, -`index.ts:241`, `cli/config.ts:10`, `get-network-host.ts:45`, -`config-store.ts:80`, and `session-scanner/service.ts:56` (lazy-bound via -`getSessionRepairService` — accepted, env-at-launch). - -Sanity greps after the re-route (expect zero NEW hits vs the pre-list above): - -Run: `rg -n "getFreshellHomeDir\(" server/ --type ts` -Run: `rg -n -e "\.freshell" server/ --type ts` - - -- [ ] **Step 4: Run the focused test** - -Run: `npm run test:vitest -- run test/unit/server/freshell-home.test.ts` - -Expected: PASS - -- [ ] **Step 5: Refactor while green** - -The re-routed consumers now call `getFreshellConfigDir()`; the import-time -const at `runtime.ts:226` is a call-time getter. - -- [ ] **Step 6: Run impacted-test verification** - -Impacted: every server unit touching home/config resolution. The coordinator -classifies mixed targets (`test/unit/server` = server-owned, -`test/unit/vite-config.test.ts` = default-owned) under the DEFAULT vitest -config, which EXCLUDES `test/unit/server/**` — so never combine both in one -command; run them separately, letting the coordinator route each: - -Run: `npm run test:vitest -- run test/unit/server` -Run: `npm run test:vitest -- run test/unit/vite-config.test.ts` - -Expected: PASS on both (no behavior change when `FRESHELL_CONFIG_DIR` is unset -— every re-routed site's default still lands at `~/.freshell/...`; the -deliberately machine-global sites are untouched, including -`network-manager.ts`'s divergent `FRESHELL_HOME`-direct shape). - -- [ ] **Step 7: Commit the task** - -```bash -git add server/freshell-home.ts server/logger.ts \ - server/coding-cli/codex-app-server/durability-store.ts \ - server/coding-cli/codex-app-server/runtime.ts \ - server/fresh-agent-extras-router.ts server/fresh-agent/recovery-store.ts \ - server/config-store.ts \ - test/unit/server/freshell-home.test.ts test/unit/server/logger.test.ts \ - test/unit/server/coding-cli/codex-app-server/durability-store.test.ts \ - test/unit/server/coding-cli/codex-app-server/runtime.test.ts \ - test/unit/server/fresh-agent/recovery-store.test.ts \ - test/server/fresh-agent-extras.test.ts \ - docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "feat(server): honor FRESHELL_CONFIG_DIR for config dir resolution" -``` - -(Stage EXACTLY these files — never `git add -A`, `-u`, or command-substitution -builts from `git diff`, which can sweep in unrelated concurrent work in this -multi-agent checkout. The test list above covers the re-routed consumers, one -behavioral test each, per the Work-queue convention.) - -Commit message MUST additionally carry this rollout caveat (a near-verbatim -version also lands in the README via Task 9): - -> **Daemon-unit caveat:** This change makes the Node server honor -> `FRESHELL_CONFIG_DIR`. The shipped daemon templates -> (`installers/systemd/freshell.service.template`, the launchd plist, and the -> Windows task XML) have always contained an (until now inert) -> `FRESHELL_CONFIG_DIR` line — if you previously generated a unit from them by -> hand and substituted a non-default config directory, that value now takes -> effect at the server's next start: config.json, tabs registry, instance id, -> and logs will relocate to (or be created fresh in) that directory, which -> looks like a settings reset. Either delete the `FRESHELL_CONFIG_DIR` line -> from your unit, or move your existing `~/.freshell` contents into the -> directory it names. Units installed with the default `~/.freshell` path are -> unaffected, as are all Rust-server installs (`freshell-rust.service`, -> `launch-rust.sh`), which do not read this variable. -> -> Machine-global by design (unchanged): Windows firewall port bookkeeping -> (`network-manager.ts`), WSL port-forward bookkeeping (`wsl-port-forward.ts`), -> checkout-scoped `server/index.ts` extensions dir, project-scoped MCP sidecars. -> (`load-bearing-validator-lb-01.md`, full consumer table.) - ---- - -### Task 5: Boot-time profile wiring in `entry.ts` (no picker yet) - -`entry.ts` is untestable-by-design (imports `electron`); keep this task's -logic minimal glue over Task 2's pure module. After this task: named-profile -boots get their own userData (and thus their own single-instance lock) and -config dir; the lock is acquired before any side effects; tray tooltip shows -the profile; none of this changes the default boot. - -**Files:** -- Modify: `electron/entry.ts` - -**Interfaces:** -- Consumes: Task 2's `resolveBootShape`, `readProfilesRegistry`, - `registryPathForHome`, `configDirForProfile`, `DEFAULT_PROFILE_ID`; Task 1's - optional `configDir` params; Task 3's `acquireInstanceLock` and tray - `appearance`. -- Produces: `activeProfileId`, `isPickerLauncher` + boot-bound `configDir` - consumed by the rest of the boot; the picker window flow arrives in Task 6. - -- [ ] **Step 1: Establish the red gate — profile decision covered by unit tests (Task 2), wiring covered by the sandboxed smoke** - -`entry.ts` is excluded from unit tests by repo convention (its header comment). -The profile-selection DECISION is already red-green'd in Task 2 via -`resolveBootShape` unit tests. What remains untested until the wiring lands is -the wiring itself; its behavioral gate is the sandboxed xvfb smoke below -(LB-03-executed procedure). Establish the red state first: - -Run the Step-4 smoke NOW, before editing `entry.ts`. Expected red evidence: -`$SMOKE/home/.freshell/logs/electron-main.*.jsonl` exists under the DEFAULT -dir (no `.freshell-smoketest/` dir exists at all) and the first log line has -no `"profile"` field — proving the wiring is absent. - -Also establish compile baseline: - -Run: `npm run build:electron` - -Expected: PASS today (baseline for the diff). - -- [ ] **Step 2: Add the wiring** - -Three edits in `electron/entry.ts`: - -(a) Module-top — replace - -```ts -const isPortAvailable = createPortAvailabilityCheck() - -const isDev = process.env.ELECTRON_DEV === '1' -const configDir = path.join(os.homedir(), '.freshell') -const mainProcessLogger = createElectronMainLogger({ configDir }) -``` - -with - -```ts -const isPortAvailable = createPortAvailabilityCheck() - -const isDev = process.env.ELECTRON_DEV === '1' - -// --- Boot-shape resolution (must run before configDir/logger binding) ------- -// One process = one Chromium userData = one instance lock, ALWAYS. Named -// profiles (--profile= or FRESHELL_PROFILE) and the picker launcher each -// get their own userData dir — which also re-keys the single-instance lock — -// so the picker NEVER shares a userData dir with a resident Default instance -// (two browser processes on one profile dir is a Chromium storage hazard). -const registryAtBoot = readProfilesRegistry( - registryPathForHome(os.homedir()), - (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : undefined), -) -const bootShape = resolveBootShape( - process.argv, process.env, registryAtBoot, - app.getName(), app.getPath('appData'), os.homedir(), -) -if (bootShape.userDataDir) { - // Electron's doc contract for app.setPath: the target directory must - // exist. Empirically 33.4.11 does NOT throw for deep nonexistent paths on - // Linux (load-bearing finder Appendix B.2), but create-first is the - // documented-correct order and is required at minimum on other platforms. - fs.mkdirSync(bootShape.userDataDir, { recursive: true }) - app.setPath('userData', bootShape.userDataDir) -} -const activeProfileId = bootShape.profileId -const isPickerLauncher = bootShape.kind === 'picker' -const configDir = bootShape.configDir -const mainProcessLogger = createElectronMainLogger({ configDir }) -if (registryAtBoot.error) { - mainProcessLogger.log({ severity: 'warn', event: 'profiles_registry_invalid', error: registryAtBoot.error }) -} -if (bootShape.error) { - mainProcessLogger.log({ severity: 'warn', component: 'electron-profile', event: 'profile_selection_invalid', error: bootShape.error }) -} - -/** True once this process holds its (userData-keyed) instance lock; - * re-entrant main() calls (wizard completion) must not re-request it. */ -let instanceLockHeld = false -``` - -and update the imports (Task 5 needs these; the picker IPC imports arrive in -Task 6): - -```ts -import { - configDirForProfile, - readProfilesRegistry, - registryPathForHome, - resolveBootShape, - DEFAULT_PROFILE_ID, -} from './profile.js' -import { acquireInstanceLock, initMainProcess } from './main.js' -``` - -(remove the old `import { initMainProcess } from './main.js'`.) - -(b) In `main()`, immediately after the existing `electron_main_started` log -(before `window-all-closed` registration and before any side effects such as -provisioning or server spawn). Under the round-3-corrected model EVERY process -— explicit profile, plain default, and picker launcher alike — passes this -gate; the only variance is WHICH userData (and therefore which lock) module -top selected. Task 6 will insert the profile-picker block immediately AFTER -this gate for picker launchers only: - -```ts - // Instance lock, acquired BEFORE any side effects (provisioning, server - // spawn). Keyed to the userData dir chosen at module top: an explicit - // profile's own dir, the default dir for a plain launch, or the launcher - // dir for a picker launch. A same-profile duplicate quits here (delivering - // `second-instance` to the resident, which then shows its window — see - // Task 3's surfacing fix). - // - // The onDenied hook lifts the `will-quit` wizard-phase veto: at this point - // `wizardPhase` is still true (it only flips false once a chooser/main - // window is reached), and entry.ts's module-level `will-quit` guard would - // otherwise preventDefault() this quit, leaving the turned-away duplicate - // as a headless zombie process. A denied duplicate never enters the wizard, - // so flipping it is unconditionally correct here. - if (!instanceLockHeld) { - if (!acquireInstanceLock(app, () => { wizardPhase = false })) { - return - } - instanceLockHeld = true - } -``` - -Immediately after the lock gate, register the CANONICAL `second-instance` -surfacing handler (round-4 finding: `initMainProcess` installs its handler -only at the END of boot — a duplicate arriving during the wizard/chooser -phases would deliver to a resident with NO handler registered and surface -nothing). Covering all phases from here is also what lets the e2e turn-away -spec observe real surfacing rather than tautological visibility: - -```ts - // Canonical duplicate-launch surfacing, registered ONCE, as early as - // possible: covers the wizard, chooser, and (until initMainProcess's own - // handler supersedes it for the main window) every intermediate phase. - if (!app.listenerCount('second-instance')) { - app.on('second-instance', () => { - const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) - if (!win) return - if (win.isMinimized()) win.restore() - win.show() - win.focus() - }) - } -``` - -And `initMainProcess` in Task 3 registers its `second-instance` handler ONLY -if none exists yet (`app.listenerCount('second-instance') === 0`), so the -canonical handler wins for early phases and the targeted main-window handler -takes over late-boot; main.test.ts's harness (a bare EventEmitter) supports -`listenerCount`, and a new case there pins the no-double-registration rule. - -Also extend the existing `electron_main_started` log with `profile: activeProfileId`. - -(c) Thread `configDir` into every desktop-config / window-state / provisioning -call site in main(): provisioning deps (`patchDesktopConfig: (p) => patchDesktopConfig(p, configDir)`), -boot read (`readDesktopConfig(configDir)`), `complete-setup` -(`patchDesktopConfig({...}, configDir)`), chooser deps -(`patchDesktopConfig: (patch) => patchDesktopConfig(patch, configDir)`), and -`createWindowStatePersistence(configDir)`. Pass the tray tooltip: - -```ts -createTray(Tray as any, Menu as any, iconPath, { /* existing callbacks */ }, - { tooltip: activeProfileId === DEFAULT_PROFILE_ID ? 'Freshell' : `Freshell (${activeProfileId})` }) -``` - -(d) Nothing to clean up for Task 6: a picker launcher RETURNS from main() -after `runProfilePicker` (its IPC handlers die with `app.exit(0)`), and an -explicit/default boot never registers them, so the wizard-driven main() -re-entry has no picker handlers to remove. - -- [ ] **Step 3: Verify compile + unit suite + sandboxed dev smoke (green gate)** - -Run: `npm run build:electron && npm run test:vitest -- --config config/vitest/vitest.electron.config.ts --run` - -Expected: PASS both. - -Manual smoke (sandboxed, then discard). This host has no display (`DISPLAY` -unset): without one, Electron dies at ozone init (`Missing X server or -$DISPLAY`, SIGSEGV) **before** `whenReady()`, and the main-process logger only -creates its file lazily on the first `log()` call (which fires after -`whenReady()`), so an un-wrapped run produces no log file at all. The smoke -MUST therefore run under `xvfb-run -a` with a fully throwaway HOME so the real -`~/.freshell*` is never touched (procedure executed and pinned during -load-bearing validation — report in the main checkout's run-log directory: -`/.worktrees/.the-usual-logs/electron-multi-profile/reports/load-bearing-validator-lb-03.md`). -From the worktree: - -```bash -SMOKE=/tmp/freshell-profile-smoke-$$ -mkdir -p "$SMOKE"/{home,xdg,cache,data} -env -u DISPLAY \ - HOME="$SMOKE/home" XDG_CONFIG_HOME="$SMOKE/xdg" \ - XDG_CACHE_HOME="$SMOKE/cache" XDG_DATA_HOME="$SMOKE/data" \ - ELECTRON_DEV=0 timeout 30 xvfb-run -a npx electron . --profile=smoketest \ - > "$SMOKE/boot.log" 2>&1 -echo "exit=$?" -``` - -Expected observable evidence: - -1. `exit=124` — timeout killed a process that was still alive at 30 s. Any - other code (1 = ozone SIGSEGV ⇒ display wrapper missing; anything else ⇒ - crash) fails the smoke. A trailing `FATAL:...Failed to shutdown` + SIGTRAP - pair at the 30 s mark is the kill artifact, not a failure. -2. `boot.log` contains **no** `Missing X server` line and **no** mention of - `dist/server`. It DOES contain: - `electron: Failed to load URL: file:///dist/wizard/index.html with error: ERR_FILE_NOT_FOUND` - — expected, and it is the proof the boot reached the setup-wizard path: a - fresh HOME has no `desktop.json`, so `setupCompleted:false` routes to the - wizard (`runStartup` returns `{ type: 'wizard' }` before any server spawn), - and `build:electron` does not build the wizard bundle (`build:wizard` / - full `build` do). Optional: run `npm run build:wizard` first to make the - line disappear; the smoke passes either way. GPU (`viz_main_impl`), DBus - (`StartServiceByName ... NoReply`), UNDICI proxy, and possibly - `electron-updater not available` lines are benign noise on this host. -3. `ls "$SMOKE/home/.freshell-smoketest/logs/"` shows - `electron-main..jsonl` whose first line contains - `"event":"electron_main_started"` and `"profile":"smoketest"`. - Timing caveat: this file is created lazily by the first log record, which - fires only after `whenReady()` — its mere existence is the proof the display - path worked; a no-display boot writes nothing. (The default profile would - log to `$SMOKE/home/.freshell/logs/` instead — Task 5's namespacing is what - moves it to `.freshell-smoketest`.) -4. `ls "$SMOKE/xdg/"` shows the profile's userData dir (per Task 2's - `userDataDirForProfile` layout) — full userData/lock/config isolation - assertions remain Task 8 e2e's job; this step only checks the sandbox - captured them. -5. Leak check (must pass): `ls -ld ~/.freshell-smoketest ~/.config/freshell*` - still says "No such file or directory", and - `ss -tln | grep -E ':3001 |:517[3-9] '` is unchanged from before the run - (the wizard path never spawns a server, so no new listener may appear). - -Cleanup: `rm -rf "$SMOKE"`. - -- [ ] **Step 4: Refactor while green** - -None expected. - -- [ ] **Step 5: Run impacted-test verification** - -Same as Step 3 first command. Include the startup/desktop-config suites: - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts --run` - -Expected: PASS - -- [ ] **Step 6: Commit the task** - -```bash -git add electron/entry.ts docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "feat(electron): boot-time profile wiring (userData namespacing, per-profile lock, launcher-userData boot shape)" -``` - ---- - -### Task 6: Profile picker decision flow (IPC handler + preload + `entry.ts` picker) - -The picker window runs in a dedicated LAUNCHER process: Task 5's module-top -`resolveBootShape` gives a picker launch its own userData dir -(`/-profile-picker`) and the Task 5 lock gate makes it hold -the launcher-scoped instance lock. Two consequences: - -1. A flag-less launch ALWAYS shows the picker when the registry names ≥1 - profile — even while a Default (or named) instance is resident — because - the launcher never contends for a real profile's userData/lock (Chromium - never sees two browser processes over one profile dir). -2. A racing second flag-less launch loses the launcher lock, quits at the - gate, and the resident picker receives `second-instance` (its own handler - surfaces the picker window — registered in `runProfilePicker` below). - -EVERY confirmed choice — Default included — relaunches the app as an explicit -profile (`app.relaunch({ args: [...stripProfileArgs(argv.slice(1)), '--profile='] })`, -then `app.exit(0)`). Continuing Default in-process is NOT possible because the -launcher's userData is the launcher dir, not the default one; the relaunched -process is an explicit launch that namespaces correctly (default leaves -userData alone) and acquires the chosen profile's lock — turning away (and -surfacing the resident) when that profile is already running, via the normal -explicit-duplicate path. Closing the picker without choosing exits the app. - -**Files:** -- Create: `electron/profile-choice-handler.ts` -- Modify: `electron/preload.ts` (two new channels) -- Modify: `electron/entry.ts` (picker step in `main()` + `runProfilePicker`) -- Test: `test/unit/electron/profile-choice-handler.test.ts` (new) -- Test: `test/unit/electron/preload.test.ts` (extend the exact-keys assertion — it pins the API shape and currently lists 12 keys) - -**Interfaces:** -- Consumes: Task 2's `PickerEntry`, `buildPickerEntries`, `stripProfileArgs`; - the Task 5 module-top products `registryAtBoot` and `isPickerLauncher`. -- Produces: `createChooseProfileHandler(deps)`; preload API - `getProfiles(): Promise` and `chooseProfile(id): Promise`. - -- [ ] **Step 1: Write the failing behavioral test** - -`test/unit/electron/preload.test.ts` — its 'has exactly the expected keys' -assertion pins the API surface and will fail until the two new keys are added -to the sorted list: - -```ts - expect(keys).toEqual([ - 'chooseLaunchOption', - 'chooseProfile', - 'completeSetup', - 'getLaunchOptions', - 'getProfiles', - 'getServerMode', - 'getServerStatus', - 'installUpdate', - 'isElectron', - 'onUpdateAvailable', - 'onUpdateDownloaded', - 'openExternal', - 'platform', - 'setGlobalHotkey', - ]) -``` - -```ts -// test/unit/electron/profile-choice-handler.test.ts -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createChooseProfileHandler } from '../../../electron/profile-choice-handler.js' - -const entries = [ - { id: 'default', label: 'Default' }, - { id: 'work', label: 'Work' }, -] - -function harness(overrides: Partial[0]> = {}) { - const deps = { - entries, - isAllowedSender: vi.fn().mockReturnValue(true), - relaunchWithProfile: vi.fn(), - ...overrides, - } - return { deps, handler: createChooseProfileHandler(deps) } -} - -describe('choose-profile handler', () => { - beforeEach(() => vi.clearAllMocks()) - - it('rejects events from a foreign sender', async () => { - const { deps, handler } = harness({ isAllowedSender: () => false }) - expect(await handler({}, 'work')).toEqual({ ok: false, error: 'Unexpected profile request.' }) - expect(deps.relaunchWithProfile).not.toHaveBeenCalled() - }) - - it('rejects non-string and unknown ids', async () => { - const { deps, handler } = harness() - expect(await handler({}, 42)).toEqual({ ok: false, error: 'Unknown profile.' }) - expect(await handler({}, 'unknown')).toEqual({ ok: false, error: 'Unknown profile.' }) - expect(deps.relaunchWithProfile).not.toHaveBeenCalled() - }) - - it('the default choice relaunches as the explicit default profile', async () => { - const { deps, handler } = harness() - expect(await handler({}, 'default')).toEqual({ ok: true }) - expect(deps.relaunchWithProfile).toHaveBeenCalledWith('default') - }) - - it('a named profile relaunches with it', async () => { - const { deps, handler } = harness() - expect(await handler({}, 'work')).toEqual({ ok: true }) - expect(deps.relaunchWithProfile).toHaveBeenCalledWith('work') - }) -}) -``` - -- [ ] **Step 2: Run the test and verify the intended failure** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/profile-choice-handler.test.ts test/unit/electron/preload.test.ts --run` - -Expected: FAIL — `electron/profile-choice-handler.js` does not exist, and -`preload.test.ts`'s exact-keys assertion lacks `getProfiles`/`chooseProfile` in -the exposed API. - -- [ ] **Step 3: Add the production implementation** - -`electron/profile-choice-handler.ts`: - -```ts -import { z } from 'zod' -import type { PickerEntry } from './profile.js' - -export interface ChooseProfileHandlerDeps { - entries: PickerEntry[] - /** Defense-in-depth: only the picker window may drive this channel. */ - isAllowedSender: (event: unknown) => boolean - /** Relaunch the app pinned to the chosen profile id, then exit this - * launcher process. 'default' is a valid id -- the relaunched process is - * an explicit launch of the default profile. */ - relaunchWithProfile: (id: string) => void -} - -export type ProfileChoiceResult = { ok: true } | { ok: false; error: string } - -export function createChooseProfileHandler(deps: ChooseProfileHandlerDeps) { - const allowed = new Set(deps.entries.map((e) => e.id)) - return async (event: unknown, rawId: unknown): Promise => { - if (!deps.isAllowedSender(event)) { - return { ok: false, error: 'Unexpected profile request.' } - } - const parsed = z.string().safeParse(rawId) - if (!parsed.success || !allowed.has(parsed.data)) { - return { ok: false, error: 'Unknown profile.' } - } - deps.relaunchWithProfile(parsed.data) - return { ok: true } - } -} -``` - -`electron/preload.ts` — extend `FreshellDesktopApi` and the registration: - -```ts -export type ProfileChoiceResult = { ok: true } | { ok: false; error: string } -export interface PickerProfileEntry { id: string; label: string } -``` - -Add to the `FreshellDesktopApi` interface: - -```ts - getProfiles: () => Promise - chooseProfile: (id: string) => Promise -``` - -and to `registerPreloadApi`'s api object: - -```ts - getProfiles: () => ipcRenderer.invoke('get-profiles'), - chooseProfile: (id: string) => ipcRenderer.invoke('choose-profile', id), -``` - -`electron/entry.ts` — add imports (Task 5's block already imported -`readProfilesRegistry` / `registryPathForHome` / `resolveBootShape` and defined -`registryAtBoot` / `isPickerLauncher`; extend it): - -```ts -import { - buildPickerEntries, - stripProfileArgs, - type PickerEntry, -} from './profile.js' -import { createChooseProfileHandler } from './profile-choice-handler.js' -``` - -Add the launcher picker function (module level): - -```ts -/** - * Show the profile picker and relaunch into the chosen profile. - * - * This launcher process holds the LAUNCHER-scoped instance lock (own - * userData dir), so a racing flag-less launch is turned away at the lock gate - * and delivers `second-instance` here, where we surface the existing picker - * window. Every confirmed choice — Default included — relaunches with an - * explicit `--profile=` and exits; the relaunched process then takes the - * chosen profile's own lock. The returned promise simply never settles. - * Closing the picker without choosing exits the app. - */ -async function runProfilePicker(entries: PickerEntry[]): Promise { - const pickerWin = new BrowserWindow({ - width: 520, - height: 480, - show: false, - autoHideMenuBar: true, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - nodeIntegration: false, - contextIsolation: true, - }, - }) - const pickerWebContentsId = pickerWin.webContents.id - const onSecondInstance = () => { - if (!pickerWin.isDestroyed()) { - pickerWin.show() - pickerWin.focus() - } - } - app.on('second-instance', onSecondInstance) - - const cleanup = () => { - app.removeListener('second-instance', onSecondInstance) - ipcMain.removeHandler('get-profiles') - ipcMain.removeHandler('choose-profile') - } - - ipcMain.removeHandler('get-profiles') - ipcMain.removeHandler('choose-profile') - ipcMain.handle('get-profiles', () => entries) - ipcMain.handle('choose-profile', createChooseProfileHandler({ - entries, - isAllowedSender: (event) => - (event as { sender?: { id?: number } }).sender?.id === pickerWebContentsId, - relaunchWithProfile: (id) => { - const args = [...stripProfileArgs(process.argv.slice(1)), `--profile=${id}`] - app.relaunch({ args }) - app.exit(0) - }, - })) - - pickerWin.on('closed', () => { - cleanup() - app.exit(0) - }) - - if (isDev) { - void pickerWin.loadURL('http://localhost:5179') - } else { - const packaged = path.join(process.resourcesPath, 'profile-picker', 'index.html') - const unpackaged = path.join(app.getAppPath(), 'dist', 'profile-picker', 'index.html') - void pickerWin.loadFile(fs.existsSync(packaged) ? packaged : unpackaged) - } - pickerWin.show() - return new Promise(() => { - // Never settles: this launcher exits via app.exit(0) on choice or close. - }) -} -``` - -And in `main()`, insert the picker block AFTER the Task 5 (b) lock gate (the -gate is where the launcher locks its own userData). A picker launcher never -proceeds past this point into provisioning or startup — it shows the picker -and exits on choice/close: - -```ts - // --- Profile picker ------------------------------------------------------- - // A picker launch (no explicit profile + registry names ≥1 profile) parks - // its userData in the launcher dir, holds the launcher lock, shows only the - // picker, and ends here. See resolveBootShape (module top) for the shape - // decision and runProfilePicker for choice semantics. - if (isPickerLauncher) { - await runProfilePicker(buildPickerEntries(registryAtBoot)) - return - } -``` - -Keep the rest of `main()` untouched for explicit/default boots — a picker -launcher never reaches provisioning (`patchDesktopConfig`) or server spawn, so -no provisioning can smear onto a config before the choice is final. - -- [ ] **Step 4: Run the focused test + compile** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/profile-choice-handler.test.ts test/unit/electron/preload.test.ts --run && npm run build:electron` - -Expected: PASS both. Note: the picker renderer does not exist yet (Task 7), so -a live boot with a registry present will fail to load the picker URL — that is -expected at this task boundary; the flow is fully e2e-proven in Task 8. - -- [ ] **Step 5: Refactor while green** - -None expected. - -- [ ] **Step 6: Run impacted-test verification** - -Impacted: full electron unit suite (preload API shape changed). - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts --run` - -Expected: PASS - -- [ ] **Step 7: Commit the task** - -```bash -git add electron/profile-choice-handler.ts electron/preload.ts electron/entry.ts \ - test/unit/electron/profile-choice-handler.test.ts test/unit/electron/preload.test.ts \ - docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "feat(electron): profile picker decision flow (choose-profile IPC + launcher picker)" -``` - ---- - -### Task 7: Profile picker renderer + build/packaging wiring - -The picker UI follows the launch-chooser pattern exactly: its own vite config -(dev port 5179, outDir `dist/profile-picker`, `base: './'`), extraResources -packaging (NOT asar — matches the chooser, which loads from the real fs), and -a React component that declares its own narrow `window.freshellDesktop` -surface in-file. - -**Files:** -- Create: `electron/profile-picker/index.html` -- Create: `electron/profile-picker/main.tsx` -- Create: `electron/profile-picker/picker.tsx` -- Create: `electron/profile-picker/picker.css` -- Create: `config/vite/vite.profile-picker.config.ts` -- Modify: `package.json` (3 scripts) -- Modify: `config/electron-builder.yml` (extraResources) -- Modify: `tsconfig.electron.json` (exclude picker tsx/html) -- Test: `test/unit/electron/profile-picker/picker.test.tsx` (new) -- Packaging verification: executed `electron-builder --dir` staging smoke in Step 6 (no declarative-config test — config-text assertions do not qualify as behavioral coverage per repo policy) - -**Interfaces:** -- Consumes: preload's `getProfiles` / `chooseProfile` (Task 6). -- Produces: `dist/profile-picker/**` build artifact; `build:profile-picker` / - `dev:profile-picker` scripts; picker bundled into packaged apps. - -- [ ] **Step 1: Write the failing behavioral test** - -```tsx -// test/unit/electron/profile-picker/picker.test.tsx -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ProfilePicker } from '../../../../electron/profile-picker/picker.js' - -function installDesktopApi(options: { chooseProfile?: ReturnType } = {}) { - const chooseProfile = options.chooseProfile ?? vi.fn().mockResolvedValue({ ok: true }) - window.freshellDesktop = { - getProfiles: vi.fn().mockResolvedValue([ - { id: 'default', label: 'Default' }, - { id: 'work', label: 'Work' }, - ]), - chooseProfile, - } - return { chooseProfile } -} - -afterEach(() => { - cleanup() - delete window.freshellDesktop -}) - -describe('ProfilePicker', () => { - beforeEach(() => vi.clearAllMocks()) - - it('renders an accessible button per profile once loaded', async () => { - installDesktopApi() - render() - expect(await screen.findByRole('button', { name: 'Default' })).toBeTruthy() - expect(screen.getByRole('button', { name: 'Work' })).toBeTruthy() - }) - - it('chooses a profile on click', async () => { - const { chooseProfile } = installDesktopApi() - render() - fireEvent.click(await screen.findByRole('button', { name: 'Work' })) - await waitFor(() => expect(chooseProfile).toHaveBeenCalledWith('work')) - }) - - it('surfaces a rejected choice via role="alert"', async () => { - const chooseProfile = vi.fn().mockResolvedValue({ ok: false, error: 'Unknown profile.' }) - installDesktopApi({ chooseProfile }) - render() - fireEvent.click(await screen.findByRole('button', { name: 'Work' })) - expect(await screen.findByRole('alert')).toBeTruthy() - }) - - it('surfaces a rejected getProfiles() promise via role="alert" instead of a blank window', async () => { - window.freshellDesktop = { - getProfiles: vi.fn().mockRejectedValue(new Error('ipc blew up')), - chooseProfile: vi.fn(), - } - render() - expect((await screen.findByRole('alert')).textContent).toContain('ipc blew up') - }) - - it('surfaces a rejected chooseProfile() promise via role="alert"', async () => { - const chooseProfile = vi.fn().mockRejectedValue(new Error('channel closed')) - installDesktopApi({ chooseProfile }) - render() - fireEvent.click(await screen.findByRole('button', { name: 'Work' })) - expect((await screen.findByRole('alert')).textContent).toContain('channel closed') - }) -}) -``` - -- [ ] **Step 2: Run the test and verify the intended failure** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/profile-picker/picker.test.tsx --run` - -Expected: FAIL because `electron/profile-picker/picker.js` does not exist. - -- [ ] **Step 3: Add the production implementation** - -`electron/profile-picker/index.html` (mirrors the launch chooser's): - -```html - - - - - - Freshell Profiles - - -
- - - -``` - -`electron/profile-picker/main.tsx`: - -```tsx -import React from 'react' -import { createRoot } from 'react-dom/client' -import './picker.css' -import { ProfilePicker } from './picker.js' - -createRoot(document.getElementById('root')!).render( - - - , -) -``` - -`electron/profile-picker/picker.tsx` (local narrow `window.freshellDesktop` -declaration, matching the setup-wizard/launch-chooser convention): - -```tsx -import { useEffect, useState } from 'react' - -declare global { - interface Window { - freshellDesktop?: { - getProfiles?: () => Promise<{ id: string; label: string }[]> - chooseProfile?: (id: string) => Promise<{ ok: true } | { ok: false; error: string }> - } - } -} - -interface PickerEntry { - id: string - label: string -} - -export function ProfilePicker() { - const [entries, setEntries] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let cancelled = false - void window.freshellDesktop?.getProfiles?.().then((list) => { - if (!cancelled) setEntries(list ?? []) - }).catch((err: unknown) => { - if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load profiles') - }) - return () => { - cancelled = true - } - }, []) - - const choose = async (id: string) => { - setError(null) - try { - const result = await window.freshellDesktop?.chooseProfile?.(id) - if (result && !result.ok) setError(result.error) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to choose profile') - } - } - - return ( -
-

Choose a Freshell profile

-

- This machine has more than one Freshell profile. Each profile keeps its - own settings and can connect to a different server. -

- {error ? ( -

{error}

- ) : null} -
    - {(entries ?? []).map((entry) => ( -
  • - -
  • - ))} -
-
- ) -} -``` - -`electron/profile-picker/picker.css` (imports the wizard's tailwind base via -the config's postcss, same as chooser.css — copy the chooser's import line and -add minimal styles): - -```css -@import 'tailwindcss/base'; -@import 'tailwindcss/components'; -@import 'tailwindcss/utilities'; - -/* If chooser.css uses different imports, mirror `electron/launch-chooser/chooser.css` exactly and keep only the rules below. */ - -body { - margin: 0; -} - -.picker { - max-width: 400px; - margin: 3rem auto; - padding: 0 1.5rem; -} - -.picker-subtitle { - font-size: 0.875rem; - opacity: 0.7; -} - -.picker-list { - list-style: none; - padding: 0; - margin: 1.5rem 0 0; -} - -.picker-list button { - width: 100%; - padding: 0.625rem 1rem; - margin-bottom: 0.5rem; - border-radius: 0.5rem; - cursor: pointer; -} - -.picker-error { - color: #b91c1c; -} -``` - -`config/vite/vite.profile-picker.config.ts` (mirror the chooser's exactly — -including the top-level-await postcss block — with root `electron/profile-picker`, -outDir `dist/profile-picker`, port **5179**). - -`package.json` — add scripts and wire them in: - -```json -"build:profile-picker": "vite build --config config/vite/vite.profile-picker.config.ts", -"dev:profile-picker": "vite --config config/vite/vite.profile-picker.config.ts", -``` - -- `electron:dev`: extend the concurrently list to `-n client,wizard,chooser,picker,electron` and add `"vite --config config/vite/vite.profile-picker.config.ts"`. -- `electron:build` and `electron:build:win`: insert `&& npm run build:profile-picker` right after `npm run build:launch-chooser`. - -`config/electron-builder.yml` — add to `extraResources`, right after the -launch-chooser block: - -```yaml - # Profile picker assets (loaded from the real filesystem before connecting) - - from: dist/profile-picker - to: profile-picker - filter: - - "**/*" -``` - -`tsconfig.electron.json` — extend `exclude`: - -```json - "electron/profile-picker/**/*.tsx", - "electron/profile-picker/index.html" -``` - -- [ ] **Step 4: Run the component test + build smoke** - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts test/unit/electron/profile-picker/picker.test.tsx --run && npm run build:profile-picker && npm run build:electron` - -Expected: PASS all; `dist/profile-picker/index.html` exists. - -- [ ] **Step 5: Refactor while green** - -None expected. - -- [ ] **Step 6: Verify packaging behaviorally + run impacted tests** - -The house convention for config assertions (`electron-builder-config.test.ts`) -is regex-only; repo guidance says config-text assertions do not qualify as -behavioral verification. The real question is whether a packaged build -contains the picker. Verify by staging an actual package layout: - -```bash -npm run build && npm run build:electron && npm run build:wizard && \ - npm run build:launch-chooser && npm run build:profile-picker && \ - npm run prepare:bundled-node -npx electron-builder --config config/electron-builder.yml --dir -test -f release/linux-unpacked/resources/profile-picker/index.html && echo "picker staged" -``` - -(NOTE: `config/electron-builder.yml` sets `directories.output: release` — the -staged layout lands under `release/linux-unpacked/` on Linux, NOT `dist/`, -so the assertion above must use `release/`. Adjust the platform dir segment -for the host OS. `--dir` stages the full packaged layout without building an -installer. If `prepare:bundled-node` is unusually slow, it may be skipped for -THIS check only: extraResources staging does not depend on the bundled -runtime.) - -Expected: the file exists (electron-builder copied `dist/profile-picker` into -`resources/profile-picker`). If it does not, the extraResources mapping is -wrong at the electron-builder layer — fix the yml entry, not the assertion. - -Then: - -Run: `npm run test:vitest -- --config config/vitest/vitest.electron.config.ts --run` - -Expected: PASS. (No new declarative-config test is added; the `--dir` smoke is -the packaging verification.) - -- [ ] **Step 7: Commit the task** - -```bash -git add electron/profile-picker/ config/vite/vite.profile-picker.config.ts \ - package.json config/electron-builder.yml tsconfig.electron.json \ - test/unit/electron/profile-picker/picker.test.tsx \ - docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "feat(electron): profile picker window and build packaging" -``` - ---- - -### Task 8: Electron e2e coverage (`test/e2e-electron/profile-picker.test.ts`) - -End-to-end proof of the user story on the real app: picker shows when a -registry exists, named profiles boot without it and get namespaced -userData + config dir, invalid registries are ignored safely. - -**Files:** -- Test: `test/e2e-electron/profile-picker.test.ts` (new) - -**Interfaces:** -- Consumes: everything above; the existing e2e harness style in - `test/e2e-electron/electron-app.test.ts` (temp HOME override, `electron.launch`). - -- [ ] **Step 1: Write the failing behavioral test** - -```ts -// test/e2e-electron/profile-picker.test.ts -/** - * Profile picker + namespacing E2E — launches the real Electron app with a - * temporary HOME containing a profiles.json registry. - * - * Requires dist/electron, dist/wizard, and dist/profile-picker to be built - * (same as the wizard/chooser specs in electron-app.test.ts). - */ - -import { test, expect, _electron as electron, type ElectronApplication } from '@playwright/test' -import path from 'path' -import fs from 'fs' -import os from 'os' - -const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..') - -function createTempHomeWithRegistry(registry: unknown): string { - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'freshell-e2e-profiles-')) - fs.mkdirSync(path.join(tmpHome, '.freshell'), { recursive: true }) - fs.writeFileSync( - path.join(tmpHome, '.freshell', 'profiles.json'), - typeof registry === 'string' ? registry : JSON.stringify(registry), - ) - return tmpHome -} - -async function launchApp(tmpHome: string, extraArgs: string[] = []): Promise { - // Sandbox ALL of Electron's per-user dirs, not just HOME: on Linux appData - // (and thus userData + the single-instance lock key) derives from - // XDG_CONFIG_HOME, and Chromium also writes XDG_CACHE_HOME/XDG_DATA_HOME. - // Without these, named profiles and locks could escape into the real home - // and collide with a live install (evidence: load-bearing-validator-lb-03). - // - // Also scrub profile-selection env from the ambient shell: an exported - // FRESHELL_PROFILE would silently make every "flag-less" spec explicit, - // and ELECTRON_DEV=1 would point the picker/wizard at dev-server URLs - // instead of the built dist/ assets these specs assert on. - const env = { ...process.env } - delete env.FRESHELL_PROFILE - delete env.ELECTRON_DEV - return electron.launch({ - args: [PROJECT_ROOT, ...extraArgs], - env: { - ...env, - HOME: tmpHome, - XDG_CONFIG_HOME: path.join(tmpHome, '.config'), - XDG_CACHE_HOME: path.join(tmpHome, '.cache'), - XDG_DATA_HOME: path.join(tmpHome, '.local', 'share'), - NODE_PATH: path.join(PROJECT_ROOT, 'node_modules'), - }, - cwd: PROJECT_ROOT, - }) -} - -test.describe('Profile picker', () => { - let app: ElectronApplication | undefined - let tmpHome: string | undefined - - test.afterEach(async () => { - if (app) { - // Hard-exit first (see the as-built reconciliation note at the end of - // this task's snippet): wizard/picker-phase apps veto app.quit(), so a - // bare close would hang the worker teardown. - await app.evaluate(() => process.exit(0)).catch(() => {}) - await app.close().catch(() => {}) - app = undefined - } - if (tmpHome) { - fs.rmSync(tmpHome, { recursive: true, force: true }) - tmpHome = undefined - } - }) - - test('shows the picker with Default first when the registry names profiles', async () => { - tmpHome = createTempHomeWithRegistry({ profiles: [{ id: 'work', label: 'Work' }] }) - app = await launchApp(tmpHome) - - const picker = await app.firstWindow() - await picker.waitForLoadState('domcontentloaded') - await expect( - picker.getByRole('heading', { name: 'Choose a Freshell profile' }), - ).toBeVisible() - await expect(picker.getByRole('button', { name: 'Default' })).toBeVisible() - await expect(picker.getByRole('button', { name: 'Work' })).toBeVisible() - }) - - // Every picker choice (Default included) relaunches as an explicit profile: - // the launcher's userData is the launcher dir, so continuing in-process - // would leak launcher storage into a real profile. Stub relaunch/exit in - // the main process before clicking and assert the rebuilt argv. - test('choosing Default from the picker relaunches as --profile=default', async () => { - tmpHome = createTempHomeWithRegistry({ profiles: [{ id: 'work', label: 'Work' }] }) - app = await launchApp(tmpHome) - - await app.evaluate(({ app: electronApp }) => { - const g = globalThis as Record - g.__relaunchCalls = [] - ;(electronApp as unknown as Record).relaunch = (opts: unknown) => { - ;(g.__relaunchCalls as unknown[]).push(opts) - } - ;(electronApp as unknown as Record).exit = (code: number) => { - g.__exitCode = code - } - }) - - const picker = await app.firstWindow() - await picker.waitForLoadState('domcontentloaded') - await picker.getByRole('button', { name: 'Default' }).click() - - await expect.poll(async () => app.evaluate(() => (globalThis as Record).__exitCode ?? null), - { timeout: 15_000 }).toBe(0) - const relaunchCalls = await app.evaluate(() => (globalThis as Record).__relaunchCalls) - expect(relaunchCalls).toHaveLength(1) - expect((relaunchCalls as { args: string[] }[])[0].args).toContain('--profile=default') - }) - - test('--profile boots the named profile without the picker and namespaces state', async () => { - tmpHome = createTempHomeWithRegistry({ profiles: [{ id: 'e2ework' }] }) - app = await launchApp(tmpHome, ['--profile=e2ework']) - - const window = await app.firstWindow() - await window.waitForLoadState('domcontentloaded') - // The named profile has an empty config dir → first-run wizard proves we booted. - await expect(window.locator('h1:has-text("Welcome to Freshell")')).toBeVisible({ timeout: 30_000 }) - - const userData = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')) - expect(path.basename(userData).toLowerCase()).toBe('freshell-e2ework') - - // The main-process logger is bound to the profile config dir. - await expect.poll(() => { - const logsDir = path.join(tmpHome!, '.freshell-e2ework', 'logs') - return fs.existsSync(logsDir) && - fs.readdirSync(logsDir).some((f) => /^electron-main\..*\.jsonl$/.test(f)) - }, { timeout: 15_000 }).toBe(true) - - // The default profile dir received no logs. - expect(fs.existsSync(path.join(tmpHome, '.freshell', 'logs'))).toBe(false) - }) - - // Two DIFFERENT named profiles must boot side by side (independent userData - // locks), each reading its OWN config and loading its OWN server. The test - // process hosts two throwaway HTTP stub servers with distinct marker bodies; - // each profile's remote-mode desktop.json points at one stub. Window URLs - // then prove the full chain per profile: config-dir read → remote mode → - // window loaded the seeded server. - test('two named profiles run concurrently, each loading its own server', async () => { - tmpHome = createTempHomeWithRegistry({ profiles: [{ id: 'e2ework' }, { id: 'e2ehome' }] }) - - const http = await import('http') - const stub = (marker: string) => new Promise<{ url: string; server: import('http').Server }>((resolve) => { - const server = http.createServer((req, res) => { - res.setHeader('content-type', (req.url ?? '').includes('/api/') ? 'application/json' : 'text/html') - if ((req.url ?? '').includes('/api/')) { - res.end(JSON.stringify({ ok: true })) - } else { - res.end(`MARKER:${marker}`) - } - }) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() - if (!addr || typeof addr === 'string') throw new Error('stub listen failed') - resolve({ url: `http://127.0.0.1:${addr.port}`, server }) - }) - }) - const [s1, s2] = await Promise.all([stub('WORK'), stub('HOME')]) - - const seedRemote = (id: string, url: string) => { - const dir = path.join(tmpHome!, `.freshell-${id}`) - fs.mkdirSync(dir, { recursive: true }) - fs.writeFileSync(path.join(dir, 'desktop.json'), JSON.stringify({ - serverMode: 'remote', port: 3001, - remoteUrl: url, remoteToken: 'e2e-token', - knownServers: [{ url, label: id }], - alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', - startOnLogin: false, minimizeToTray: false, setupCompleted: true, - }, null, 2)) - } - seedRemote('e2ework', s1.url) - seedRemote('e2ehome', s2.url) - - app = await launchApp(tmpHome, ['--profile=e2ework']) - const app2 = await launchApp(tmpHome, ['--profile=e2ehome']) - try { - // Both alive (independent per-profile locks). - expect(app.process().exitCode).toBeNull() - expect(app2.process().exitCode).toBeNull() - - const w1 = await app.firstWindow() - const w2 = await app2.firstWindow() - // Neither shows the first-run wizard (each read its OWN seeded config). - await expect.poll(async () => { - const isWizard = async (w: typeof w1) => (await w.locator('h1:has-text("Welcome to Freshell")').count()) > 0 - return !(await isWizard(w1)) && !(await isWizard(w2)) - }, { timeout: 30_000 }).toBe(true) - - // The core requested-behavior proof: each profile's window navigated to - // ITS OWN stub server. URL equality per profile = wrong-config wiring - // would land both windows on the same URL. - await expect.poll(() => w1.url(), { timeout: 30_000 }).toContain(String(new URL(s1.url).port)) - await expect.poll(() => w2.url(), { timeout: 30_000 }).toContain(String(new URL(s2.url).port)) - await expect(w1.locator('text=MARKER:WORK')).toBeVisible({ timeout: 30_000 }) - await expect(w2.locator('text=MARKER:HOME')).toBeVisible({ timeout: 30_000 }) - - const ud1 = await app.evaluate(({ app: a1 }) => a1.getPath('userData')) - const ud2 = await app2.evaluate(({ app: a2 }) => a2.getPath('userData')) - expect(path.basename(ud1).toLowerCase()).toBe('freshell-e2ework') - expect(path.basename(ud2).toLowerCase()).toBe('freshell-e2ehome') - - for (const id of ['e2ework', 'e2ehome']) { - await expect.poll(() => { - const d = path.join(tmpHome!, `.freshell-${id}`, 'logs') - return fs.existsSync(d) && fs.readdirSync(d).some((f) => /^electron-main\..*\.jsonl$/.test(f)) - }, { timeout: 15_000 }).toBe(true) - } - // The default profile dir received no logs from either named process. - expect(fs.existsSync(path.join(tmpHome, '.freshell', 'logs'))).toBe(false) - } finally { - await app2.close().catch(() => {}) - s1.server.close() - s2.server.close() - } - }) - - // The relaunch path itself must be proven: stub app.relaunch/app.exit in the - // main process before clicking, then assert the IPC choice rebuilt argv with - // --profile (an unstubbed relaunch would re-exec and lose the assertion). - test('choosing a named profile from the picker relaunches with --profile=', async () => { - tmpHome = createTempHomeWithRegistry({ profiles: [{ id: 'work', label: 'Work' }] }) - app = await launchApp(tmpHome) - - await app.evaluate(({ app: electronApp }) => { - const g = globalThis as Record - g.__relaunchCalls = [] - ;(electronApp as unknown as Record).relaunch = (opts: unknown) => { - ;(g.__relaunchCalls as unknown[]).push(opts) - } - ;(electronApp as unknown as Record).exit = (code: number) => { - g.__exitCode = code - } - }) - - const picker = await app.firstWindow() - await picker.waitForLoadState('domcontentloaded') - await picker.getByRole('button', { name: 'Work' }).click() - - await expect.poll(async () => app.evaluate(() => (globalThis as Record).__exitCode ?? null), - { timeout: 15_000 }).toBe(0) - const relaunchCalls = await app.evaluate(() => (globalThis as Record).__relaunchCalls) - expect(relaunchCalls).toHaveLength(1) - expect((relaunchCalls as { args: string[] }[])[0].args).toContain('--profile=work') - // stripProfileArgs must not double-append: exactly one --profile= entry. - expect((relaunchCalls as { args: string[] }[])[0].args.filter((a) => a.startsWith('--profile='))).toHaveLength(1) - }) - - test('an invalid registry file is ignored and the default profile boots', async () => { - tmpHome = createTempHomeWithRegistry('not valid json {{{') - app = await launchApp(tmpHome) - - const window = await app.firstWindow() - await window.waitForLoadState('domcontentloaded') - await expect(window.locator('h1:has-text("Welcome to Freshell")')).toBeVisible({ timeout: 30_000 }) - }) - - // LB-02 / dedicated-launcher design: a flag-less launch must reach the - // picker even while a Default-profile instance is resident (the launcher - // parks in its own userData with its own lock, so Default never blocks it). - // This is the steady state the feature exists for (minimizeToTray - // defaults true, so Default typically stays resident). - test('a flag-less launch while Default is resident still shows the picker', async () => { - tmpHome = createTempHomeWithRegistry({ profiles: [{ id: 'work', label: 'Work' }] }) - - // First process: an EXPLICIT default launch becomes the resident Default - // instance (a picker choice would relaunch into a new untracked process). - app = await launchApp(tmpHome, ['--profile=default']) - const window1 = await app.firstWindow() - await window1.waitForLoadState('domcontentloaded') - await expect(window1.locator('h1:has-text("Welcome to Freshell")')) - .toBeVisible({ timeout: 30_000 }) - - // Second flag-less launch shows the picker: the launcher's userData (and - // lock) is the dedicated launcher dir, so the resident Default does not - // contend with it at all. - const app2 = await launchApp(tmpHome) - try { - const picker2 = await app2.firstWindow() - await picker2.waitForLoadState('domcontentloaded') - await expect( - picker2.getByRole('heading', { name: 'Choose a Freshell profile' }), - ).toBeVisible({ timeout: 30_000 }) - } finally { - await app2.close().catch(() => {}) - } - }) - - // Racing flag-less launches: the second one loses the launcher lock, exits, - // and the resident picker receives second-instance (one picker at a time). - test('a second flag-less launch is turned away and delivers second-instance to the resident picker', async () => { - tmpHome = createTempHomeWithRegistry({ profiles: [{ id: 'work', label: 'Work' }] }) - app = await launchApp(tmpHome) - const picker = await app.firstWindow() - await picker.waitForLoadState('domcontentloaded') - await expect(picker.getByRole('heading', { name: 'Choose a Freshell profile' })) - .toBeVisible({ timeout: 30_000 }) - - await app.evaluate(({ app: launcherApp }) => { - ;(globalThis as Record).__pickerSecondInstance = 0 - launcherApp.on('second-instance', () => { - ;(globalThis as Record).__pickerSecondInstance = - ((globalThis as Record).__pickerSecondInstance as number) + 1 - }) - }) - - const app2 = await launchApp(tmpHome) - await expect.poll(() => app2.process().exitCode, { timeout: 30_000 }).not.toBeNull() - await app2.close().catch(() => {}) - - await expect.poll( - () => app.evaluate(() => (globalThis as Record).__pickerSecondInstance), - { timeout: 15_000 }, - ).toBe(1) - // The resident picker is still there, alive. - expect(app.process().exitCode).toBeNull() - await expect(picker.getByRole('heading', { name: 'Choose a Freshell profile' })).toBeVisible() - }) - - // Same-profile turn-away: an explicit duplicate of the resident profile is - // turned away at the lock gate; the resident's production second-instance - // handler (installed in main(), not a test listener) surfaces it — proven - // by hiding the resident's wizard window and asserting it re-appears. - test('an explicit duplicate of a resident profile quits and the resident surfaces', async () => { - tmpHome = createTempHomeWithRegistry({ profiles: [{ id: 'work', label: 'Work' }] }) - app = await launchApp(tmpHome, ['--profile=work']) - const window = await app.firstWindow() - await window.waitForLoadState('domcontentloaded') - // Named profile, fresh HOME → first-run wizard proves we are resident. - await expect(window.locator('h1:has-text("Welcome to Freshell")')).toBeVisible({ timeout: 30_000 }) - - // Hide the resident's window via the resident's own BrowserWindow API; the - // surfacing claim only means something if production code is what restores - // visibility. NB: read NATIVE visibility via isVisible() in the main - // process — a DOM locator's visibility does not reflect native window show/hide. - await app.evaluate(({ BrowserWindow: BW }) => { - const win = BW.getAllWindows().find((w) => !w.isDestroyed()) - win?.hide() - }) - const isNativeVisible = () => app.evaluate(({ BrowserWindow: BW }) => { - const win = BW.getAllWindows().find((w) => !w.isDestroyed()) - return win ? win.isVisible() : false - }) - expect(await isNativeVisible()).toBe(false) - - const app2 = await launchApp(tmpHome, ['--profile=work']) - await expect.poll(() => app2.process().exitCode, { timeout: 30_000 }).not.toBeNull() - await app2.close().catch(() => {}) - - // Production `second-instance` handler surfaced the resident's window. - // (The resident's wizard was hidden by the test above; only production - // surfacing can flip this back on.) - await expect.poll(isNativeVisible, { timeout: 15_000 }).toBe(true) - expect(app.process().exitCode).toBeNull() - }) -}) -``` - -> **As-built reconciliation (post-execution):** three places above were revised -> during implementation; the committed file -> (`test/e2e-electron/profile-picker.test.ts`) is authoritative: -> -> 1. `test.afterEach` hard-exits the app before `app.close()`: -> `await app.evaluate(() => process.exit(0)).catch(() => {})` — wizard/picker -> phase apps veto `app.quit()` via the `wizardPhase` will-quit guard, so a -> plain close hangs Playwright's worker teardown (120 s). -> 2. Both turn-away specs (the picker-race one at ~line 2830 and the duplicate -> one at ~line 2869) spawn the duplicate as a raw `child_process` of the -> Electron binary via a `spawnDuplicateAndWaitForExit(tmpHome, extraArgs)` -> helper instead of `electron.launch` — `electron.launch`'s `process()` -> handle throws (`Cannot read properties of undefined (reading '_object')`) -> for a short-lived turned-away app; the committed assertions are -> `expect(await spawnDuplicateAndWaitForExit(tmpHome[, '--profile=work'])).toBe(0)`. -> 3. The Electron binary path is resolved portably via the `electron` package -> export (`createRequire(import.meta.url)('electron')` as `ELECTRON_BIN`), -> not the hardcoded Linux `node_modules/electron/dist/electron` path. -> -> These were discovered because the as-drafted specs hung/failed on first run; -> no plan-behavior contract changed. - -- [ ] **Step 2: Run the spec and verify it passes against the integrated implementation** - -Prereqs (fresh builds the picker's prod load path needs): - -Run: `npm run build:electron && npm run build:wizard && npm run build:profile-picker` - -Then: `CI=true npx playwright test --config test/e2e-electron/playwright.electron.config.ts profile-picker` - -(On this headless Linux box Electron needs a display; if the run fails with -`Missing X server`/`$DISPLAY` errors, re-run as -`CI=true xvfb-run -a npx playwright test --config test/e2e-electron/playwright.electron.config.ts profile-picker`.) - -NOTE on red/green honesty: this e2e suite lands LAST, after the implementation -tasks it integrates (Tasks 5-7), so it cannot serve as the red gate for those -tasks — its failure-first evidence lives in the unit tasks of Tasks 2-7 (each -names its red expectation) and in the load-bearing validators' executed -experiments (the `--profile` flag is inert at base_ref, so a checkout at -base_ref necessarily fails the namespacing and picker specs). What Step 2/3 -protect is end-to-end integration: if any spec fails here, the wiring across -Tasks 5-7 is wrong — fix the implementation, not the spec. - -- [ ] **Step 3: Confirm all nine specs pass** - -Run: `CI=true npx playwright test --config test/e2e-electron/playwright.electron.config.ts profile-picker` (with `xvfb-run -a` if needed) - -Expected: 9 passed. - -- [ ] **Step 4: Refactor while green** - -Share no helpers with `electron-app.test.ts` (file-local helpers are the file's existing convention). - -- [ ] **Step 5: Run impacted-test verification** - -The rest of the electron e2e file must stay green (it exercises the default -boot path with no registry — the backward-compat invariant): - -Run: `CI=true npx playwright test --config test/e2e-electron/playwright.electron.config.ts` (with `xvfb-run -a` if needed) - -Expected: PASS (all specs, existing + new). - -- [ ] **Step 6: Commit the task** - -```bash -git add test/e2e-electron/profile-picker.test.ts docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "test(e2e): profile picker and per-profile namespacing e2e coverage" -``` - ---- - -### Task 9: End-user documentation (README) - -The repo's only end-user doc location is `README.md`. Add a new section -(documentation-only task; no test steps — sections render in the GitHub UI; -verify by reading the file after edit). - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Add the section** - -The README has no dedicated desktop/Electron install section; insert the new -`## Desktop profiles (multiple instances)` section immediately before the -existing `## Usage` section (the desktop app is described inside Features and -Usage, so this placement is adjacent to where desktop usage is documented): - -```markdown -## Desktop profiles (multiple instances) - -The desktop app normally runs one instance with one configuration. **Profiles** -let you run multiple independent desktop clients on the same machine at the -same time — for example one connected to your work server and one to a -personal server. - -Each named profile gets its own: - -- settings, window state, and logs (`~/.freshell-/`; the default profile - keeps using `~/.freshell/`) -- Electron storage dir (`…/Freshell-`), so cookies and localStorage never - mix -- single-instance lock: launching the same profile twice focuses the running - window; different profiles run side by side - -### Defining profiles - -Create `~/.freshell/profiles.json`: - -```json -{ - "profiles": [ - { "id": "work", "label": "Work" }, - { "id": "home" } - ] -} -``` - -Rules: `id` is lowercase letters/digits/dashes starting with a letter or digit -(max 32 chars); `default` and `profile-picker` are reserved (the first means -the original un-namespaced environment; the second is the picker launcher's -own storage dir); `label` is optional display text. - -When at least one named profile is defined, launching the app without a -profile shows a picker (the default profile is always listed first; the built- -in default counts, so one named profile in the file already means "more than -one configured"). The picker is a small launcher: whichever profile you pick, -the app relaunches itself pinned to it — you'll see a quick restart, then the -app continues in the chosen profile. Pin a launch to a profile with -`--profile=` or `FRESHELL_PROFILE=`; named ids do not have to be -listed in `profiles.json` — an unlisted id simply starts with a fresh -configuration. - -### Notes and limitations - -- Global hotkey: the first instance to register an accelerator keeps it; - later instances log a warning (`global_hotkey_registration_failed`) and have - no hotkey. Give each profile a distinct hotkey in its own settings. -- App-bound servers: each profile spawns its own server pinned to that - profile's config dir (`FRESHELL_CONFIG_DIR`); choose a distinct port per - profile. -- Daemon services (`freshell.service`, `com.freshell.server`, - "Freshell Server" task) are machine-global single instances — do not use - daemon mode in two profiles at once. -- Silent-install provisioning (`desktop.provision`) applies to the default - profile only. -- Auto-update relaunches the app without `--profile`: after an update, the - picker shows again (pick your profile back). -- Installing/upgrading on Windows terminates all running Freshell instances. -- Relaunching while a profile is running: on Linux/Windows, a launch without a - flag shows the picker again and choosing the running profile focuses its - window; launching with the same `--profile` as a running instance focuses - that window (the new process quits). On macOS, relaunching from Finder or - the Dock while ANY Freshell instance is running just activates the running - instance (the OS enforces this) and never shows the picker — use - `--profile=` flags or `FRESHELL_PROFILE` from a terminal, or Quit before - relaunching to get the picker. Two simultaneous flag-less launches race for - the picker's launcher slot: the first shows the picker; the second quietly - exits and brings the existing picker forward. -- Daemon-service caveat for the Node server: the shipped daemon templates have - always contained an (until now inert) `FRESHELL_CONFIG_DIR` environment - line; starting with this release the Node server honors it. If you - hand-generated a daemon unit from those templates with a non-default config - directory, the value now takes effect at next start (state relocates to that - directory): remove the line from your unit, or move your existing - `~/.freshell` contents into the directory it names. Units using the default - `~/.freshell` path are unaffected; Rust-server installs never read this - variable. -``` - -- [ ] **Step 2: Verify rendering** - -Read the edited README section and confirm the markdown structure matches the -surrounding document (heading levels, fenced code block language tags). - -- [ ] **Step 3: Commit the task** - -```bash -git add README.md docs/plans/2026-08-26-electron-multi-profile.md -git commit -m "docs: desktop profiles for multiple instances" -``` - ---- - -## As-built changes from the post-execution independent review - -All of the following landed after the original 9 tasks, driven by independent -review rounds on the delta: - -- **Server-owning boots never adopt a discovery-found neighbor server.** - `chooseLaunchAction` previously auto-connected to a single scanning-based - localhost candidate before falling through to `start-local`; with one - profile's server resident, a different profile's boot would attach to the - wrong server and config (the token resolution reads the WRONG config dir's - .env, so the window fails auth). Ownership now extends to ANY tenant boot: - named profiles always, the Default profile whenever any named profile is - registered/used/evidenced by a `~/.freshell-` dir, and fail-closed when - the registry is unreadable. The canonical gate is `computeOwnsServer` in - `electron/profile.ts` (unit-tested in `test/unit/electron/profile.test.ts`); - `entry.ts` passes its result through to launch policy and startup. Owning - boots show no discovered neighbors (runStartup skips the discovery probe), - but the `alwaysAskOnLaunch` chooser remains reachable: the chooser renders - its Remote/New-local sections independently of the candidate list, and is - the only place the setting can be flipped off. Regression coverage: - `test/unit/electron/launch-policy.test.ts` ('server ownership') and - `test/unit/electron/startup.test.ts` ('app-bound mode'). - `test/unit/electron/startup.test.ts` ('app-bound mode'). -- **Server-owning boots auto-bump a busy port.** If an owning profile's - configured port is already held (typically by another resident profile), - startup scans forward for a free port, logs `profile_port_reassigned`, - updates the in-memory desktop config, and persists the choice via the - profile-scoped config patch — without this, the unauthenticated - `/api/health` probe would have succeeded against the NEIGHBOR profile's - server and the window would have loaded it with the wrong token. Exhaustion - (no free port in 200) logs `profile_port_scan_exhausted` and shows the - manual chooser instead of spawning into a black hole. Regression coverage: - `test/unit/electron/startup.test.ts` ('app-bound mode'). -- **dotenv ordering kept at module scope via `server/env-load.ts`.** Loading - `.env` inside `server/index.ts` module body would run only after all - imports evaluate and silently disable every `.env`-backed knob - (`LOG_LEVEL`, scrollback caps, the debug-log filename's `PORT`). The load - lives in `server/env-load.ts` and `server/index.ts` imports bootstrap first, - env-load second, THEN everything else (`fresh-agent-extras-router` etc.) — - so `.env` exists and loads before any module-level env reader evaluates. - `server/bootstrap.ts` itself has no dotenv load (keeps the module safe to - import in unit tests without env side effects). -- **`.env` migration for daemon units.** When the anchored `.env` is missing - but `/.env` exists (the systemd/launchd templates lack WorkingDirectory, - so a pre-feature install parks its token at `$HOME/.env`), bootstrap copies - (never moves) it into the anchored location; no AUTH_TOKEN rotation. The - logic is the exported `migrateLegacyEnvFile` (dependency-injected, five - unit cases in `test/unit/server/bootstrap.test.ts`). The `.env` anchor rule - (explicit `FRESHELL_CONFIG_DIR` first, else cwd) is shared by - `resolveEnvAnchorDir` in `server/freshell-home.ts` — `bootstrap.ts`, - `env-load.ts`, and `get-network-host.ts` all consume it. Task 4's consumer - list and staging block also cover `server/bootstrap.ts`, `server/index.ts`, - `server/env-load.ts`, `server/get-network-host.ts`, and - `server/config-store.ts` (per the earlier review note). -- **Environment contract tightened.** App-bound spawn env: `AUTH_TOKEN` and - inherited `FRESHELL_CONFIG_DIR` are always dropped; `FRESHELL_CONFIG_DIR` - is pinned ONLY for named profiles (the default profile keeps its legacy - `FRESHELL_HOME` resolution, so `FRESHELL_HOME`-exported deployments never - silently switch dirs). PTYs also drop `FRESHELL_PROFILE` so nested launches - keep picker/default behavior. -- **Profile-aware client surfaces.** `config.fallback` now carries the - effective `backupPath`; `/api/bootstrap` carries `configDir`; the picker - has explicit loading/missing-preload states; picker loads are awaited with - a logged `profile_picker_load_failed` failure path (no blank zombie - window); `get-profiles` is sender-checked; `initMainProcess` no longer - carries an unreachable duplicate `second-instance` handler; - `{"profiles": []}` is a valid "no named profiles" registry, not invalid. -- **Server-owning boots distinguish "my own resident server" from a - neighbor.** When an owning boot's base port is busy, startup probes the - resident's unauthenticated `/api/health` for `instanceId` and compares - against `/instance-id`; a match (post-crash restart, or the - self-hosted server sharing one state dir) attaches without spawning a - second server, anything else bumps. Regression coverage: - `test/unit/electron/startup.test.ts` ('app-bound mode'). -- **Picker relaunch survives AppImage.** `app.relaunch` gets - `execPath: process.env.APPIMAGE` when set (transient-mount workaround), - via the tested `buildRelaunchOptions` in `electron/profile.ts`. -- **Daemon mode is refused on named profiles** (a daemon's port is the - install-time Default port, never the named profile's). README says daemon - mode is Default-only. -- **Package/build hygiene.** The WS contract artifacts regenerated after the - `backupPath` addition; the Rust `STRIP_ENV` list gained the two profile - vars with a parity test; the port spec docs enumerate them once. - ---- - -## Final verification gate - -After all tasks: run the coordinated full suite once on HEAD. - -Run: `FRESHELL_TEST_SUMMARY='electron-multi-profile final gate' npm run check` - -Expected: PASS (green except for any baseline-ledgered pre-existing failures; -there were none at base_ref). 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/docs/superpowers/plans/2026-09-04-pr699-integration-repair.md b/docs/superpowers/plans/2026-09-04-pr699-integration-repair.md new file mode 100644 index 000000000..37b4c7e15 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-pr699-integration-repair.md @@ -0,0 +1,859 @@ +# PR699 Integration Repair Implementation Plan + +> **For agentic workers:** Execute this plan task by task with a fresh implementer and a specification-plus-quality review after every task. Track progress with the checkbox steps below. Source code becomes authoritative over the draft code examples once execution starts. + +## User Request + +### Requested result +Implement the PR699 integration repair plan with the standard the-usual workflow, fixing the remaining defects and completing its regression, browser, and native-package verification. + +### Explicit constraints +- Continue the existing PR699 work in its dedicated worktree, preserve completed review fixes, and update the existing branch rather than changing main directly. +- Fix CLI Host Stats forwarding, prove terminal-free Host Stats creation/splitting, cover metric calculations and memory-source precedence, and isolate the new runtime test configurations from ambient environment settings. +- Preserve Rust-only backend retirement and the integrated Host Stats, fresh-agent undo/redo, and other main-branch behavior. +- Use red/green/refactor where applicable and meaningful behavior tests; do not skip tests, weaken coverage, or conceal flaky failures. Investigate and fix failures that block the required verification, including the reported executable-fixture CI failure. +- Verify actual affected browser scenarios and all four native package platforms, not just test selection or installer creation. +- Follow repository test coordination and sandbox rules. Use cloud tests for Vitest and browser coverage, with the required supplemental local sandbox checks; do not silently fall back from a failing cloud backend. +- Keep commits focused, preserve unrelated agents' work, use the specified GitHub account, and leave a clean committed and pushed feature branch. +- Do not merge the PR, deploy assets, restart production, or change the existing restart-survival guarantees. + +### Accepted tradeoffs and residuals +- Missing metric test coverage is not itself evidence of incorrect production calculations; add tests without inventing production changes. +- No new macOS metric collector or restoration of the retired Node backend is required. + +**Goal:** Finish PR699's Rust-only runtime retirement while preserving main's Host Stats and fresh-agent undo/redo behavior, with executable regression and native-package evidence. + +**Architecture:** Keep capability metadata shared by the standalone CLI and MCP, and forward supported options into the existing Rust REST handlers. Exercise metric calculations through the Rust collector's existing fixture roots and explicit sample timestamps; do not restore Node backend code or introduce a second collector. Keep new collector tests in a separate child module rather than expanding the large implementation file. + +**Tech Stack:** TypeScript/Node ESM CLI and MCP, Rust/Tokio/Axum, React, Vitest, Playwright, Electron, GitHub Actions. + +## Global Constraints + +Follow the current repository instructions for worktree isolation, meaningful tests, coordinated broad checks, sandboxed process tests, account-qualified GitHub access, and production safety. Preserve existing behavior and user changes. This repair run's immutable review base is `ff8ee3f6e9c1ac71844f3e783c531cc2d04e3275`; its run record is `/home/dan/code/freshell/.worktrees/.the-usual-logs/pr699-integration-repair/run-state.md`. Keep this adopted plan at its existing path. Independent plan review precedes implementation; task review and a final full-delta review follow implementation. Pushing to the existing PR is authorized by this plan; merging and deployment are not. + +--- + +## File map + +| Path | Responsibility / planned change | +| --- | --- | +| `tools/freshell-cli/index.ts` | Forward `hostStats` in both creation request bodies. | +| `test/unit/cli/retained-flags.test.ts` | Execute the real source CLI and inspect its HTTP requests for six boolean cases. | +| `crates/freshell-freshagent/src/terminal_tabs.rs` | Strengthen the existing Host Stats creation test with registry and stored-pane assertions. | +| `test/e2e-browser/specs/cli-rust.spec.ts` | Exercise compiled CLI creation and splitting through a real Rust server and browser without creating terminals. | +| `crates/freshell-server/src/host_stats.rs` | Attach the new test-only child module; no collector behavior change is currently justified. | +| `crates/freshell-server/src/host_stats_collection_tests.rs` | New deterministic rate and memory-precedence regression tests. | +| `config/vitest/vitest.runtime.config.ts` and `config/vitest/vitest.electron-runtime.config.ts` | Extend main's ambient environment isolation to PR699's new runtime lanes. | +| `test/unit/config/sanitize-test-env.test.ts` and `test/unit/config/fixtures/sanitize-env-child.ts` | Execute both new configs before a real child fetch; verify inherited environment and stderr. | +| `docs/superpowers/plans/2026-09-04-pr699-integration-repair.md` | Track execution and append commit-specific verification receipts. | +| `AGENTS.md` | Link this agent-facing plan; already done when the plan is committed. | + +Read-only dependencies: `tools/node-client-runtime/action-capabilities.ts`, `tools/freshell-mcp/freshell-tool.ts`, `crates/freshell-platform/src/host_stats_readers.rs`, `crates/freshell-freshagent/src/pane_ops_tests.rs`, `test/e2e-browser/helpers/test-harness.ts`, `scripts/sandbox-test.sh`, `docs/development/test-sandbox.md`, and `.github/workflows/electron-build.yml`. + +## Starting state and scope + +This is a follow-up to the detailed review, not a request to redo completed fixes. Work in the existing dedicated worktree: + +~~~text +/home/dan/code/freshell/.worktrees/retire-node-server-v2 +branch: the-usual/retire-node-server-v2 +PR: https://github.com/danshapiro/freshell/pull/699 +~~~ + +Main at `db8e09cb67e08a1028ab50b71b99b160a2e7f35f` was integrated in merge commit `18ed1e414`. Its first parent is `b6b7152dd`. All 27 conflicts were resolved, preserving Rust-only retirement plus Host Stats and undo/redo. Do not recreate this merge or restore deleted Node code. + +Completed review repairs: + +| Commit | Repair already implemented | +| --- | --- | +| `925cfc655` | Rust-only build/test routing, client build stamping, and Rust runtime preparation. | +| `eab2efc6d` | Auth bootstrap with actual dotenv semantics and portable npm launching. | +| `3dd301a8a` | Retained CLI flags/aliases and supported agent resumes. | +| `d7db701c0` | Desktop child ownership, log preservation, and bounded shutdown flushing. | +| `918934dc4` | Actual native packaged-runtime verification and platform-specific artifact paths. | +| `0329bccc0` | Windows MCP through WSL, executable/argument conversion, and native Node launching. | +| `b6b7152dd` | Migration performance measured independently of unrelated CPU scheduling. | +| `18ed1e414` | Main integration, combined protocol v8 (33 inbound/55 outbound messages), retained rollback/provider tests, Rust-only browser fixtures, and MCP Host Stats support. | +| `bd1b6c32c` | Second main integration through PR703, retaining test-flakiness fixes and resolving six additional conflicts without restoring retired Node configs. | + +Confirmed remaining defect: shared capabilities accept `--hostStats`, but standalone CLI `new-tab` and `split-pane` omit it from their explicit POST bodies. The request can therefore create a terminal instead of the requested Host Stats pane. + +While writing this plan, main advanced to PR703's test-flakiness fixes at `5b3851322e0ddc60d6c6c10d9b05a27c490ada2e`. Integration commit `bd1b6c32c` preserves those Rust test changes, their classifier, and environment cleanup without restoring the four retired Node test configs. Task 5 covers a newly identified gap: the two runtime configs added by PR699 also need the environment-cleanup prelude. No Task 1–7 implementation has been executed as part of writing this plan. + +Coverage gaps, not demonstrated production bugs: the Rust collector lacks the removed Node suite's exact nonzero rate assertions and memory-source precedence cases. An existing Rust REST Host Stats new-tab test already exists; strengthen it rather than claiming the endpoint is untested. + +Integration evidence already obtained at `18ed1e414`: client/tools typechecks, Cargo workspace check, Rust formatting, protocol 45 tests, port-contract 45 tests, runtime-boundary/distribution 58 tests, MCP/Composer/View 324 tests, and retained sidecar/provider/protocol tests. Browser selection found all 13 incoming rollback/Host Stats cases, and four selection-helper tests passed. **Selection is not browser execution.** Earlier full-suite/package/browser results predate the merge and are not a final merged-branch receipt. + +The second integration at `bd1b6c32c` passed 82 focused tests (sanitizer 5, classifier 19, runtime boundary 36, distribution 22), all four browser-selection helper tests, both TypeScript checks, and `cargo check --workspace --all-targets --locked`. Independent static review found no additional Rust/classifier integration defect. Logs: `/tmp/freshell-pr699-pr703-typecheck.log`, `/tmp/freshell-pr699-pr703-cargo-check.log`, and `/tmp/freshell-pr699-pr703-selection.log`. Full post-repair verification remains outstanding. + +Non-goals: new macOS metric collectors, restoring the retired backend, deleting historical fixture corpora, provider features unrelated to this review, creating another PR, merging PR699, deploying client assets, or restarting production. Existing Rust behavior reports unavailable sections when platform sources are absent; do not reintroduce Node's macOS fallback as an incidental test migration. + +## Current-main integration checkpoints + +At execution setup, `origin/main` is `1463021212246c631ab943adf0dfa70d5a3d22e2`. Integrate that pinned commit once before the original seven tasks; do not chase later main changes unless they prevent PR integration. Preserve its terminal-interest delivery, serialized fresh-agent message queue, provider controls, and sidebar status sorting. The read-only integration map is `/home/dan/code/freshell/.worktrees/.the-usual-logs/pr699-integration-repair/reports/plan-main-drift.md`. + +### Task A: Integrate current main without restoring retired behavior + +- [x] Merge the pinned main commit in this worktree and resolve the 21 mapped conflicts by behavior, not whole-file replacement. Keep retired Node implementations and their implementation-specific tests deleted. Keep incoming standalone Claude sidecar tests at `test/unit/claude-sidecar/`; repair their four relative crate imports. +- [x] Preserve both the unavailable shell-command notice and main's expandable queue controls. Preserve the complete one-at-a-time outgoing-turn lifecycle while adapting the composer callback to plain text; do not restore attachments or `/api/fresh-agent/exec`. +- [x] Keep negotiated `terminalInterestV1` and `terminal.interest`. The retained combined inventory is 34 inbound / 55 outbound / 89 total at protocol v8. Regenerate contracts with the supported generator; do not hand-maintain generated schemas. +- [x] Keep incoming provider/browser scenarios using PR699's owned Rust fixture API. Change the new sidebar spec from the removed `TestServerInfo` to `E2eServerInfo`. Remove only newly reintroduced unsupported attachment claims from README/mock documentation. +- [x] Run typechecks, protocol/port tests, retained sidecar tests, FreshAgent Composer/View/control tests, terminal-interest tests, and sidebar tests. Preserve failing receipts; correct merge regressions with targeted behavioral coverage. Commit the integration checkpoint and obtain its independent task review. + +Task A receipt: integration commit `c2fba7bb904c5bafac6dd355c9950406588e955c`; independent requirements PASS and code-quality APPROVED, no findings. Complete evidence and the disclosed host-test safety deviation are recorded in `/home/dan/code/freshell/.git/worktrees/retire-node-server-v2/usual-sdd/pr699-integration-repair/task-001-review.md`. The valid sandbox sidecar run passed all 41 tests; broader execution remains pending. + +### Task B: Preserve installed Claude runtime and model discovery + +**Files:** `scripts/prepare-electron-runtime.ts`, its Electron unit tests, `crates/freshell-freshagent/src/model_capabilities.rs` and the existing Claude sidecar-entry resolver, and `test/integration/electron/checkout-free-runtime.test.ts`. + +- [ ] Add failing behavior regressions that stage and execute the relocated sidecar and request live Claude model discovery from a copied runtime using the existing fake-SDK seam. The fake SDK must implement `supportedModels()`; require its distinctive model data, not a successful fallback response. +- [ ] Include new `session-settings.mjs` and `model-catalog.mjs` in the existing explicit staged-file list and required-file contract where applicable. +- [ ] Resolve the catalog helper beside the configured `FRESHELL_CLAUDE_SIDECAR` entry, with the existing source fallback. Reuse the established path resolver rather than introducing another setting. Preserve `FRESHELL_CLAUDE_NODE` and process lifetime behavior. +- [ ] Verify focused staging and Rust resolver/probe coverage, then actual checkout-free runtime execution inside the sandbox. Refactor duplication if needed, commit, and obtain the task review. Require this runtime/catalog behavior in all four native receipts in Task 7. + +### Task C: Fix the shared executable-fixture race + +**Files:** `crates/freshell-ws/tests/common/mod.rs` and a focused integration regression importing that real shared helper. + +- [ ] Add a deterministic failing regression calling `common::sleeper_cli_spec` twice for the same provider and proving distinct usable executable paths. Run executable/process tests in the disposable sandbox. +- [ ] Allocate a unique path per call, retaining existing CLI arguments and permissions. Do not serialize production behavior or conceal the race with retries. Preserve the separately fixed private cross-kind fixture. +- [ ] Run the regression, `pane_ledger_triggers` explicitly with four test threads, and `cross_kind_liveness`; inspect cleanup and keep temporary data isolated. Refactor, commit, and obtain the task review. + +These are bounded prerequisites for preserving integrated behavior and completing the original verification request, not new provider features. The execution order is A, B, C, then 1–7: ten tasks total. + +## Execution rules and dependencies + +- [ ] Read `AGENTS.md` and `docs/development/test-sandbox.md` before execution. Confirm the current worktree is clean and belongs to PR699; do not touch the main checkout's unrelated edits. +- [ ] Inspect `npm run test:status` before broad checks; wait for any foreign holder. Never kill another agent's test process. +- [x] Preserve the selected test backend. Dan selected cloud tests on 2026-09-05, including the required supplemental local sandbox checks offered with that choice. Both preferences are persisted in `/home/dan/.bashrc`. Explicitly supply them to ongoing agent processes that predate that change. Never silently substitute local for a failing configured cloud backend. +- [ ] Run process-kill, config-corruption, restart-storm, and owned-server browser suites only inside the disposable sandbox or a disposable CI runner. Never point tests at port 3001 or real user data. +- [ ] Use explicit GitHub identity on every call, for example `GH_TOKEN="$(gh auth token --user danshapiro)" gh pr view 699 --repo danshapiro/freshell`. Preserve the existing noreply git identity. +- [ ] Use red/green/refactor for Task 1's confirmed defect. Tasks 2–4 add tests of currently implemented behavior and may immediately pass; do not deliberately damage production code to manufacture a red phase. If a new assertion fails, preserve the failure and trace the calculation before changing implementation. +- [ ] Do not write prose/config-content assertions, skip tests, or weaken assertions to obtain green results. +- [ ] Use sequential fresh implementers with exclusive file ownership and a fresh specification-plus-quality review after each task, as required by the standard workflow. Task 2 follows Task 1; Task 4 follows Task 3. Run broad verification only on committed inputs and at the workflow's ten-non-merge-commit checkpoints. + +## Task 1: Forward Host Stats flags in the standalone CLI + +**Files:** modify `test/unit/cli/retained-flags.test.ts` and `tools/freshell-cli/index.ts`. + +- [ ] Add these six executable cases to `retained-flags.test.ts`, reusing its existing `invoke` helper. That helper starts the real source CLI with a temporary HTTP server; this tests behavior, not help text. + +~~~ts +it.each([ + ['new-tab', '--hostStats', true], + ['new-tab', '--hostStats=true', true], + ['new-tab', '--hostStats=false', false], + ['split-pane', '--hostStats', true], + ['split-pane', '--hostStats=true', true], + ['split-pane', '--hostStats=false', false], +] as const)('%s forwards Host Stats option %s', async (action, flag, enabled) => { + const args = action === 'new-tab' + ? [action, flag] + : [action, '--target', 'p1', flag] + const result = await invoke(args) + + expect(result.code).toBe(0) + expect(result.stderr).toBe('') + const request = result.requests.at(-1) + expect(request).toBeDefined() + expect(request!.url).toBe( + action === 'new-tab' ? '/api/tabs' : '/api/panes/p1/split', + ) + expect((request!.body as Record).hostStats) + .toBe(enabled ? true : undefined) +}) +~~~ + +- [ ] Capture the failing test output. + +~~~bash +npm run test:vitest -- run test/unit/cli/retained-flags.test.ts -t 'Host Stats' +~~~ + +Expected: six selected cases execute; the four enabled cases fail because `hostStats` is absent. The two explicit-false cases should pass. + +- [ ] In `tools/freshell-cli/index.ts`, add this property spread to **both** the POST `/api/tabs` body in `new-tab` and the POST `/api/panes/:id/split` body in `split-pane`: + +~~~ts +...(isTruthy(getFlag(flags, 'hostStats')) ? { hostStats: true } : {}), +~~~ + +Use the already defined `isTruthy` and `getFlag` functions. No new parser, capability entry, HTTP endpoint, default mode change, or helper abstraction is needed. Keep every existing body field and resume alias intact. + +- [ ] Run the complete affected CLI/MCP tests and tools typecheck. + +~~~bash +npm run test:vitest -- run test/unit/cli/retained-flags.test.ts test/unit/cli/action-capabilities.test.ts test/unit/mcp/freshell-tool.test.ts +npm run typecheck:tools +~~~ + +Expected: all selected tests pass, including all six new cases; no TypeScript errors. + +- [ ] Refactor review: confirm both request builders use the same boolean conversion and that false omits the option. Two one-line spreads are preferable to a generic request-builder refactor for this change. If no cleanup improves clarity, record that decision without unrelated edits. + +- [ ] Commit the fix and its regression tests. + +~~~bash +git add tools/freshell-cli/index.ts test/unit/cli/retained-flags.test.ts +git commit -m "fix: forward Host Stats options from standalone CLI" +~~~ + +## Task 2: Prove Host Stats creation never allocates a terminal + +**Files:** modify `crates/freshell-freshagent/src/terminal_tabs.rs` and `test/e2e-browser/specs/cli-rust.spec.ts`. Existing split coverage in `crates/freshell-freshagent/src/pane_ops_tests.rs` stays intact. + +- [ ] Replace only the existing `create_host_stats_tab_attaches_host_stats_pane_content_and_no_terminal` test with the following body and attributes: + +~~~rust +#[tokio::test] +async fn create_host_stats_tab_attaches_host_stats_pane_content_and_no_terminal() { + let state = state_with_registry(); + let registry = state.terminal_registry.clone().unwrap(); + assert!(registry.inventory().is_empty()); + let mut rx = state.broadcast_tx.subscribe(); + + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({ "hostStats": true }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(body["data"]["tabId"].as_str().is_some()); + let pane_id = body["data"]["paneId"].as_str().expect("created pane id"); + assert!(body["data"].get("terminalId").is_none()); + assert!(registry.inventory().is_empty()); + + let pane = state.layout.get_pane_snapshot(pane_id).expect("stored pane"); + assert_eq!(pane.kind.as_deref(), Some("host-stats")); + assert!(pane.terminal_id.is_none()); + + let frame = rx.recv().await.expect("ui.command frame broadcast"); + let msg: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(msg["command"], json!("tab.create")); + assert_eq!(msg["payload"]["paneContent"]["kind"], json!("host-stats")); +} +~~~ + +- [ ] Run the test and the existing split Host Stats regression inside the sandbox. + +~~~bash +scripts/sandbox-test.sh "cargo test -p freshell-freshagent --lib --locked host_stats" +~~~ + +Expected: the creation and split tests execute and pass, with no terminal added by either Host Stats operation. These are coverage additions, not expected production failures. + +- [ ] Add this import to `cli-rust.spec.ts`: + +~~~ts +import { TestHarness } from '../helpers/test-harness.js' +~~~ + +- [ ] Add the following test inside the existing `standalone CLI -- Rust server replacement` describe block. Reuse the file's actual `runCliJson` and `ActionResult` helpers. + +~~~ts +test('creates and splits Host Stats panes without allocating terminals', async ({ page }) => { + const server = new RustServer({ verbose: false }) + const info = await server.start() + + try { + ensureMcpServerBuilt(REPO_ROOT) + await page.goto(info.baseUrl + '/?token=' + info.token + '&e2e=1') + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + + const inventory = () => runCliJson( + info.baseUrl, info.token, ['list-terminals'], + ) + expect(await inventory()).toEqual([]) + + const created = await runCliJson>( + info.baseUrl, info.token, + ['new-tab', '--hostStats', '--name', 'CLI Host Stats'], + ) + expect(created.status).toBe('ok') + expect(created.data.terminalId).toBeUndefined() + const regions = page.getByRole('region', { name: 'Host stats' }) + await expect(regions).toHaveCount(1) + await expect(regions.first()).toBeVisible() + expect(await inventory()).toEqual([]) + + const split = await runCliJson>( + info.baseUrl, info.token, + ['split-pane', '--target', created.data.paneId, '--hostStats=true'], + ) + expect(split.status).toBe('ok') + expect(split.data.paneId).not.toBe(created.data.paneId) + expect(split.data.terminalId).toBeUndefined() + await expect(regions).toHaveCount(2) + await expect(regions.nth(1)).toBeVisible() + expect(await inventory()).toEqual([]) + } finally { + await server.stop() + } +}) +~~~ + +- [ ] Execute the focused browser test using the configured backend, in a disposable runtime. For an approved local backend: + +~~~bash +scripts/sandbox-test.sh "FRESHELL_E2E_BACKEND=local npm run test:e2e -- --project=chromium test/e2e-browser/specs/cli-rust.spec.ts --grep 'Host Stats'" +~~~ + +Expected: exactly one real browser test passes; it creates two visible Host Stats panes and observes an empty terminal inventory before and after. A `--list` result or a skipped test does not count. + +- [ ] Refactor review: keep the ownership/cleanup in the existing test helper pattern and preserve the original broad CLI acceptance test. Do not replace the browser assertions with mocked REST success. + +- [ ] Format and commit. + +~~~bash +cargo fmt --all +git add crates/freshell-freshagent/src/terminal_tabs.rs test/e2e-browser/specs/cli-rust.spec.ts +git commit -m "test: cover terminal-free Host Stats CLI orchestration" +~~~ + +## Task 3: Restore deterministic nonzero collector-rate coverage + +**Files:** create `crates/freshell-server/src/host_stats_collection_tests.rs`; modify `crates/freshell-server/src/host_stats.rs`. + +The existing `CollectorCtx` methods accept explicit millisecond sample timestamps and read fixture roots. Use that seam without sleeping or activating background sampling. + +- [ ] Add the following complete child-module declaration at the end of `host_stats.rs`: + +~~~rust +#[cfg(test)] +#[path = "host_stats_collection_tests.rs"] +mod collection_tests; +~~~ + +- [ ] Create `host_stats_collection_tests.rs` with these helpers and four tests: + +~~~rust +use super::*; +use std::path::Path; + +fn write_fixture(root: &Path, relative: &str, text: &str) { + let file = root.join(relative); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(file, text).unwrap(); +} + +fn fixture_collector(root: &Path) -> HostStatsCollectorService { + HostStatsCollectorService::new( + HostStatsCollectorConfig { + proc_root: root.join("proc"), + sys_root: root.join("sys"), + ..Default::default() + }, + freshell_terminal::TerminalRegistry::new(), + HostStatsInterestRegistry::default(), + Instant::now(), + ) +} + +#[test] +fn cpu_rates_use_deltas_for_aggregate_steal_and_each_core() { + let root = tempfile::tempdir().unwrap(); + let proc_root = root.path().join("proc"); + write_fixture( + &proc_root, + "stat", + "cpu 100 0 0 900 0 0 0 0\n\ + cpu0 25 0 0 225 0 0 0 0\n\ + cpu1 25 0 0 225 0 0 0 0\n\ + cpu2 25 0 0 225 0 0 0 0\n\ + cpu3 25 0 0 225 0 0 0 0\n", + ); + let collector = fixture_collector(root.path()); + let first = collector.ctx.read_cpu_section(1_000); + assert!(first.available); + assert_eq!(first.usage_pct, 0.0); + assert_eq!(first.steal_pct, Some(0.0)); + assert_eq!(first.per_core_pct, vec![0.0; 4]); + + write_fixture( + &proc_root, + "stat", + "cpu 280 0 0 1700 0 0 0 20\n\ + cpu0 100 0 0 400 0 0 0 0\n\ + cpu1 100 0 0 400 0 0 0 0\n\ + cpu2 100 0 0 400 0 0 0 0\n\ + cpu3 100 0 0 400 0 0 0 0\n", + ); + let next = collector.ctx.read_cpu_section(3_000); + assert!(next.available); + assert_eq!(next.usage_pct, 20.0); + assert_eq!(next.steal_pct, Some(2.0)); + assert_eq!(next.per_core_pct, vec![30.0; 4]); +} + +#[test] +fn paging_rates_convert_page_deltas_over_elapsed_seconds() { + let root = tempfile::tempdir().unwrap(); + let proc_root = root.path().join("proc"); + write_fixture( + &proc_root, + "vmstat", + "pswpin 100\npswpout 40\npgmajfault 50\noom_kill 2\n", + ); + let collector = fixture_collector(root.path()); + let first = collector.ctx.read_paging_section(1_000); + assert!(first.available); + assert_eq!(first.swap_in_kbps, 0.0); + assert_eq!(first.swap_out_kbps, 0.0); + assert_eq!(first.maj_faults_per_sec, 0.0); + assert_eq!(first.oom_kills_delta, 0); + assert_eq!(first.oom_kills_total, 2); + + write_fixture( + &proc_root, + "vmstat", + "pswpin 108\npswpout 44\npgmajfault 70\noom_kill 5\n", + ); + let next = collector.ctx.read_paging_section(3_000); + assert!(next.available); + assert_eq!(next.swap_in_kbps, 16.0); + assert_eq!(next.swap_out_kbps, 8.0); + assert_eq!(next.maj_faults_per_sec, 10.0); + assert_eq!(next.oom_kills_delta, 3); + assert_eq!(next.oom_kills_total, 5); +} + +#[test] +fn disk_rates_convert_sectors_and_compute_utilization_and_await() { + let root = tempfile::tempdir().unwrap(); + let proc_root = root.path().join("proc"); + write_fixture( + &proc_root, + "diskstats", + "8 0 sda 1000 0 100000 4000 2000 0 400000 8000 0 500 0\n", + ); + let collector = fixture_collector(root.path()); + let first = collector.ctx.read_disk_io_section(5_000); + assert!(first.available); + assert_eq!(first.read_bps, 0.0); + assert_eq!(first.write_bps, 0.0); + assert_eq!(first.util_pct, None); + assert_eq!(first.weighted_await_ms, None); + + write_fixture( + &proc_root, + "diskstats", + "8 0 sda 1100 0 151200 6000 2400 0 502400 10000 0 1500 0\n", + ); + let next = collector.ctx.read_disk_io_section(10_000); + assert!(next.available); + assert_eq!(next.read_bps, 5_242_880.0); + assert_eq!(next.write_bps, 10_485_760.0); + assert_eq!(next.util_pct, Some(20.0)); + assert_eq!(next.weighted_await_ms, Some(8.0)); +} + +#[test] +fn network_rates_keep_error_and_drop_totals_and_deltas_distinct() { + let root = tempfile::tempdir().unwrap(); + let proc_root = root.path().join("proc"); + write_fixture( + &proc_root, + "net/dev", + "eth0: 1000000 0 3 2 0 0 0 0 500000 0 1 4 0 0 0 0\n", + ); + let collector = fixture_collector(root.path()); + let first = collector.ctx.read_network_section(5_000); + assert!(first.available); + assert_eq!(first.rx_bps, 0.0); + assert_eq!(first.tx_bps, 0.0); + assert_eq!(first.rx_errors_delta, 0); + assert_eq!(first.tx_errors_delta, 0); + assert_eq!(first.rx_dropped_delta, 0); + assert_eq!(first.tx_dropped_delta, 0); + + write_fixture( + &proc_root, + "net/dev", + "eth0: 1500000 0 5 3 0 0 0 0 600000 0 3 5 0 0 0 0\n", + ); + let next = collector.ctx.read_network_section(10_000); + assert!(next.available); + assert_eq!(next.rx_bps, 100_000.0); + assert_eq!(next.tx_bps, 20_000.0); + assert_eq!(next.rx_errors_total, 5); + assert_eq!(next.tx_errors_total, 3); + assert_eq!(next.rx_dropped_total, 3); + assert_eq!(next.tx_dropped_total, 5); + assert_eq!(next.rx_errors_delta, 2); + assert_eq!(next.tx_errors_delta, 2); + assert_eq!(next.rx_dropped_delta, 1); + assert_eq!(next.tx_dropped_delta, 1); +} +~~~ + +- [ ] Run the new tests. + +~~~bash +cargo test -p freshell-server --bin freshell-server --locked host_stats::collection_tests +~~~ + +Expected: exactly four tests execute and pass. This narrow module creates fixture files only and does not start a server or background collector. + +- [ ] Refactor review: keep fixture writing and collector construction shared; keep every metric's input and exact expected result beside its assertion. Do not introduce sleeps, fake clocks in production, or platform-specific collector mechanisms. + +- [ ] Format and commit. + +~~~bash +cargo fmt --all +git add crates/freshell-server/src/host_stats.rs crates/freshell-server/src/host_stats_collection_tests.rs +git commit -m "test: cover nonzero Rust Host Stats metric rates" +~~~ + +## Task 4: Preserve memory-source precedence and degraded behavior + +**File:** append to `crates/freshell-server/src/host_stats_collection_tests.rs`. Depends on Task 3's existing `write_fixture` and `fixture_collector` helpers. + +- [ ] Add the following complete tests. Every case uses its own temporary proc/sys roots; nothing reads real cgroup state as the memory source. + +~~~rust +fn write_host_memory(root: &Path) { + write_fixture( + &root.join("proc"), + "meminfo", + "MemTotal: 64000000 kB\n\ + MemAvailable: 32000000 kB\n\ + SwapTotal: 8000000 kB\n\ + SwapFree: 8000000 kB\n", + ); +} + +#[test] +fn finite_cgroup_memory_wins_without_mixing_host_totals() { + let root = tempfile::tempdir().unwrap(); + write_host_memory(root.path()); + write_fixture(&root.path().join("proc"), "self/cgroup", "0::/freshell-test\n"); + let cgroup = root.path().join("sys/fs/cgroup/freshell-test"); + write_fixture(&cgroup, "memory.max", "8000000000\n"); + write_fixture(&cgroup, "memory.current", "500000000\n"); + + let memory = fixture_collector(root.path()).ctx.read_memory_section(); + assert!(memory.available); + assert_eq!(memory.source, "cgroup"); + assert_eq!(memory.total_bytes, 8_000_000_000); + assert_eq!(memory.used_bytes, 500_000_000); + assert_eq!(memory.available_bytes, 7_500_000_000); + assert_eq!(memory.cgroup_limit_bytes, Some(8_000_000_000)); + assert_eq!(memory.swap_total_bytes, Some(8_000_000 * 1024)); + assert_eq!(memory.swap_used_bytes, Some(0)); +} + +#[test] +fn unlimited_cgroup_memory_uses_host_used_and_available_values() { + let root = tempfile::tempdir().unwrap(); + write_host_memory(root.path()); + write_fixture(&root.path().join("proc"), "self/cgroup", "0::/freshell-test\n"); + let cgroup = root.path().join("sys/fs/cgroup/freshell-test"); + write_fixture(&cgroup, "memory.max", "max\n"); + write_fixture(&cgroup, "memory.current", "500000000\n"); + + let memory = fixture_collector(root.path()).ctx.read_memory_section(); + assert!(memory.available); + assert_eq!(memory.source, "host"); + assert_eq!(memory.total_bytes, 64_000_000 * 1024); + assert_eq!(memory.used_bytes, 32_000_000 * 1024); + assert_eq!(memory.available_bytes, 32_000_000 * 1024); + assert_eq!(memory.cgroup_limit_bytes, None); + assert_eq!(memory.swap_total_bytes, Some(8_000_000 * 1024)); + assert_eq!(memory.swap_used_bytes, Some(0)); +} + +#[test] +fn absent_cgroup_memory_uses_host_meminfo() { + let root = tempfile::tempdir().unwrap(); + write_host_memory(root.path()); + + let memory = fixture_collector(root.path()).ctx.read_memory_section(); + assert!(memory.available); + assert_eq!(memory.source, "host"); + assert_eq!(memory.total_bytes, 64_000_000 * 1024); + assert_eq!(memory.used_bytes, 32_000_000 * 1024); + assert_eq!(memory.available_bytes, 32_000_000 * 1024); + assert_eq!(memory.cgroup_limit_bytes, None); + assert_eq!(memory.swap_total_bytes, Some(8_000_000 * 1024)); + assert_eq!(memory.swap_used_bytes, Some(0)); +} + +#[test] +fn missing_memory_sources_produce_an_unavailable_full_shape() { + let root = tempfile::tempdir().unwrap(); + let memory = fixture_collector(root.path()).ctx.read_memory_section(); + + assert!(!memory.available); + assert_eq!(memory.total_bytes, 0); + assert_eq!(memory.used_bytes, 0); + assert_eq!(memory.available_bytes, 0); + assert_eq!(memory.cgroup_limit_bytes, None); + assert_eq!(memory.swap_total_bytes, None); + assert_eq!(memory.swap_used_bytes, None); +} +~~~ + +- [ ] Run the complete new module and the existing platform readers. + +~~~bash +cargo test -p freshell-server --bin freshell-server --locked host_stats::collection_tests +cargo test -p freshell-platform --lib --locked host_stats_readers +~~~ + +Expected: eight collector tests and the existing selected reader tests execute and pass. If a collector assertion fails, investigate `read_memory_section` and `read_cgroup_memory`; do not substitute host-dependent assertions. + +- [ ] Refactor review: retain separate named tests for finite, unlimited, absent, and unavailable sources. Keep swap assertions host-scoped. No production refactor is required when the established behavior passes. + +- [ ] Format and commit. + +~~~bash +cargo fmt --all +git add crates/freshell-server/src/host_stats_collection_tests.rs +git commit -m "test: preserve Rust Host Stats memory precedence" +~~~ + +## Task 5: Apply main's environment isolation to the two new runtime lanes + +**Files:** modify `config/vitest/vitest.runtime.config.ts`, `config/vitest/vitest.electron-runtime.config.ts`, `test/unit/config/sanitize-test-env.test.ts`, `test/unit/config/fixtures/sanitize-env-child.ts`, and the environment-isolation note in `AGENTS.md`. + +PR703 introduced `sanitize-test-env.ts` while this plan was being written. Its original configs receive the prelude, but PR699's new source-runtime and packaged-runtime configs do not. Importing those configs therefore still passes ambient proxy/bind settings into their child processes. This is a distinct integration gap; keep the main implementation and extend its real-child regression harness. + +- [ ] In `sanitize-env-child.ts`, change its first comment to: + +~~~ts +// Fixture for sanitize-test-env.test.ts. argv[2] = plain, clean, or config. +~~~ + +Add this import beside the existing child-process import: + +~~~ts +import { pathToFileURL } from 'node:url' +~~~ + +Replace only the existing mode-selection block with: + +~~~ts +const mode = process.argv[2] +if (mode === 'clean') { + const { stripAmbientEnvPoisons } = await import('../../../../config/vitest/sanitize-test-env.js') + stripAmbientEnvPoisons(process.env) +} else if (mode === 'config') { + const configPath = process.argv[3] + if (!configPath) throw new Error('config mode requires an absolute config path') + await import(pathToFileURL(configPath).href) +} +~~~ + +Keep the existing inner-child fetch, pinned Node environment flags, and JSON report unchanged. + +- [ ] Add these behavioral cases to `sanitize-test-env.test.ts`. Reuse its existing imports, `POISONED_ENV`, `fixture`, `tsxCli`, and `execFileAsync`. + +~~~ts +it.each([ + 'vitest.runtime.config.ts', + 'vitest.electron-runtime.config.ts', +])('%s sanitizes the environment inherited by child processes', async (configName) => { + const env = { ...process.env, ...POISONED_ENV } + delete env.FRESHELL_RUN_REAL_PROVIDER_CONTRACTS + const configPath = path.resolve(process.cwd(), 'config/vitest', configName) + const { stdout } = await execFileAsync( + process.execPath, + [tsxCli, fixture, 'config', configPath], + { env, maxBuffer: 1024 * 1024 }, + ) + const result = JSON.parse(stdout) as { + innerStderr: string + envReport: Record + } + expect(result.innerStderr).toBe('') + for (const key of AMBIENT_ENV_POISONS) { + expect(result.envReport[key]).toBeUndefined() + } +}) +~~~ + +- [ ] Run the new config-load regressions. + +~~~bash +npm run test:vitest -- run test/unit/config/sanitize-test-env.test.ts +~~~ + +Expected: five existing tests pass; the two new cases fail because the loaded configs do not remove the poisoned environment. The test executes config loading and child inheritance; it does not search config text. + +- [ ] Add this first import to both `vitest.runtime.config.ts` and `vitest.electron-runtime.config.ts`: + +~~~ts +import './sanitize-test-env.js' +~~~ + +Do not duplicate sanitizer logic or alter its real-provider escape hatch. + +- [ ] Replace the temporary environment-isolation note in `AGENTS.md` with this completed description: + +~~~text +- Ambient proxy vars (HTTP(S)_PROXY, either case) and FRESHELL_BIND_HOST are stripped by config/vitest/sanitize-test-env.ts at Vitest config load, including source-runtime and packaged-runtime lanes. The exact FRESHELL_RUN_REAL_PROVIDER_CONTRACTS=1 escape hatch preserves proxy egress but still removes FRESHELL_BIND_HOST. +~~~ + +- [ ] Run the regression and tools typecheck. + +~~~bash +npm run test:vitest -- run test/unit/config/sanitize-test-env.test.ts +npm run typecheck:tools +~~~ + +Expected: all seven tests pass and no type errors. Full source/packaged-runtime execution follows in Tasks 6–7. + +- [ ] Refactor review: keep one shared sanitizer and the existing behavioral fixture. No new environment global, duplicate sanitizer, or source-text test is needed. + +- [ ] Commit the integration repair. + +~~~bash +git add config/vitest/vitest.runtime.config.ts config/vitest/vitest.electron-runtime.config.ts test/unit/config/sanitize-test-env.test.ts test/unit/config/fixtures/sanitize-env-child.ts AGENTS.md +git commit -m "fix: isolate new runtime test lanes from ambient environment" +~~~ + +## Task 6: Verify the repaired merge, including real browser execution + +**Files:** no planned production edits. Update this plan's receipt after verification. Temporary scripts/bundles below are ignored test artifacts, not new product tooling. + +- [ ] Confirm clean committed inputs, inspect the shared gate, and capture the exact tested SHA. + +~~~bash +git status --short +git rev-parse HEAD +npm run test:status +~~~ + +Expected: no uncommitted task changes; no foreign holder before starting a broad run. + +- [ ] Run non-destructive static checks on the worktree. + +~~~bash +npm run typecheck +cargo fmt --all --check +cargo check --workspace --locked +cargo clippy --workspace --all-targets --locked -- -D warnings +npm run lint +npm run contract:generate +git diff --exit-code -- port/contract +~~~ + +Expected: all commands exit zero; contract regeneration produces no diff. Record pre-existing lint warnings separately, never as new failures or as warning-free output. + +- [ ] Prepare the approved backend's committed-source, Git-aware disposable verification route. Implement task-owned ignored orchestration scripts, not permanent product tooling. The architecture was independently validated as feasible; no launcher or test result was validated by that inspection. Decision evidence: `/home/dan/code/freshell/.worktrees/.the-usual-logs/pr699-integration-repair/reports/load-bearing-validator-lb1.md`. + +The host supervisor must acquire the existing host repository coordinator endpoint through its exported endpoint/store APIs, write an honest task-specific holder without a suite key, and retain the reservation until the canonical sandbox invocation terminates and its exact owned container is confirmed gone. Wait for foreign holders. Handle normal exit, test failure, and catchable cancellation without releasing early; do not add crash-recovery guarantees or fabricate standard-suite success. + +Invoke unchanged `scripts/sandbox-test.sh` through a narrow task-local Docker adapter. Select a prepared task-specific immutable image and record a unique container identity; preserve the canonical namespaces, resource limits, ordinary caches, and automatic removal. Reject unexpected image-build calls rather than retagging the shared image. Use the validated same-user Docker access (`sudo -n -u dan -g docker`). Prepare Rust 1.96.0, the workspace's Tauri development libraries, and Chromium inside the disposable environment; cache warming also holds the host reservation. Do not mount user data, host Git internals, the Docker socket, or production environment. + +Keep the existing Git-bundle approach: bundle the exact committed feature HEAD into a task-owned ignored file, clone it into an inner temporary bare repository, create a detached linked worktree there, and connect the canonical dependency caches. This gives real Git build stamps without host Git access. Verify the lockfile-compatible cache and exact inner SHA. Capture stdout/stderr continuously on the host; copy inner coordinator/browser artifacts to a task-owned `/workspace/dist` receipt directory before normal or handled-error disposal, preserving the original failing status. + +Before trusting the launcher for broad tests, exercise its real canonical entrypoint and proportionate behavior checks for contention, exact-image selection, failed commands, cancellation during launch/execution, owned-container reconciliation, and failure-preserving artifact capture. An uncatchably killed supervisor cannot retain a process-owned socket; this is not a new production recovery requirement. + +For an approved local run, execute this command sequence inside the committed-source sandbox with Rust 1.96.0, bounded build jobs, and the selected local backend environment: + +~~~bash +git rev-parse HEAD +npm run check +npm run test:vitest -- run --config config/vitest/vitest.port.config.ts +npm run test:e2e:helpers -- helpers/selection-nonvacuity.test.ts +npm exec -- playwright install chromium +npm run test:e2e -- --project=chromium --workers=2 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 test/e2e-browser/specs/fresh-agent-control-rust.spec.ts test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts test/e2e-browser/specs/host-stats-pane.spec.ts test/e2e-browser/specs/server-build-mismatch-rust.spec.ts test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts test/e2e-browser/specs/freshopencode-model-picker.spec.ts test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/sidebar-status-tier-sort-rust.spec.ts test/e2e-browser/specs/opencode-terminal-restore-rust.spec.ts +~~~ + +Require every applicable scenario to actually execute, including both CLI Host Stats actions, rollback/redo, build mismatch, incoming provider controls/settings/queue behavior, and sidebar sorting. Record actual counts and exclusions; selection or an old fixed count is not evidence. `npm run check` must cover default JavaScript, source-runtime, Rust workspace, and Electron lanes. Keep the explicit concurrent Task C fixture test even if the broad suite uses bounded/serial Rust execution. + +If dependencies in the reused sandbox cache no longer match the committed lockfile, run `scripts/sandbox-test.sh "npm ci --no-audit --no-fund"` before retrying; do not alter the lockfile to fit a stale cache. If container resource limits cause failure, diagnose that limit rather than rerunning destructive tests on the host. + +- [ ] If the chosen backend is cloud instead, run the repo's configured cloud Vitest/browser paths and record actual shard results. Do not run the local recipe as an unannounced fallback: + +~~~bash +FRESHELL_VITEST_BACKEND=cloud npm run test:vitest -- run --config config/vitest/vitest.config.ts +FRESHELL_E2E_BACKEND=cloud npm run test:e2e -- --project=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 test/e2e-browser/specs/fresh-agent-control-rust.spec.ts test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts test/e2e-browser/specs/host-stats-pane.spec.ts +~~~ + +The cloud path does not replace native Cargo/source-runtime/Electron evidence. Use PR CI for those lanes. Check `test/e2e-browser/playwright.cloud.config.ts` for every selected spec: a cloud-skipped case is still uncovered. `server-build-mismatch-rust.spec.ts` specifically needs git-aware local/disposable-CI execution; obtain an explicit supplemental local choice if cloud is selected, then run the git-aware sandbox recipe. Do not claim the browser requirement complete while any affected spec remains unexecuted. + +- [ ] If a new regression fails, preserve command, SHA, log, and failing assertion; use the debugging/TDD workflow to correct the specific cause before rerunning both its focused test and the affected broad lane. Do not weaken coverage, add retries to conceal a race, or silently mark existing failures unrelated. + +- [ ] Append an execution receipt here containing: tested SHA, backend, commands, pass/fail/skip counts, log locations or CI links, documented existing exclusions, and any still-blocked native platform. Commit the receipt only after it accurately reflects observed results. + +## Task 7: Verify native packages and hand off a clean branch + +**Read-only workflow:** `.github/workflows/electron-build.yml`; four native environments: Intel macOS, ARM macOS, Linux, and Windows. Do not trigger the release/publishing workflow. + +- [ ] Push the focused repair commits and receipt to the existing PR branch without force. + +~~~bash +GH_TOKEN="$(gh auth token --user danshapiro)" git -c credential.helper= -c 'credential.helper=!gh auth git-credential' push origin HEAD:the-usual/retire-node-server-v2 +~~~ + +- [ ] Read PR state and CI for the exact pushed head. + +~~~bash +GH_TOKEN="$(gh auth token --user danshapiro)" gh pr view 699 --repo danshapiro/freshell --json headRefOid,mergeable,statusCheckRollup,url +GH_TOKEN="$(gh auth token --user danshapiro)" gh run list --repo danshapiro/freshell --branch the-usual/retire-node-server-v2 --limit 20 +GH_TOKEN="$(gh auth token --user danshapiro)" gh pr checks 699 --repo danshapiro/freshell +~~~ + +Expected: PR is mergeable against current main; required checks succeed. A new main conflict requires a separate reviewed integration checkpoint, not force-pushing main or reusing stale check results. + +- [ ] Inspect each Electron Build job's logs. Require all of: native Rust build, Electron unit tests, installer build, `verify:electron-artifact`, and `test:electron:runtime` with the job's actual packaged resources path: + +| Runner | Runtime acceptance path | +| --- | --- | +| Intel macOS | `release/mac/Freshell.app/Contents/Resources` | +| ARM macOS | `release/mac-arm64/Freshell.app/Contents/Resources` | +| Linux | `release/linux-unpacked/resources` | +| Windows | `release/win-unpacked/resources` | + +A green installer build alone is insufficient. Acceptance must launch the packaged runtime without depending on checkout source, and Windows must use a native Windows build, not Linux binaries copied into a Windows archive. + +- [ ] Confirm the remaining PR jobs cover client typecheck/default Vitest, Rust formatting/clippy/workspace/source runtime, browser selection, and protocol drift. Browser selection alone is not a substitute for Task 6's actual scenarios. + +- [ ] Append exact native run URLs/results to the receipt. If a platform fails, investigate its real log and leave the plan incomplete until the failure is resolved or a concrete external blocker is reported. Do not label local Linux success as macOS/Windows verification. + +- [ ] Perform the standard workflow's independent full-delta review against immutable repair base `ff8ee3f6e9c1ac71844f3e783c531cc2d04e3275`. Verify no Node listener was restored, rollback handling remains present, Host Stats works through both CLI and MCP, incoming main behavior remains supported in native packages, and no unrelated work was changed. Clear or explicitly disposition every finding before claiming completion. + +- [ ] Commit the final receipt, push it, and confirm the worktree is clean. Report the implemented findings, test evidence, remaining caveats, and PR link. Stop before merging PR699 or deploying anything; this plan does not grant that authority. + +## Completion criteria + +- [ ] Pinned main behavior is preserved, including terminal interest, queued sends, provider controls, and sidebar sorting, without restoring retired features. +- [ ] Copied native runtimes load the new Claude helpers and return actual live model data without the build checkout. +- [ ] The real shared sleeper helper uses distinct executable paths and concurrent ledger tests pass. +- [ ] Both CLI creation commands forward enabled Host Stats options and treat explicit false correctly. +- [ ] Compiled CLI + Rust + browser show two Host Stats panes with no terminal allocation. +- [ ] Rust tests protect exact nonzero CPU, paging, disk, and network calculations. +- [ ] Rust tests protect finite/unlimited/absent memory precedence and unavailable full-shape output. +- [ ] Both new runtime test configurations apply the shared environment sanitizer, proved through actual config loading and child execution. +- [ ] Fresh-agent rollback, protocol v8, and Rust-only runtime boundaries remain passing after the repairs. +- [ ] Full merged-branch regression and actual affected browser scenarios have current receipts. +- [ ] All four native packaged-runtime jobs have current passing receipts. +- [ ] Branch is committed/pushed and clean; main, production, and unrelated agents' files remain untouched. + +## Execution receipt + +### Periodic full gate after Task 5 + +- Tested SHA: `98920e3d529adf8f987d8586d4b650eab879b87d` (clean detached Git-aware sandbox checkout). +- Command: `npm run check` with `FRESHELL_VITEST_BACKEND=cloud`, `FRESHELL_E2E_BACKEND=cloud`, and `RUST_TEST_THREADS=2`. +- Cloud Vitest: status 0; four non-empty Cloud Run shards, 5,986 tests total, zero failures. +- Source-runtime phase: status 0, including the Rust SPA startup/auth/restart checks. +- Rust phase: status 0; release/debug builds and `cargo test --workspace --locked` passed. The workspace reported zero failing tests (the two existing host-gated managed-Codex tests remained ignored by their declared host-gating contract). +- Electron phase: 32 files, 319 tests passed, zero failures. +- Overall: status 0. Inner receipt: `/home/dan/code/freshell/.worktrees/retire-node-server-v2/dist/pr699-verification/git-input-6rSfi7/receipts/run-mh3U0N/receipt.json`; coordinator receipt: `/home/dan/code/freshell/.worktrees/retire-node-server-v2/dist/pr699-verification/git-input-6rSfi7/receipts/run-mh3U0N/artifacts/coordinator/command-runs.json`; cloud receipt: `/home/dan/code/freshell/.worktrees/retire-node-server-v2/dist/pr699-verification/git-input-6rSfi7/cloud-result.json`. +- Sandbox cleanup: owned container absent, launcher closed, companion status 0, and the host coordinator reservation released. No production process or user data was touched. + +The remaining Task 6 browser scenarios, static checks, and Task 7 native packaged-runtime receipts are still required; this gate is not a substitute for those checks. 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 e1fb3cc8a..7823ec16f 100644 --- a/electron/desktop-config.ts +++ b/electron/desktop-config.ts @@ -1,20 +1,18 @@ 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 defaultConfigDir(): string { - return path.join(os.homedir(), '.freshell') -} - -function resolveConfigDir(configDir?: string): string { - return configDir ?? defaultConfigDir() +function getConfigPath(): string { + return path.join(os.homedir(), '.freshell', DESKTOP_CONFIG_FILENAME) } -function getConfigPath(configDir?: string): string { - return path.join(resolveConfigDir(configDir), DESKTOP_CONFIG_FILENAME) +function getConfigDir(): string { + return path.join(os.homedir(), '.freshell') } export function getDefaultDesktopConfig(): DesktopConfig { @@ -30,65 +28,95 @@ export function getDefaultDesktopConfig(): DesktopConfig { } } -export async function readDesktopConfig(configDir?: string): Promise { - const configPath = getConfigPath(configDir) +export async function readDesktopConfig(): Promise { + const configPath = getConfigPath() 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 } } -export async function writeDesktopConfig(config: DesktopConfig, configDir?: string): Promise { - const dir = resolveConfigDir(configDir) - await fsp.mkdir(dir, { recursive: true }) +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 }) - const configPath = getConfigPath(dir) + const configPath = getConfigPath() const tmpPath = configPath + '.tmp' await fsp.writeFile(tmpPath, JSON.stringify(config, null, 2)) await fsp.rename(tmpPath, configPath) } -// Per-directory mutex chains so two profiles' writes never serialize against -// each other while writes on the SAME directory stay ordered. -const mutexChains = new Map>() +// Simple mutex for serializing config patches +let mutexChain: Promise = Promise.resolve() -export async function patchDesktopConfig( - patch: Partial, - configDir?: string, -): Promise { - const dir = resolveConfigDir(configDir) +export async function patchDesktopConfig(patch: Partial): Promise { let result: DesktopConfig - // Chain onto the existing mutex for THIS directory so concurrent calls on - // the same dir run sequentially. - const work = (mutexChains.get(dir) ?? Promise.resolve()).then(async () => { - const existing = await readDesktopConfig(dir) + // Chain onto the existing mutex so concurrent calls run sequentially + const work = mutexChain.then(async () => { + const existing = await readDesktopConfig() const base = existing ?? getDefaultDesktopConfig() const merged = { ...base, ...patch } const validated = DesktopConfigSchema.parse(merged) - await writeDesktopConfig(validated, dir) + await writeDesktopConfig(validated) result = validated }) - // Update the chain — subsequent calls wait for this one to finish. - mutexChains.set(dir, work.catch(() => {})) + // Update the chain -- subsequent calls wait for this one to finish + mutexChain = work.catch(() => {}) await work return result! } /** - * Reset the internal mutex chains. Only for use in tests to ensure - * inter-test isolation — the module-level mutex map holds references from - * prior calls, which can leak state between test files. + * Reset the internal mutex chain. Only for use in tests to ensure + * inter-test isolation -- the module-level mutexChain holds references + * from prior calls, which can leak state between test files. */ export function _resetMutexForTesting(): void { - mutexChains.clear() + mutexChain = Promise.resolve() } diff --git a/electron/entry.ts b/electron/entry.ts index 1104b66d8..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' @@ -30,19 +29,7 @@ import { createTray } from './tray.js' import { resolveTrayIconPath } from './icon-path.js' import { buildAppMenu } from './menu.js' import { runStartup, type StartupContext, type BrowserWindowLike } from './startup.js' -import { acquireInstanceLock, initMainProcess } from './main.js' -import { - DEFAULT_PROFILE_ID, - buildPickerEntries, - buildRelaunchOptions, - computeOwnsServer, - readProfilesRegistry, - registryPathForHome, - resolveBootShape, - stripProfileArgs, - type PickerEntry, -} from './profile.js' -import { createChooseProfileHandler } from './profile-choice-handler.js' +import { initMainProcess } from './main.js' import { createWizardWindow } from './setup-wizard/wizard-window.js' import { createChooseLaunchOptionHandler } from './launch-choice-handler.js' import { buildLaunchOptions } from './launch-options.js' @@ -56,130 +43,8 @@ import type { RecoverableWebContents } from './renderer-recovery.js' const isPortAvailable = createPortAvailabilityCheck() const isDev = process.env.ELECTRON_DEV === '1' - -// --- Boot-shape resolution (must run before configDir/logger binding) ------- -// One process = one Chromium userData = one instance lock, ALWAYS. Named -// profiles (--profile= or FRESHELL_PROFILE) and the picker launcher each -// get their own userData dir — which also re-keys the single-instance lock — -// so the picker NEVER shares a userData dir with a resident Default instance -// (two browser processes on one profile dir is a Chromium storage hazard). -const registryAtBoot = readProfilesRegistry( - registryPathForHome(os.homedir()), - (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : undefined), -) - -const bootShape = resolveBootShape( - process.argv, process.env, registryAtBoot, - app.getName(), app.getPath('appData'), os.homedir(), -) -if (bootShape.userDataDir) { - // Electron's doc contract for app.setPath: the target directory must - // exist. Create-first is the documented-correct order. On failure we CANNOT - // continue with default userData (that would share the Default profile's - // Chromium store AND instance lock — the exact hazard the namespacing exists - // to prevent), so fail loudly instead. The logger isn't live this early. - try { - fs.mkdirSync(bootShape.userDataDir, { recursive: true }) - app.setPath('userData', bootShape.userDataDir) - } catch (err) { - console.error( - `[freshell] could not create the profile userData dir ${bootShape.userDataDir}; refusing to boot into shared-Default storage.`, - err instanceof Error ? err.message : String(err), - ) - app.exit(1) - process.exit(1) - } -} -const activeProfileId = bootShape.profileId -const isPickerLauncher = bootShape.kind === 'picker' -const configDir = bootShape.configDir +const configDir = path.join(os.homedir(), '.freshell') const mainProcessLogger = createElectronMainLogger({ configDir }) -if (registryAtBoot.error) { - mainProcessLogger.log({ severity: 'warn', event: 'profiles_registry_invalid', error: registryAtBoot.error }) -} -if (bootShape.error) { - mainProcessLogger.log({ severity: 'warn', event: 'profile_selection_invalid', error: bootShape.error }) -} - -/** True once this process holds its (userData-keyed) instance lock; - * re-entrant main() calls (wizard completion) must not re-request it. */ -let instanceLockHeld = false - -/** - * Show the profile picker and relaunch into the chosen profile. - * - * This launcher process holds the LAUNCHER-scoped instance lock (own - * userData dir), so a racing flag-less launch is turned away at the lock gate - * and delivers `second-instance` here, where we surface the existing picker - * window. Every confirmed choice — Default included — relaunches with an - * explicit `--profile=` and exits; the relaunched process then takes the - * chosen profile's own lock. The returned promise never settles. - * Closing the picker without choosing exits the app. - */ -async function runProfilePicker(entries: PickerEntry[]): Promise { - const pickerWin = new BrowserWindow({ - width: 520, - height: 480, - show: false, - autoHideMenuBar: true, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - nodeIntegration: false, - contextIsolation: true, - }, - }) - const pickerWebContentsId = pickerWin.webContents.id - // Duplicate flag-less launches are surfaced by the canonical handler - // installed in main() (covers all windows), so the picker doesn't add one. - - const cleanup = () => { - ipcMain.removeHandler('get-profiles') - ipcMain.removeHandler('choose-profile') - } - - ipcMain.removeHandler('get-profiles') - ipcMain.removeHandler('choose-profile') - ipcMain.handle('get-profiles', (event) => { - if ((event as { sender?: { id?: number } }).sender?.id !== pickerWebContentsId) return [] - return entries - }) - ipcMain.handle('choose-profile', createChooseProfileHandler({ - entries, - isAllowedSender: (event) => - (event as { sender?: { id?: number } }).sender?.id === pickerWebContentsId, - relaunchWithProfile: (id) => { - const args = [...stripProfileArgs(process.argv.slice(1)), `--profile=${id}`] - // On Linux AppImage, execPath points into a transient squashfs mount — - // relaunch needs the real AppImage path (electron-builder #1727/#4650). - app.relaunch(buildRelaunchOptions(args)) - app.exit(0) - }, - })) - - pickerWin.on('closed', () => { - cleanup() - app.exit(0) - }) - - try { - if (isDev) { - await pickerWin.loadURL('http://localhost:5179') - } else { - const packaged = path.join(process.resourcesPath, 'profile-picker', 'index.html') - const unpackaged = path.join(app.getAppPath(), 'dist', 'profile-picker', 'index.html') - await pickerWin.loadFile(fs.existsSync(packaged) ? packaged : unpackaged) - } - } catch (err) { - // The picker is the default boot path once profiles.json exists — log the - // failure loudly and still show the (broken) window so the user can close - // it instead of the app dying as a background zombie. - mainProcessLogger.log({ severity: 'error', event: 'profile_picker_load_failed', error: err instanceof Error ? err.message : String(err) }) - } - pickerWin.show() - return new Promise(() => { - // Never settles: this launcher exits via app.exit(0) on choice or close. - }) -} type EntryBrowserWindow = InstanceType type WindowListener = { event: string; callback: (...args: any[]) => void } @@ -360,10 +225,6 @@ function createRecoverableEntryWindow( /** True during the wizard flow; prevents app.quit() on window-all-closed. */ let wizardPhase = true -// True when startup adopted an already-running resident server that proves it -// owns this profile's config dir (tray/status surfaces read it as running). -let attachedToOwnResidentServer = false - /** * An explicit chooser selection to honor on the next main() pass. Set by the * choose-launch-option handler before it restarts the launch flow, consumed @@ -380,55 +241,8 @@ async function main(): Promise { event: 'electron_main_started', appVersion: app.getVersion(), isDev, - profile: activeProfileId, }) - // Instance lock, acquired BEFORE any side effects (provisioning, server - // spawn). Keyed to the userData dir chosen at module top: an explicit - // profile's own dir, the default dir for a plain launch, or the launcher - // dir for a picker launch. A same-profile duplicate quits here (delivering - // `second-instance` to the resident, which then shows its window). - // - // The onDenied hook lifts the `will-quit` wizard-phase veto: at this point - // `wizardPhase` is still true (it only flips false once a chooser/main - // window is reached), and entry.ts's module-level `will-quit` guard would - // otherwise preventDefault() this quit, leaving the turned-away duplicate - // as a headless zombie process. A denied duplicate never enters the wizard, - // so flipping it is unconditionally correct here. - if (!instanceLockHeld) { - if (!acquireInstanceLock(app, () => { wizardPhase = false })) { - return - } - instanceLockHeld = true - } - - // Canonical duplicate-launch surfacing, registered ONCE, as early as - // possible: covers the wizard, chooser, main window, and picker (initMain - // registers no second-instance handler — this one owns it end to end). - if (!app.listenerCount('second-instance')) { - app.on('second-instance', () => { - const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) - if (!win) return - if (win.isMinimized()) win.restore() - win.show() - win.focus() - }) - } - - // --- Profile picker ------------------------------------------------------- - // A picker launch (no explicit profile + registry names ≥1 profile) parks - // its userData in the launcher dir, holds the launcher lock, shows only the - // picker, and ends here. See resolveBootShape (module top) for the shape - // decision and runProfilePicker for choice semantics. - if (isPickerLauncher) { - // The picker is its own lifecycle — there is no wizard transition to - // protect. Let will-quit/window-all-closed behave normally so Cmd+Q and - // closing the window both exit the launcher cleanly. - wizardPhase = false - await runProfilePicker(buildPickerEntries(registryAtBoot)) - return - } - // Consolidated window-all-closed handler: during the wizard phase we keep // the app alive so main() can re-run after the wizard closes. Once the main // window is up (wizardPhase = false), quit on non-macOS as is standard. @@ -454,7 +268,7 @@ async function main(): Promise { /* best-effort cleanup */ } }, - patchDesktopConfig: (p) => patchDesktopConfig(p, configDir), + patchDesktopConfig, }) // Consume any pending forced launch (set by the chooser handler before it @@ -463,15 +277,14 @@ async function main(): Promise { pendingForcedLaunch = undefined // Read desktop config (or use defaults for first run) - const desktopConfig = (await readDesktopConfig(configDir)) ?? getDefaultDesktopConfig() + const desktopConfig = (await readDesktopConfig()) ?? getDefaultDesktopConfig() const port = desktopConfig.port ?? 3001 // Create DI implementations const resourcesPath = isDev ? undefined : process.resourcesPath - const daemonManager = await createDaemonManager(resourcesPath) const serverSpawner = createServerSpawner() const hotkeyManager = createHotkeyManager(globalShortcut) - const windowStatePersistence = createWindowStatePersistence(configDir) + const windowStatePersistence = createWindowStatePersistence() // autoUpdater is only available when the app is packaged. // In dev mode, provide a no-op stub. @@ -504,36 +317,6 @@ async function main(): Promise { const ctx: StartupContext = { desktopConfig, forcedLaunch, - profileId: activeProfileId, - // Default is just another tenant once any profile evidence exists - // (registry entries, unreadable registry, or ~/.freshell- dirs from - // unlisted ids): it must never auto-attach to a neighbor's server and it - // auto-bumps a busy port. computeOwnsServer is the tested, pure gate. - ownsServer: computeOwnsServer({ - profileId: activeProfileId, - registry: registryAtBoot, - // Only DIRECTORIES whose suffix is a valid profile id count (a backup - // tarball or the port's oracle seeds must not flip ownership). - listHomeDirsWithState: () => { - // Match the `.freshell-` prefix BEFORE stat-ing anything: a large or - // partially network-mounted home should not make boot pay per-entry cwd sync. - let names: string[] = [] - try { - names = fs.readdirSync(os.homedir()).filter((name) => name.startsWith('.freshell-')) - } catch (err) { - // A broken home read must not crash the boot before a window exists. - mainProcessLogger.log({ severity: 'warn', event: 'profile_state_scan_failed', error: err instanceof Error ? err.message : String(err) }) - } - return names.filter((name) => { - try { - return fs.statSync(path.join(os.homedir(), name)).isDirectory() - } catch { - return false - } - }) - }, - }), - daemonManager, serverSpawner, hotkeyManager, windowStatePersistence, @@ -543,31 +326,6 @@ async function main(): Promise { resourcesPath, configDir, mainProcessLogger, - isPortAvailable, - patchDesktopConfig: (patch: { port?: number }) => patchDesktopConfig(patch, configDir), - fetchServerInstanceId: (url: string): Promise => { - // Unauthenticated /api/health only; failure => indistinct (caller treats - // as foreign — safer to bump than to misidentify a neighbor as ours). - return new Promise((resolve) => { - const req = http.get(`${url}/api/health`, { timeout: 8_000 }, (res) => { - const chunks: Buffer[] = [] - res.on('data', (chunk: Buffer) => chunks.push(chunk)) - res.on('end', () => { - clearTimeout(timer) - try { - const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) - resolve(typeof body?.instanceId === 'string' && body.instanceId ? body.instanceId : undefined) - } catch { - resolve(undefined) - } - }) - res.on('error', () => { clearTimeout(timer); resolve(undefined) }) - }) - req.on('timeout', () => { req.destroy(); clearTimeout(timer); resolve(undefined) }) - req.on('error', () => { clearTimeout(timer); resolve(undefined) }) - const timer = setTimeout(() => { req.destroy(); resolve(undefined) }, 10_000) - }) - }, platform: process.platform, fetchHealthCheck: (url: string): Promise => { // Use Node's http module instead of global fetch() — Electron's main @@ -630,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, @@ -681,12 +445,11 @@ async function main(): Promise { }, getServerStatus: async () => { return { - running: serverSpawner.isRunning() || attachedToOwnResidentServer, + running: serverSpawner.isRunning(), mode: desktopConfig.serverMode, } }, }, - { tooltip: activeProfileId === DEFAULT_PROFILE_ID ? 'Freshell' : `Freshell (${activeProfileId})` }, ) }, } @@ -757,14 +520,17 @@ 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, globalHotkey: config.globalHotkey, setupCompleted: true, - }, configDir) + }) }) ipcMain.handle('get-launch-options', () => @@ -772,7 +538,7 @@ async function main(): Promise { ) ipcMain.handle('choose-launch-option', createChooseLaunchOptionHandler({ - patchDesktopConfig: (patch) => patchDesktopConfig(patch, configDir), + patchDesktopConfig, getCurrentPort: () => desktopConfig.port, validateServerAuth: (url: string, token: string) => ctx.fetchAuthenticated?.(`${url}/api/settings`, token) ?? Promise.resolve(false), isAllowedSender: (event) => { @@ -796,7 +562,6 @@ async function main(): Promise { // Run startup sequence const result = await runStartup(ctx) - if (result.type === 'main' && result.attached) attachedToOwnResidentServer = true if (result.type === 'wizard') { // Show the setup wizard @@ -854,7 +619,7 @@ async function main(): Promise { ipcMain.handle('get-server-mode', () => desktopConfig.serverMode) ipcMain.handle('get-server-status', async () => ({ - running: serverSpawner.isRunning() || attachedToOwnResidentServer, + running: serverSpawner.isRunning(), mode: desktopConfig.serverMode, })) diff --git a/electron/launch-policy.ts b/electron/launch-policy.ts index 756950b29..af47a972f 100644 --- a/electron/launch-policy.ts +++ b/electron/launch-policy.ts @@ -21,13 +21,6 @@ export interface ChooseLaunchActionOptions { candidates: LaunchServerCandidate[] savedRemoteReachable: boolean savedRemoteAuthenticated?: boolean - /** - * True when the booting profile owns its own server: always for named - * profiles, and also for the DEFAULT profile once any named profile is - * installed (a machine with several profiles treats Default as just another - * tenant — it must never attach to a neighbor's server either). - */ - ownsServer?: boolean } export function chooseLaunchAction(options: ChooseLaunchActionOptions): LaunchAction { @@ -67,19 +60,6 @@ export function chooseLaunchAction(options: ChooseLaunchActionOptions): LaunchAc return { type: 'show-chooser', candidates, reason: 'saved-remote-unreachable' } } - // Owning-boot override. Runs AFTER remote-mode handling on purpose: a saved - // remote URL is a per-profile intent that stays valid even against an empty - // candidate list. For every other owning boot: app-bound/daemon start their - // own server; remote-without-a-URL goes to the manual chooser — NEVER to a - // discovery-derived auto-connect, which would attach a neighbor profile's - // server with a token resolved from the wrong config dir. - if (options.ownsServer) { - if (desktopConfig.serverMode === 'app-bound' || desktopConfig.serverMode === 'daemon') { - return { type: 'start-local' } - } - return { type: 'show-chooser', candidates, reason: 'manual-choice' } - } - if (candidates.length > 1) { return { type: 'show-chooser', candidates, reason: 'multiple-candidates' } } @@ -92,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 55fd6b021..430aafcd4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -5,7 +5,6 @@ export interface ElectronApp { whenReady(): Promise on(event: string, callback: (...args: any[]) => void): void - listenerCount(event: string): number quit(): void requestSingleInstanceLock(): boolean } @@ -18,32 +17,20 @@ export interface MainProcessDeps { platform: NodeJS.Platform } -/** - * Acquire the single-instance lock for this process's userData dir. When - * entry.ts has namespaced userData per profile, each profile holds its own - * lock. Call BEFORE any boot side effects (provisioning, server spawn). - * Returns true when the lock is held; on failure the app quits and this - * returns false. `onDenied` (optional) runs immediately BEFORE app.quit() — - * entry.ts uses it to lift the wizard-phase `will-quit` veto for the denied - * duplicate, which never enters the wizard. - */ -export function acquireInstanceLock(app: ElectronApp, onDenied?: () => void): boolean { +export async function initMainProcess(deps: MainProcessDeps): Promise { + const { app, minimizeToTray } = deps + + // Single-instance lock const gotLock = app.requestSingleInstanceLock() if (!gotLock) { - onDenied?.() app.quit() - return false + return } - return true -} -export async function initMainProcess(deps: MainProcessDeps): Promise { - // The caller must hold the instance lock already (see acquireInstanceLock) - // and install the canonical `second-instance` surfacing handler (entry.ts - // registers it right after the lock gate, covering every boot phase). - const { app, minimizeToTray } = deps let mainWindow: any = null let isQuitting = false + let quitContinuationStarted = false + let serverStopInProgress: Promise | undefined await app.whenReady() @@ -61,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 @@ -74,6 +102,16 @@ export async function initMainProcess(deps: MainProcessDeps): Promise { } }) + // Second instance: focus existing window + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized?.()) { + mainWindow.restore?.() + } + mainWindow.focus?.() + } + }) + // Note: window-all-closed is handled by entry.ts with a lifecycle-aware // guard (wizardPhase). This prevents the app from quitting during the // wizard-to-main transition on Windows/Linux. diff --git a/electron/preload.ts b/electron/preload.ts index f1d5a2098..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 @@ -25,9 +25,6 @@ export type LaunchChoiceResult = | { ok: true } | { ok: false; error: string } -export type ProfileChoiceResult = { ok: true } | { ok: false; error: string } -export interface PickerProfileEntry { id: string; label: string } - export interface FreshellDesktopApi { platform: string isElectron: boolean @@ -40,8 +37,6 @@ export interface FreshellDesktopApi { completeSetup: (config: WizardSetupConfig) => Promise getLaunchOptions: () => Promise chooseLaunchOption: (choice: LaunchChoice) => Promise - getProfiles: () => Promise - chooseProfile: (id: string) => Promise openExternal: (url: string) => Promise } @@ -70,8 +65,6 @@ export function registerPreloadApi( completeSetup: (config: WizardSetupConfig) => ipcRenderer.invoke('complete-setup', config), getLaunchOptions: () => ipcRenderer.invoke('get-launch-options'), chooseLaunchOption: (choice: LaunchChoice) => ipcRenderer.invoke('choose-launch-option', choice), - getProfiles: () => ipcRenderer.invoke('get-profiles'), - chooseProfile: (id: string) => ipcRenderer.invoke('choose-profile', id), openExternal: (url: string) => ipcRenderer.invoke('open-external-url', url), } diff --git a/electron/profile-choice-handler.ts b/electron/profile-choice-handler.ts deleted file mode 100644 index 6f49edc36..000000000 --- a/electron/profile-choice-handler.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from 'zod' -import type { PickerEntry } from './profile.js' - -export interface ChooseProfileHandlerDeps { - entries: PickerEntry[] - /** Defense-in-depth: only the picker window may drive this channel. */ - isAllowedSender: (event: unknown) => boolean - /** Relaunch the app pinned to the chosen profile id, then exit this - * launcher process. 'default' is a valid id -- the relaunched process is - * an explicit launch of the default profile. */ - relaunchWithProfile: (id: string) => void -} - -export type ProfileChoiceResult = { ok: true } | { ok: false; error: string } - -export function createChooseProfileHandler(deps: ChooseProfileHandlerDeps) { - const allowed = new Set(deps.entries.map((e) => e.id)) - return async (event: unknown, rawId: unknown): Promise => { - if (!deps.isAllowedSender(event)) { - return { ok: false, error: 'Unexpected profile request.' } - } - const parsed = z.string().safeParse(rawId) - if (!parsed.success || !allowed.has(parsed.data)) { - return { ok: false, error: 'Unknown profile.' } - } - deps.relaunchWithProfile(parsed.data) - return { ok: true } - } -} diff --git a/electron/profile-picker/index.html b/electron/profile-picker/index.html deleted file mode 100644 index f46e121bf..000000000 --- a/electron/profile-picker/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Freshell Profiles - - -
- - - diff --git a/electron/profile-picker/main.tsx b/electron/profile-picker/main.tsx deleted file mode 100644 index b9c9617bc..000000000 --- a/electron/profile-picker/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import { createRoot } from 'react-dom/client' -import './picker.css' -import { ProfilePicker } from './picker.js' - -createRoot(document.getElementById('root')!).render( - - - , -) diff --git a/electron/profile-picker/picker.css b/electron/profile-picker/picker.css deleted file mode 100644 index 96ba1ba73..000000000 --- a/electron/profile-picker/picker.css +++ /dev/null @@ -1,45 +0,0 @@ -body { - margin: 0; - font-family: system-ui, sans-serif; - background: #101418; - color: #f4f7fa; -} - -.picker { - max-width: 400px; - margin: 3rem auto; - padding: 0 1.5rem; -} - -.picker-subtitle { - font-size: 0.875rem; - opacity: 0.7; -} - -.picker-list { - list-style: none; - padding: 0; - margin: 1.5rem 0 0; -} - -.picker-list button { - width: 100%; - padding: 0.625rem 1rem; - margin-bottom: 0.5rem; - border-radius: 0.5rem; - border: 1px solid #2d3a4a; - background: #1a2230; - color: inherit; - font-size: 1rem; - cursor: pointer; -} - -.picker-list button:hover, -.picker-list button:focus-visible { - background: #24304a; - outline: none; -} - -.picker-error { - color: #f87171; -} diff --git a/electron/profile-picker/picker.tsx b/electron/profile-picker/picker.tsx deleted file mode 100644 index 28756cdc0..000000000 --- a/electron/profile-picker/picker.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useState } from 'react' - -declare global { - interface Window { - freshellDesktop?: { - getProfiles?: () => Promise<{ id: string; label: string }[]> - chooseProfile?: (id: string) => Promise<{ ok: true } | { ok: false; error: string }> - } - } -} - -interface PickerEntry { - id: string - label: string -} - -export function ProfilePicker() { - const [entries, setEntries] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let cancelled = false - void window.freshellDesktop?.getProfiles?.().then((list) => { - if (!cancelled) setEntries(list ?? []) - }).catch((err: unknown) => { - if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load profiles') - }) - return () => { - cancelled = true - } - }, []) - - const choose = async (id: string) => { - setError(null) - try { - const result = await window.freshellDesktop?.chooseProfile?.(id) - if (result && !result.ok) setError(result.error) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to choose profile') - } - } - - return ( -
-

Choose a Freshell profile

-

- This machine has more than one Freshell profile. Each profile keeps its - own settings and can connect to a different server. -

- {error ? ( -

{error}

- ) : null} - {entries === null && !error ? ( - window.freshellDesktop?.getProfiles - ?

Loading profiles…

- :

The profile list is unavailable (preload did not load).

- ) : null} -
    - {(entries ?? []).map((entry) => ( -
  • - -
  • - ))} -
-
- ) -} diff --git a/electron/profile.ts b/electron/profile.ts deleted file mode 100644 index b0b0b808c..000000000 --- a/electron/profile.ts +++ /dev/null @@ -1,356 +0,0 @@ -// electron/profile.ts -import path from 'path' -import { z } from 'zod' - -export const DEFAULT_PROFILE_ID = 'default' - -/** - * The profile-picker launcher reserves this id: a flag-less launch that is - * about to show the picker namespaces its userData to - * `/-profile-picker` so the picker process never shares a - * Chromium userData dir with a resident Default (or named) instance. - */ -export const PICKER_USERDATA_ID = 'profile-picker' - -/** - * Profile ids become directory names on every supported OS, so keep them - * conservative: lowercase kebab-case, no path separators or dots (so no - * '..' traversal), bounded length. - */ -export const PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/ - -export const ProfileEntrySchema = z.object({ - id: z.string().regex(PROFILE_ID_PATTERN), - label: z.string().trim().min(1).max(64).optional(), -}) - -export const ProfilesRegistrySchema = z.object({ - profiles: z.array(ProfileEntrySchema), -}).superRefine((value, ctx) => { - const seen = new Set() - for (const entry of value.profiles) { - if (entry.id === DEFAULT_PROFILE_ID) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `'${DEFAULT_PROFILE_ID}' is a reserved profile id` }) - } - if (entry.id === PICKER_USERDATA_ID) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `'${PICKER_USERDATA_ID}' is a reserved profile id` }) - } - if (seen.has(entry.id)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate profile id '${entry.id}'` }) - } - seen.add(entry.id) - } -}) - -export type ProfileEntry = z.infer - -/** - * Contract note — the built-in Default profile is ALWAYS part of the choice - * set, so "more than one profile is configured" (per the User Request wording) - * is satisfied as soon as the registry names ≥1 named profile: the effective - * choices are `[Default, ...registry.profiles]`. This keeps the registry file - * minimal (named profiles only) and matches the picker UX. - */ - -export type ProfileSource = 'argv' | 'env' | 'default' - -export interface ProfileSelection { - id: string - explicit: boolean - source: ProfileSource -} - -export interface ProfileSelectionResult { - selection: ProfileSelection - /** Set when an explicitly requested id was invalid and default was substituted. */ - error?: string -} - -/** Extract `--profile=` or `--profile ` from a raw argv slice. */ -export function parseProfileArg(argv: string[]): string | undefined { - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--profile') { - const next = argv[i + 1] - if (next && !next.startsWith('--')) return next - return undefined - } - if (arg.startsWith('--profile=')) return arg.slice('--profile='.length) - } - return undefined -} - -/** Remove every `--profile=` / `--profile ` pair from an argv slice. - * Mirrors parseProfileArg exactly: `--profile` only consumes the next token - * when it is a non-flag value; `--profile --other` drops just `--profile` - * (since no value was taken) and keeps `--other`. */ -export function stripProfileArgs(argv: string[]): string[] { - const out: string[] = [] - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg.startsWith('--profile=')) continue - if (arg === '--profile') { - const next = argv[i + 1] - if (next !== undefined && !next.startsWith('--')) i++ // consumed a real value - continue - } - out.push(arg) - } - return out -} - -/** - * Resolve the active profile. Precedence: `--profile` argv > `FRESHELL_PROFILE` - * env > (picker, by returning non-explicit default) > default. - */ -export function resolveProfileSelection( - argv: string[], - env: NodeJS.ProcessEnv, -): ProfileSelectionResult { - const fromArgv = parseProfileArg(argv) - const fromEnv = env.FRESHELL_PROFILE?.trim() - const raw = fromArgv ?? (fromEnv ? fromEnv : undefined) - const source: ProfileSource = fromArgv !== undefined ? 'argv' : raw !== undefined ? 'env' : 'default' - if (raw === undefined) { - return { selection: { id: DEFAULT_PROFILE_ID, explicit: false, source: 'default' } } - } - if (raw === DEFAULT_PROFILE_ID) { - return { selection: { id: DEFAULT_PROFILE_ID, explicit: true, source } } - } - if (raw === PICKER_USERDATA_ID) { - return { - selection: { id: DEFAULT_PROFILE_ID, explicit: false, source: 'default' }, - error: `Profile id '${PICKER_USERDATA_ID}' is reserved for the picker launcher; using the default profile.`, - } - } - if (!PROFILE_ID_PATTERN.test(raw)) { - return { - selection: { id: DEFAULT_PROFILE_ID, explicit: false, source: 'default' }, - error: `Invalid profile id '${raw}' (must match ${PROFILE_ID_PATTERN}); using the default profile.`, - } - } - return { selection: { id: raw, explicit: true, source } } -} - -/** Profile config dir: `~/.freshell` for default, `~/.freshell-` for named. */ -export function configDirForProfile(id: string, homedir: string): string { - if (id === DEFAULT_PROFILE_ID) return path.join(homedir, '.freshell') - return path.join(homedir, `.freshell-${id}`) -} - -/** - * userData dir for a named profile. Returns undefined for the default - * profile, meaning "leave Electron's default userData untouched". - */ -export function userDataDirForProfile( - id: string, - appName: string, - appDataDir: string, -): string | undefined { - if (id === DEFAULT_PROFILE_ID) return undefined - return path.join(appDataDir, `${appName}-${id}`) -} - -/** - * userData dir for the ephemeral profile-picker launcher process. It MUST NOT - * be the default profile's userData: when a Default instance is resident, a - * picker launch that reused Default's userData would put two browser processes - * on one Chromium profile dir (process-singleton violation, storage hazard). - * The picker's own userData also re-keys the instance lock, giving one picker - * at a time with `second-instance` focusing the resident picker. - */ -export function userDataDirForPicker(appName: string, appDataDir: string): string { - return path.join(appDataDir, `${appName}-${PICKER_USERDATA_ID}`) -} - -/** The registry is machine-global and always lives in the default config dir. */ -export function registryPathForHome(homedir: string): string { - return path.join(homedir, '.freshell', 'profiles.json') -} - -export interface RegistryReadResult { - profiles: ProfileEntry[] - /** Set when a file existed but was unusable; profiles are then empty. */ - error?: string -} - -/** - * Read and validate the profile registry. A missing file is normal (no - * profiles configured); a present-but-invalid file is an error the caller - * should surface (log) while booting the default profile. - */ -export function readProfilesRegistry( - registryPath: string, - readFile: (p: string) => string | undefined, -): RegistryReadResult { - let content: string | undefined - try { - content = readFile(registryPath) - } catch (err) { - // Exists-but-unreadable (EACCES, a directory named profiles.json, a TOCTOU - // race between existsSync and readFileSync in the caller's reader): warn - // and fall back to the default profile, exactly like an invalid registry. - return { profiles: [], error: `Profile registry at ${registryPath} could not be read (${err instanceof Error ? err.message : String(err)}); ignoring it.` } - } - if (content === undefined) return { profiles: [] } - let parsedJson: unknown - try { - parsedJson = JSON.parse(content) - } catch { - return { profiles: [], error: `Profile registry at ${registryPath} is not valid JSON; ignoring it.` } - } - const parsed = ProfilesRegistrySchema.safeParse(parsedJson) - if (!parsed.success) { - return { profiles: [], error: `Profile registry at ${registryPath} is invalid; ignoring it.` } - } - return { profiles: parsed.data.profiles } -} - -/** - * The picker appears when the choice set (default + named) has more than one - * entry — that equals any non-empty named list, since Default always counts. - */ -export function shouldShowProfilePicker( - selection: ProfileSelection, - registry: RegistryReadResult, -): boolean { - return !selection.explicit && registry.profiles.length >= 1 -} - -/** - * The full module-top boot decision for entry.ts. One of: - * - 'picker': flag-less launch with ≥1 named profiles in the registry — - * userData is namespaced to the launcher dir and the boot shows ONLY - * the picker (configDir stays the default profile dir, since the registry - * and the launcher's diagnostic logs live there). - * - 'explicit': argv/env named a valid profile — namespace userData (except - * default) and boot that profile. - * - 'default': everything else — today's boot, zero behavior change. - */ -export interface BootShape { - kind: 'picker' | 'explicit' | 'default' - profileId: string - userDataDir?: string - configDir: string - /** Set when an explicit request was invalid and default was substituted; - * entry.ts logs it (warn) so the fallback is visible. */ - error?: string -} - -export function resolveBootShape( - argv: string[], - env: NodeJS.ProcessEnv, - registry: RegistryReadResult, - appName: string, - appDataDir: string, - homedir: string, -): BootShape { - const { selection, error } = resolveProfileSelection(argv, env) - // An explicitly requested but INVALID profile must NOT surface the picker: - // the resolver already fell back to default; honor that and surface the - // reason via `error`. - if (error) { - return { - kind: 'default', - profileId: DEFAULT_PROFILE_ID, - configDir: configDirForProfile(DEFAULT_PROFILE_ID, homedir), - error, - } - } - if (selection.explicit) { - return { - kind: 'explicit', - profileId: selection.id, - userDataDir: userDataDirForProfile(selection.id, appName, appDataDir), - configDir: configDirForProfile(selection.id, homedir), - } - } - if (shouldShowProfilePicker(selection, registry)) { - // The picker launcher is not itself a profile session: it logs to the - // default config dir but parks its userData in its own dir. - return { - kind: 'picker', - profileId: DEFAULT_PROFILE_ID, - userDataDir: userDataDirForPicker(appName, appDataDir), - configDir: configDirForProfile(DEFAULT_PROFILE_ID, homedir), - } - } - return { - kind: 'default', - profileId: DEFAULT_PROFILE_ID, - configDir: configDirForProfile(DEFAULT_PROFILE_ID, homedir), - } -} - -export interface PickerEntry { - id: string - label: string -} - -/** - * `app.relaunch` options for switching profiles. When the current process is - * an AppImage, `process.execPath` points into a TRANSIENT squashfs mount and a - * plain relaunch silently fails (electron-builder #1727/#4650) — so we pin - * execPath to the real AppImage path from `process.env.APPIMAGE`. - */ -export function buildRelaunchOptions( - args: string[], - env: NodeJS.ProcessEnv = process.env, -): { args: string[]; execPath?: string } { - const appImage = env.APPIMAGE - return appImage ? { args, execPath: appImage } : { args } -} - -/** Picker entries: the default profile first, then the registry in file order. */ -export function buildPickerEntries(registry: RegistryReadResult): PickerEntry[] { - return [ - { id: DEFAULT_PROFILE_ID, label: 'Default' }, - ...registry.profiles.map((p) => ({ id: p.id, label: p.label ?? p.id })), - ] -} - -/** - * True when any named-profile state exists on disk, even when the registry is - * missing/unreadable or an id was used without being listed (both are - * documented: `FRESHELL_PROFILE=work` works with no registry entry — the id - * "simply starts with a fresh configuration" in `~/.freshell-work`). - * - * A `~/.freshell-` directory proves a profile ran here; the picker - * launcher's own userData dir lives under appData, NOT homedir. Only - * directories whose suffix is a valid profile id count. CAUTION: anything - * validly named — including a hand-made backup like `~/.freshell-backup` and - * the port oracle's seed dirs (`~/.freshell-qa-...`) — DOES count. That is - * the deliberate fail-safe side (Default boot treats itself as a tenant once - * it could collide); it changes Default's launch behavior on hosts where - * such a dir exists, so document, don't pretend. - */ -export function hasNamedProfileState( - listHomeDirs: () => string[], -): boolean { - return listHomeDirs().some((name) => { - const m = /^\.freshell-(.+)$/.exec(name) - return m !== null && PROFILE_ID_PATTERN.test(m[1]) - }) -} - -/** - * Canonical server-ownership gate for a boot. A boot OWNS its server when: - * 1. it is a named profile, OR - * 2. the registry names any named profile (multi-profile install), OR - * 3. the registry could not be read (fail closed: a broken registry must - * never re-enable neighbor-server adoption), OR - * 4. a named-profile state dir exists on disk (covers unlisted ids and a - * deleted-after-use registry). - * Anything else is a legacy single-profile install and keeps historical - * discovery-based behavior. - */ -export function computeOwnsServer(options: { - profileId: string - registry: RegistryReadResult - listHomeDirsWithState: () => string[] -}): boolean { - if (options.profileId !== DEFAULT_PROFILE_ID) return true - if (options.registry.error !== undefined) return true - if (options.registry.profiles.length > 0) return true - return hasNamedProfileState(options.listHomeDirsWithState) -} diff --git a/electron/server-spawner.ts b/electron/server-spawner.ts index a18db9a8d..280b9f479 100644 --- a/electron/server-spawner.ts +++ b/electron/server-spawner.ts @@ -3,211 +3,373 @@ 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 (or the active profile's config dir) - /** When true, pin FRESHELL_CONFIG_DIR=configDir in the spawned server env. - * Set for named profiles only — the default profile keeps legacy - * FRESHELL_HOME resolution. */ - pinProfileConfigDir?: boolean - 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 + /** Time to allow the final server log writes to finish after process exit. */ + logFlushTimeoutMs?: 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 } -/** Environment for a spawned server: inherits ours, pinned to the spawn port. - * AUTH_TOKEN and any inherited FRESHELL_CONFIG_DIR are always DROPPED: the - * spawned server's bootstrap drives `/.env` (bootstrap anchors it), and - * dotenv never overrides an exported value — inheriting either would desync - * the server from the renderer's token/config. The optional third argument - * pins FRESHELL_CONFIG_DIR — used for named-profile app-bound servers only; - * omitted for the default profile so server-side FRESHELL_HOME resolution - * keeps its legacy precedence. */ -export function buildSpawnEnv( - baseEnv: NodeJS.ProcessEnv, +const DEFAULT_GRACEFUL_TIMEOUT_MS = 5_000 +const DEFAULT_FORCE_TIMEOUT_MS = 5_000 +const DEFAULT_LOG_FLUSH_TIMEOUT_MS = 2_000 +const REQUEST_TIMEOUT_MS = 2_000 + +interface HttpResponseBody { + statusCode?: number + body: string +} + +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 +} + +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'), + }) + }) + response.on('error', reject) + } + + 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, - configDir?: string, -): Record { - const { AUTH_TOKEN: _a, FRESHELL_CONFIG_DIR: _c, ...rest } = baseEnv as Record - const out: Record = { ...rest, PORT: String(port) } - if (configDir !== undefined) { - out.FRESHELL_CONFIG_DIR = configDir + 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}`) } - return out + + 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 +} + +function logServerFailure(event: string, error: unknown, pid?: number): void { + console.error(JSON.stringify({ + severity: 'error', + component: 'electron-server-spawner', + event, + pid, + error: error instanceof Error ? error.message : String(error), + })) +} + +function pipeServerLog(child: ChildProcess, logDir: string): Promise { + return new Promise((resolve) => { + try { + const logStream = fs.createWriteStream(path.join(logDir, 'server.log'), { flags: 'a' }) + logStream.once('finish', resolve) + logStream.once('close', resolve) + logStream.on('error', (error) => { + // File-open and disk errors arrive asynchronously. Drain the child pipes + // after a log failure so a full pipe cannot block the Rust server. + child.stdout?.unpipe(logStream) + child.stderr?.unpipe(logStream) + child.stdout?.resume() + child.stderr?.resume() + logServerFailure('server_log_failed', error, child.pid) + resolve() + }) + child.stdout?.pipe(logStream, { end: false }) + child.stderr?.pipe(logStream, { end: false }) + // close runs after both stdio pipes have closed; either pipe ending first + // must not close the shared destination while the other still has output. + child.once('close', () => logStream.end()) + } catch (error) { + child.stdout?.resume() + child.stderr?.resume() + logServerFailure('server_log_failed', error, child.pid) + resolve() + } + }) } 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 - - async function pollHealthCheck(port: number, timeoutMs: number): Promise { - const startTime = Date.now() - let delay = 100 + let logCompletion: Promise | undefined - 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') - } - - 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')) - }) - }) - return // Success - } catch { - // Wait before retrying - await new Promise((resolve) => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, 5000) // Exponential backoff, cap at 5s - } + async function flushServerLog(timeoutMs = DEFAULT_LOG_FLUSH_TIMEOUT_MS): Promise { + const completion = logCompletion + if (!completion) return + let timer: ReturnType | undefined + try { + await Promise.race([ + completion, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Server log did not finish before the shutdown deadline')), timeoutMs) + }), + ]) + if (logCompletion === completion) logCompletion = undefined + } finally { + if (timer) clearTimeout(timer) } - - throw new Error(`Health check timed out after ${timeoutMs}ms`) } return { async start(options: ServerSpawnerOptions): Promise { - // Kill existing process if running (double-start idempotent) - if (childProcess && running) { + if (childProcess || logCompletion) { 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 = buildSpawnEnv(process.env, port, options.pinProfileConfigDir ? configDir : undefined) - - 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 + 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, } - - // 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 - processExited = true + const markExited = () => { + if (childProcess === spawned) { + processExited = true + running = false + childProcess = null + } } + spawned.once('close', markExited) + spawned.on('error', (error) => { + // A failed spawn has no PID. Errors from kill() or IPC on an existing + // child do not establish that it exited, so keep tracking that child. + if (spawned.pid === undefined) markExited() + logServerFailure('server_process_failed', error, spawned.pid) + }) - childProcess.on('close', startCloseHandler) - childProcess.on('error', startCloseHandler) + logCompletion = pipeServerLog(spawned, resources.logDir) - // Pipe to log file try { - const logStream = fs.createWriteStream(path.join(logDir, 'server.log'), { flags: 'a' }) - childProcess.stdout?.pipe(logStream) - childProcess.stderr?.pipe(logStream) - } catch { - // Ignore log errors + await pollHealthCheck(port, timeoutMs, () => processExited) + const authToken = options.authToken ?? readAuthToken(resources.configDir) + await verifyRustServerInfo(port, authToken, () => processExited) + } catch (error) { + // A rejected start never reaches the main window's quit cleanup. + // Release this exact child before reporting the readiness failure. + try { + await this.stop() + } catch (stopError) { + logServerFailure('server_start_cleanup_failed', stopError, spawned.pid) + throw new AggregateError([error, stopError], 'Server startup and cleanup failed') + } + throw error } - - await pollHealthCheck(port, timeoutMs) }, - 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 + await flushServerLog(options.logFlushTimeoutMs) + 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) + if (error) reject(error) + else resolve() + } + + const onExit = () => { + if (childProcess === proc) { + childProcess = null + running = false + processExited = true + } + finish() + } + + proc.once('close', 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) }) + await flushServerLog(options.logFlushTimeoutMs) }, isRunning(): boolean { 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) {
- -