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..27bbec05d 100644
--- a/.github/workflows/electron-build.yml
+++ b/.github/workflows/electron-build.yml
@@ -1,46 +1,123 @@
name: Electron Build
on:
+ workflow_dispatch:
push:
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/.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/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 @@
-
+
@@ -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-claude-sidecar/model-catalog.mjs b/crates/freshell-claude-sidecar/model-catalog.mjs
index e086855c5..5e171e0ca 100644
--- a/crates/freshell-claude-sidecar/model-catalog.mjs
+++ b/crates/freshell-claude-sidecar/model-catalog.mjs
@@ -1,4 +1,5 @@
-import { pathToFileURL } from 'node:url'
+import { realpathSync } from 'node:fs'
+import { fileURLToPath, pathToFileURL } from 'node:url'
const log = (message, details = {}) => process.stderr.write(`${JSON.stringify({ severity: 'warn', component: 'claude-model-catalog', message, ...details })}\n`)
@@ -33,7 +34,21 @@ export async function probeModelCatalog(query, { env = process.env, timeoutMs =
}
}
-if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+function isMainModule() {
+ if (!process.argv[1]) return false
+ try {
+ // macOS commonly exposes the same temporary/app path through aliases such
+ // as /var and /private/var. Canonicalize both sides before comparing so a
+ // direct launch still runs the probe when the argv path is symlinked.
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])
+ } catch {
+ // Preserve the normal URL comparison as a best-effort fallback if a path
+ // disappears during startup or the platform cannot resolve it.
+ return import.meta.url === pathToFileURL(process.argv[1]).href
+ }
+}
+
+if (isMainModule()) {
try {
const { query } = await import(process.env.FRESHELL_CLAUDE_SDK_QUERY_MODULE || '@anthropic-ai/claude-agent-sdk')
const models = await probeModelCatalog(query)
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 ad14ef0e7..7461b6c93 100644
--- a/crates/freshell-freshagent/src/terminal_tabs.rs
+++ b/crates/freshell-freshagent/src/terminal_tabs.rs
@@ -3288,13 +3288,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 a4dfab046..92380c9c9 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
@@ -89,6 +89,15 @@ pub struct ManagedCodexMcpRenderings {
pub sidecar: McpInjection,
}
+/// 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`).
@@ -97,14 +106,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).
@@ -128,36 +158,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(),
- )]);
+ return Ok(McpServerCommand {
+ command: McpServerArg::Literal("node".to_string()),
+ args: vec![McpServerArg::Path(built.to_string_lossy().into_owned())],
+ });
}
- // `require.resolve('tsx')` resolves the package export "." →
- // `./dist/loader.mjs` (rev 2 pin vs node_modules/tsx/package.json).
- 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.",
- ));
- }
- 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`
@@ -185,62 +319,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();
- let recipe = resolve_mcp_server_command_recipe(rt)?;
- render_mcp_server_command_recipe(&recipe, |path| {
- if needs_win_paths {
- Ok(rt.convert_to_windows_path(path))
- } else {
- Ok(path.to_string())
- }
- })
+ 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())
+ })
+ .map(str::to_string)
+ .collect::>();
+ entries.extend(CONTEXT.map(str::to_string));
+ entries.join(":")
}
-fn resolve_mcp_server_command_recipe(
+/// 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,
-) -> Result, McpInjectError> {
- rt.server_command_args()
+ 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::, _>>()?,
+ ))
}
fn render_mcp_server_command_recipe(
@@ -265,6 +505,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))
@@ -272,14 +518,20 @@ 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}]"),
]
}
-fn managed_codex_inline_toml_args(server_args: &[String]) -> Vec {
- let mut args = codex_inline_toml_args(server_args);
+fn managed_codex_inline_toml_args(
+ server_command: &str,
+ server_args: &[String],
+) -> Vec {
+ let mut args = codex_inline_toml_command_args(server_command, server_args);
let env_vars = FRESHELL_MCP_CONTEXT_ENV_VARS
.iter()
.map(|name| toml_escape(name))
@@ -303,7 +555,9 @@ pub fn build_managed_codex_mcp_renderings(
is_wsl_env: bool,
tui_target: ProviderTarget,
) -> Result {
- let recipe = resolve_mcp_server_command_recipe(runtime)?;
+ // Resolve the tagged command once so the TUI and sidecar receive the same
+ // immutable recipe and a runtime cannot return mismatched paths per call.
+ let command = runtime.server_command()?;
let sidecar_target = if host_os == HostOs::Windows {
ProviderTarget::Windows
} else {
@@ -311,9 +565,16 @@ pub fn build_managed_codex_mcp_renderings(
};
Ok(ManagedCodexMcpRenderings {
- tui: managed_codex_mcp_injection(&recipe, runtime, env, host_os, is_wsl_env, tui_target)?,
+ tui: managed_codex_mcp_injection(
+ &command,
+ runtime,
+ env,
+ host_os,
+ is_wsl_env,
+ tui_target,
+ )?,
sidecar: managed_codex_mcp_injection(
- &recipe,
+ &command,
runtime,
env,
host_os,
@@ -324,31 +585,62 @@ pub fn build_managed_codex_mcp_renderings(
}
fn managed_codex_mcp_injection(
- recipe: &[McpServerArg],
+ command: &McpServerCommand,
runtime: &dyn McpRuntime,
env: &dyn Env,
host_os: HostOs,
is_wsl_env: bool,
target: ProviderTarget,
) -> Result {
- let needs_wsl_windows_paths = target == ProviderTarget::Windows && is_wsl_env;
- let needs_native_windows_unix_paths =
- host_os == HostOs::Windows && target == ProviderTarget::Unix;
- let server_args = render_mcp_server_command_recipe(recipe, |path| {
- if needs_wsl_windows_paths {
- Ok(runtime.convert_to_windows_path(path))
- } else if needs_native_windows_unix_paths {
+ // A packaged Windows Node remains a Windows process even when a Unix TUI
+ // launches it through WSL. Translate only the executable and carry the
+ // Windows arguments unchanged across that process boundary.
+ if target == ProviderTarget::Unix && host_os == HostOs::Windows {
+ if let McpServerArg::Path(executable) = &command.command {
+ if executable.as_bytes().get(1) == Some(&b':') || executable.starts_with("\\\\") {
+ let mut rendered = vec![
+ format!("WSLENV={}", mcp_wsl_env(runtime.wsl_env())),
+ runtime.convert_to_unix_path(executable)?,
+ ];
+ rendered.extend(command.args.iter().map(|arg| match arg {
+ McpServerArg::Literal(value) | McpServerArg::Path(value) => value.clone(),
+ }));
+ return Ok(McpInjection {
+ args: managed_codex_inline_toml_args("env", &rendered),
+ env: BTreeMap::new(),
+ });
+ }
+ }
+ }
+
+ let server_command = match &command.command {
+ McpServerArg::Literal(value) => value.clone(),
+ McpServerArg::Path(path) => {
+ if host_os == HostOs::Windows && target == ProviderTarget::Unix {
+ convert_windows_path_to_wsl_path(path, env, is_wsl_env).ok_or_else(|| {
+ McpInjectError::new(
+ "Cannot render managed Codex path for a Unix TUI: failed to convert the Windows path to a WSL path.",
+ )
+ })?
+ } else {
+ provider_path(runtime, target, path)?
+ }
+ }
+ };
+ let server_args = render_mcp_server_command_recipe(&command.args, |path| {
+ if host_os == HostOs::Windows && target == ProviderTarget::Unix {
convert_windows_path_to_wsl_path(path, env, is_wsl_env).ok_or_else(|| {
McpInjectError::new(
"Cannot render managed Codex MCP path for a Unix TUI: failed to convert the Windows path to a WSL path.",
)
})
} else {
- Ok(path.to_string())
+ provider_path(runtime, target, path)
}
})?;
+
Ok(McpInjection {
- args: managed_codex_inline_toml_args(&server_args),
+ args: managed_codex_inline_toml_args(&server_command, &server_args),
env: BTreeMap::new(),
})
}
@@ -394,20 +686,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)
}
@@ -586,12 +876,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())
@@ -648,7 +938,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 {
@@ -657,9 +947,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(),
})
}
@@ -677,8 +967,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 00f238a53..58c43c807 100644
--- a/crates/freshell-platform/src/mcp_inject_tests.rs
+++ b/crates/freshell-platform/src/mcp_inject_tests.rs
@@ -75,7 +75,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()),
]
}
@@ -112,7 +112,7 @@ fn managed_codex_renderings_resolve_one_recipe_and_forward_only_names() {
assert_eq!(calls.load(Ordering::SeqCst), 1);
let expected = managed_codex_pairs(
- "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\"]",
);
assert_eq!(renderings.tui.args, expected);
assert_eq!(renderings.sidecar.args, expected);
@@ -153,13 +153,13 @@ fn managed_codex_renderings_use_unc_for_wsl_windows_tui_and_posix_for_sidecar()
assert_eq!(
renderings.tui.args,
managed_codex_pairs(
- "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\"]",
)
);
assert_eq!(
renderings.sidecar.args,
managed_codex_pairs(
- "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\"]",
)
);
}
@@ -170,7 +170,7 @@ fn managed_codex_renderings_use_wsl_paths_for_native_windows_unix_tui() {
let windows_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()),
+ McpServerArg::Path("C:\\repo\\tools\\freshell-mcp\\server.ts".to_string()),
];
let rt = FakeRt::new(scratch.path(), false, windows_args);
let env = MapEnv::new().with("WSL_MOUNT_PREFIX", "/mnt");
@@ -181,22 +181,20 @@ fn managed_codex_renderings_use_wsl_paths_for_native_windows_unix_tui() {
assert_eq!(
renderings.tui.args,
managed_codex_pairs(
- "mcp_servers.freshell.args=[\"--import\", \"/mnt/c/repo/node_modules/tsx/dist/loader.mjs\", \"/mnt/c/repo/server/mcp/server.ts\"]",
+ "mcp_servers.freshell.args=[\"--import\", \"/mnt/c/repo/node_modules/tsx/dist/loader.mjs\", \"/mnt/c/repo/tools/freshell-mcp/server.ts\"]",
)
);
assert_eq!(
renderings.sidecar.args,
managed_codex_pairs(
- "mcp_servers.freshell.args=[\"--import\", \"C:\\\\repo\\\\node_modules\\\\tsx\\\\dist\\\\loader.mjs\", \"C:\\\\repo\\\\server\\\\mcp\\\\server.ts\"]",
+ "mcp_servers.freshell.args=[\"--import\", \"C:\\\\repo\\\\node_modules\\\\tsx\\\\dist\\\\loader.mjs\", \"C:\\\\repo\\\\tools\\\\freshell-mcp\\\\server.ts\"]",
)
);
let unconvertible_rt = FakeRt::new(
scratch.path(),
false,
- vec![McpServerArg::Path(
- "\\\\server\\share\\server.ts".to_string(),
- )],
+ vec![McpServerArg::Path("\\\\server\\share\\server.ts".to_string())],
);
let err = build_managed_codex_mcp_renderings(
&unconvertible_rt,
@@ -212,6 +210,328 @@ fn managed_codex_renderings_use_wsl_paths_for_native_windows_unix_tui() {
);
}
+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");
@@ -227,7 +547,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)]
{
@@ -285,7 +605,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\"]"
);
}
@@ -297,31 +617,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::new(
- scratch.path(),
- false, // native Windows host: isWslEnvironment() is false
- 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()),
- ],
- );
+ let rt = WindowsPackagedRt {
+ tmp: scratch.path().to_path_buf(),
+ 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\"]"
);
}
@@ -398,7 +710,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 7b94f66eb..65b77340c 100644
--- a/crates/freshell-server/src/host_stats.rs
+++ b/crates/freshell-server/src/host_stats.rs
@@ -2617,3 +2617,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