From 3ee096cd76a1a0cfa2bc02502e19d28e91230879 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:19:38 -0700 Subject: [PATCH 01/64] docs: add corrected Node server retirement plan --- .../plans/2026-08-26-retire-node-server-v2.md | 1691 +++++++++++++++++ 1 file changed, 1691 insertions(+) create mode 100644 docs/plans/2026-08-26-retire-node-server-v2.md diff --git a/docs/plans/2026-08-26-retire-node-server-v2.md b/docs/plans/2026-08-26-retire-node-server-v2.md new file mode 100644 index 000000000..b5416faea --- /dev/null +++ b/docs/plans/2026-08-26-retire-node-server-v2.md @@ -0,0 +1,1691 @@ +# Rust-Only Freshell Backend Retirement Plan (v2) + +> **For agentic workers:** Execute this plan in order on +> `.worktrees/retire-node-server-v2`. Use a fresh implementer plus specification +> and quality review after every task. Each task must finish with its focused +> tests green and a focused commit before the next task starts. + +## User Request + +### Requested result +Retire Freshell's legacy Node.js application server so the Rust server is the only supported backend/server path going forward. + +### Explicit constraints +- Use the requested the-usual workflow. +- Work in the fresh isolated `the-usual/retire-node-server-v2` worktree created from updated, green `origin/main`; preserve the first run as a superseded audit record until this replacement plan is validated. +- Treat current Rust server behavior as the compatibility baseline. +- Inventory and triage Node-only server features absent from Rust. If important and not tracked elsewhere, file them as katas. +- Do not carry the prior BrowserPane security-redesign premise into this retirement. +- Node may remain for non-server frontend/build/test tooling, the Electron shell, standalone CLI/MCP clients, and the isolated Claude SDK sidecar; no Node process may remain as Freshell's HTTP/WebSocket/backend server. +- Relocate retained CLI/MCP client source and build artifacts out of the legacy `server/` and `dist/server/` namespaces; do not rewrite them in Rust solely for this retirement. +- Remove or clearly disable current client/CLI/MCP actions that only call Node-only endpoints absent from the Rust baseline; already-tracked future capabilities remain owned by their existing issues. +- Make every supported source, packaged Electron, daemon/service, container, test, and release server path launch `freshell-server` rather than the Node backend. +- Use Red-Green-Refactor TDD and preserve appropriate unit, integration, and end-to-end coverage for retained behavior. +- Keep end-user documentation in `README.md`; update `docs/index.html` only for a major user-facing UI change. +- Commit `.kata.toml` whenever it is modified. +- Do not create or open a PR without explicit user approval, and do not push behavior changes directly to `origin/main`. +- Never restart the live self-hosted Rust server on port 3001 without the user's explicit word `APPROVED`. +- Prefer bash; repository code must use robust structured JSONL logging with severity where logging is needed. + +### Accepted tradeoffs and residuals +- Current Rust server behavior, rather than every legacy Node-only behavior, is the compatibility baseline for retirement. +- Node-only server features absent from Rust are not automatic porting requirements; important untracked features are preserved as katas instead. +- The prior run's BrowserPane security redesign is outside this retirement scope. +- Retained Node CLI/MCP programs are non-server backend clients and may remain after being disentangled from the legacy server build. + +**Goal:** `freshell-server` is the only executable that listens on Freshell's +HTTP/WebSocket port, owns Freshell PTYs, or composes backend state. Browser, +Electron, daemon/service, container, test, and release paths all start that Rust +binary. Node remains only in the explicitly permitted frontend/build/test, +Electron-shell, standalone CLI/MCP-client, and Claude-sidecar roles. + +**Architecture:** Keep the Rust backend unchanged as the product compatibility +baseline. First move neutral TypeScript contracts and the retained HTTP clients +out of `server/`, and make Rust-absent actions truthful without porting them. +Then make every live harness, source command, Electron process plan, installer, +container, and CI/release job Rust-backed. Only after those consumers are green +delete the legacy implementation, its tests, configs, dependencies, and emitted +namespace. Permanent structural and non-vacuity guards prevent a Node backend or +an empty test lane from returning. + +**Tech stack:** Rust 1.96.0 (`freshell-server`, Cargo workspace, Tokio/Axum, +structured `tracing` output), React/Vite/TypeScript, standalone Node 22 CLI and +MCP HTTP clients, Vitest, Playwright, Electron/electron-builder, bash launchers, +Docker, and GitHub Actions. + +## Global Execution Constraints + +- Work only in `/home/dan/code/freshell/.worktrees/retire-node-server-v2` on + `the-usual/retire-node-server-v2`. Preserve + `/home/dan/code/freshell/.worktrees/retire-node-server` and its plan as an + untouched superseded audit record. +- Current Rust behavior is authoritative. Do not port attachments, fresh-agent + exec/diff/send, external editor opening, extension lifecycle/assets, raw TCP + forwarding, WebSocket proxy upgrades, `/api/run`, paged transcript turns, + terminal viewport/paged scrollback, `codingcli.*`, or the incident dump merely + to delete Node. Existing parity issue #624/checklist items retain ownership. +- The separately tracked same-origin BrowserPane concern `freshell#g8d3` is not + modified, related, closed, or redesigned here. Ordinary Rust localhost HTTP + proxying remains the supported BrowserPane behavior. +- Never contact, stop, restart, or health-check port 3001. Every executable test + owns an isolated `HOME`/`FRESHELL_HOME`, token, PID, and OS-assigned or unique + non-3001 loopback port. Lifecycle/restart-storm tests use + `scripts/sandbox-test.sh`; no broad kill pattern is allowed. +- Direct Vitest runs go through `npm run test:vitest -- ...`; broad branch runs + use the shared coordinator. Before a configured Playwright run, obey the + repository rule for an unset `FRESHELL_E2E_BACKEND`. A required spec in + `CLOUD_SKIP_SPECS`, a zero-test filter, or a soft skip is not coverage. +- New Node/Electron/tooling logs are one JSON object per line with `severity`, + `event`, and non-secret context. New Rust logs use the configured structured + `tracing` subscriber. Never log tokens, authorization headers, prompts, + attachment/file bodies, or sidecar payloads. +- No task starts a PR. A branch push is permitted only when needed for the native + artifact workflow; never push to `origin/main`. Do not deploy the result. +- `.kata.toml` is expected to remain byte-identical. If implementation really + changes it, include it in the focused task commit. Normal Kata create/search + operations must not change it. +- `docs/index.html` remains unchanged: the default UI layout is not being + redesigned. User-visible capability and install/runtime statements belong in + `README.md`; contributor/runtime commands belong in `AGENTS.md` and the + Windows Electron build guide. + +## File Responsibility and Interface Map + +- `scripts/retirement/runtime-boundary.ts` owns the permanent classification of + supported runtime paths. It scans active code/config/test/release surfaces, + ignores historical `docs/plans/**` and frozen evidence, and reports sorted + `legacyDebt` and `unexpectedNodeBackend` entries. +- `shared/tab-registry-types.ts` and `shared/freshell-home.ts` own application + contracts formerly imported from the Node backend. + `config/vite/get-network-host.ts` owns Vite's bind-host lookup. + `scripts/testing/repo-context.ts` owns test-coordinator Git and worktree + discovery. +- `tools/freshell-cli/**` is the retained package CLI; `tools/freshell-mcp/**` is + the retained stdio MCP bridge; `tools/node-client-runtime/**` owns common + client config, terminal-key translation, shared error constants, and the + minimal runtime-dependency manifest. These programs are + HTTP clients only: they never listen, own a PTY, import `server/**`, or compose + backend state. `tsconfig.tools.json` emits only `dist/tools/**`. +- `crates/freshell-platform/src/mcp_inject.rs` injects the retained MCP client. + Its production interface accepts the explicit pair `FRESHELL_MCP_NODE` and + `FRESHELL_MCP_ENTRY`; checkout fallback resolves + `dist/tools/freshell-mcp/server.js` or the TypeScript source under `tools/`. +- `src/components/**`, `src/lib/api.ts`, `src/store/freshAgentThunks.ts`, and + `shared/ws-protocol.ts` advertise only current Rust-baseline behavior. A + disabled action never sends a request to a known-missing route. +- `test/e2e-browser/helpers/rust-server.ts`, `external-target.ts`, `fixtures.ts`, + and `playwright.config.ts` own one Rust-backed browser lane. An external target + is read-only and never stopped; an owned target records/reaps its exact PID. +- `scripts/testing/**`, `config/vitest/vitest.config.ts`, and + `config/vitest/vitest.electron.config.ts` own the broad gate: retained Vitest, + the Rust workspace, and Electron. Required lanes reject zero selection and do + not use `--passWithNoTests`. +- `scripts/start-rust-server.ts`, `scripts/launch.sh`, and + `scripts/launch-rust.sh` own source start/serve lifecycle. They launch only + `target/{debug,release}/freshell-server` and preserve exact-PID safety. +- `electron/server-spawner.ts` owns the Electron app-bound Rust child. + `electron/daemon/**` and installer templates own persistent Rust service + registration. Their process contract is `serverBinary`, `clientDir`, + `claudeNodeBinary`, `claudeSidecarEntry`, `mcpNodeBinary`, `mcpEntry`, + `homeDir`, `configDir`, and `logDir`; it has no Node server entry or + `NODE_PATH`. +- `scripts/prepare-electron-runtime.ts` stages the host-native Rust server, built + client, compiled MCP bridge plus its minimal production dependency closure, + and the isolated Claude Node/sidecar runtime. `config/electron-builder.yml` + packages only those staged resources plus Electron assets/installers. +- `docker/cloud-run/**`, `examples/docker/Dockerfile`, `.github/workflows/**`, + and `scripts/verify-electron-artifact.ts` own container/CI/release proof that + the backend artifact is Rust and forbidden Node-server artifacts are absent. +- `README.md` is the end-user truth. `AGENTS.md`, `.env.example`, and + `docs/development/windows-electron-build.md` are active contributor/operator + truth. Historical plans and port evidence remain as provenance. + +## Requirement Trace + +| Requirement | Delivering tasks | Proof | +| --- | --- | --- | +| Rust is the sole backend/server | 1, 4, 6-11 | Runtime guard reaches zero debt; source/browser/Electron/container/release provenance names `freshell-server`; `server/` and `dist/server/` do not exist. | +| CLI/MCP remain standalone Node clients | 2, 7-8, 10 | Sources and output are `tools/**`/`dist/tools/**`; MCP injection and package bin use them; unit and live Rust E2E pass; no client listens or imports backend code. | +| Rust-absent actions are honest | 2-3, 5 | CLI/MCP reject `run`/`fresh-send` without HTTP; browser client makes no missing-route requests; dead REST/WS declarations disappear. | +| Browser uses only Rust | 3-5, 11 | One `chromium` project, Rust fixture provenance, at least 308 tests in at least 86 files, zero legacy project/kind, and configured E2E green. | +| Electron/daemon use packaged Rust | 7-9, 11 | Spawn/template unit tests, app-bound Electron E2E, native artifact inspection, and all-OS CI receipts show the Rust binary and reject Node backend artifacts. | +| Test/build/release proof is non-vacuous | 4, 6, 9, 11 | No `--passWithNoTests`; Cargo workspace is in the broad gate; Tauri smoke fails without a binary; selection/artifact floors and provenance assertions pass. | +| Node-only gaps are triaged, not silently ported | 3, 5, 11 | Final external receipt repeats source/caller/Kata/GitHub/checklist searches; expected result is no important untracked gap; a Kata is filed only on contrary evidence. | +| Safety/docs/process constraints | all, especially 11 | Isolated ports/PIDs, no port-3001 contact, README/active guides updated, `docs/index.html` untouched, `.kata.toml` unchanged or committed. | + +--- + +### Task 1: Establish the Runtime Boundary and Move Neutral TypeScript Owners + +**Files:** + +- Create: `scripts/retirement/runtime-boundary.ts` +- Create: `test/unit/architecture/rust-only-server-runtime.test.ts` +- Create: `shared/tab-registry-types.ts` +- Create: `shared/freshell-home.ts` +- Create: `config/vite/get-network-host.ts` +- Create: `scripts/testing/repo-context.ts` +- Modify: `src/store/tabRegistryTypes.ts` +- Modify: `server/tabs-registry/types.ts` +- Modify: `server/freshell-home.ts` +- Modify: `config/vite/vite.config.ts` +- Modify: `scripts/testing/test-coordinator.ts` +- Modify: `scripts/precheck.ts` +- Modify: `test/unit/vite-config.test.ts` +- Modify: `test/e2e-browser/helpers/session-corpus/session-corpus.test.ts` +- Modify: existing coordinator/precheck/tab-registry tests that import the moved owners + +**Interfaces:** + +- `analyzeRuntimeBoundary(root): Promise<{ legacyDebt: string[]; + unexpectedNodeBackend: string[] }>` returns stable sorted repo-relative + evidence and ignores historical plans, generated output, dependencies, and + frozen screenshots/reports. Its sanctioned Node roles are explicit + entrypoint/module rules, not directory-wide exclusions: backend listeners, + WebSocket servers, Freshell PTY ownership, or imports from `server/**` still + fail when placed under Electron, tools, scripts, or tests. +- `getFreshellHomeDir(env)` and `getFreshellConfigDir(env)` preserve the current + `FRESHELL_HOME`-then-home behavior without relying on the `NodeJS` global type; + the two legacy `server/**` modules are temporary re-exports until Task 10. +- `getNetworkHost({ env, configDir, isWsl })` is dependency-injected and has no + import from `server/**`; Vite's live wrapper supplies process env and WSL + detection. +- `resolveGitRepoRoot`, `resolveGitCheckoutRoot`, and cache reset remain available + to the coordinator from `scripts/testing/repo-context.ts`. +- `scripts/precheck.ts` retains branch confirmation, dependency checks, and port + conflict checks; it drops the duplicate Node-updater invocation because Rust + already owns update behavior. + +- [ ] **Step 1: Write the failing behavioral test** + + Add `rust-only-server-runtime.test.ts` with a synthetic-tree test proving an + invented Node HTTP listener is `unexpectedNodeBackend`, an allowlist test for + Vite/Vitest/Electron-main/CLI/MCP/Claude-sidecar Node roles, and a current-tree + test requiring the known debt entries `server/index.ts`, + `package.json:scripts.start`, `config/electron-builder.yml:dist/server`, and + `test/e2e-browser/playwright.config.ts:legacy-chromium`. Extend the existing + Vite/coordinator/tab-registry tests to import only the new neutral paths. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:e2e:helpers -- test/e2e-browser/helpers/session-corpus/session-corpus.test.ts + ``` + + Expected: FAIL because `scripts/retirement/runtime-boundary.ts` and the neutral + modules do not exist. No test may fail by contacting a server or port 3001. + +- [ ] **Step 3: Add the minimal implementation** + + Implement the scanner and move the neutral code without changing its data + semantics. Replace the coordinator import of `server/coding-cli/utils.ts`, the + Vite import of `server/get-network-host.ts`, and the client import of + `server/tabs-registry/types.ts`. Make `server/freshell-home.ts` and + `server/tabs-registry/types.ts` temporary NodeNext `.js` re-exports from the + neutral owners so the intermediate backend consumes the same contracts. Remove + only the update-check block/import from `scripts/precheck.ts`; preserve its + serve-branch and port protections. Keep a temporary explicit debt list so + later tasks can remove entries one by one. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:e2e:helpers -- test/e2e-browser/helpers/session-corpus/session-corpus.test.ts + ``` + + Expected: PASS; synthetic Node listener rejection bites, sanctioned tooling is + accepted, and the current legacy debt is enumerated rather than hidden. + +- [ ] **Step 5: Refactor while green** + + Deduplicate path normalization/file walking, sort every diagnostic, and extract + pure adapters around process env/filesystem access. Preserve public schema/type + names so client persistence does not migrate. Add a fixture showing that a file + under `docs/plans/**` is ignored while the same text under `scripts/**` is debt, + plus a fake `tools/` or `electron/` Node HTTP listener proving sanctioned + directory names cannot bypass capability detection. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "server/(tabs-registry/types|get-network-host|coding-cli/utils|updater)" src config scripts test/e2e-browser test/unit --glob '!test/unit/server/**' + npm run typecheck:client + npm run test:vitest -- run test/unit/architecture test/unit/vite-config.test.ts test/unit/server/testing test/unit/server/prebuild-guard.test.ts --config config/vitest/vitest.server.config.ts + ``` + + Expected: the search returns no retained consumer of those paths; typecheck and + impacted tests PASS. References confined to legacy implementation/tests remain + eligible for Task 10 deletion. + +- [ ] **Step 7: Commit the task** + + ```bash + git add scripts/retirement test/unit/architecture shared/tab-registry-types.ts shared/freshell-home.ts config/vite/get-network-host.ts scripts/testing/repo-context.ts src/store/tabRegistryTypes.ts server/tabs-registry/types.ts server/freshell-home.ts config/vite/vite.config.ts scripts/testing/test-coordinator.ts scripts/precheck.ts test/unit/vite-config.test.ts test/e2e-browser/helpers/session-corpus + git commit -m "refactor: isolate neutral code from Node server" + ``` + +### Task 2: Relocate and Make Truthful the Standalone CLI and MCP Clients + +**Files:** + +- Create: `tools/freshell-cli/**` from retained `server/cli/**` +- Create: `tools/freshell-mcp/{server.ts,freshell-tool.ts,http-client.ts}` +- Create: `tools/node-client-runtime/{config,keys,codex-restore-contract}.ts` +- Create: `tsconfig.tools.json` +- Move: `test/unit/server/mcp/{freshell-tool,http-client,server}.test.ts` to `test/unit/mcp/` +- Modify: `test/unit/cli/**` +- Delete after replacement: `test/e2e/agent-cli-flow.test.ts` +- Delete after replacement: `test/e2e/agent-cli-screenshot-smoke.test.ts` +- Create: `test/e2e-browser/specs/cli-rust.spec.ts` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `server/agent-api/router.ts` +- Modify: `server/coding-cli/codex-app-server/restore-decision.ts` +- Modify: `server/mcp/config-writer.ts` +- Modify: `test/unit/server/mcp/config-writer.test.ts` +- Modify: `test/unit/server/mcp/config-writer-paths.test.ts` +- Modify: `crates/freshell-platform/src/mcp_inject.rs` +- Modify: `crates/freshell-platform/src/mcp_inject_tests.rs` +- Modify: `crates/freshell-platform/src/cli_launch_goldens.rs` +- Modify: `test/e2e-browser/helpers/mcp-stdio-client.ts` +- Modify: `test/e2e-browser/playwright.config.ts` +- Modify: `test/e2e-browser/specs/mcp-bridge-rust.spec.ts` +- Modify: `test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts` +- Retain until Task 10: `server/mcp/config-writer.ts` as part of the still-pending legacy backend only; it is not copied into `tools/**` + +**Interfaces:** + +- `package.json#bin.freshell` points to `dist/tools/freshell-cli/index.js`. + `build:tools` runs `tsc -p tsconfig.tools.json`; that config uses + NodeNext/NodeNext, `rootDir: "tools"`, `outDir: "dist/tools"`, and includes + only `tools/**/*.ts`. Tool-relative runtime imports carry `.js`; no tool emits + under `dist/server` or requires a compiled `shared/**` tree. +- CLI `run` returns exit code 2 and + `run is unavailable on the Rust backend; create a shell pane and use send-keys` + without an HTTP request. +- MCP `run` and `fresh-send` return structured `{ error, hint }` unsupported + results without an HTTP request; help/description no longer advertise them as + supported. All other current actions retain request paths and output shapes. +- Replace the hard-coded-`node` args-only seam with + `McpServerCommand { command: McpServerArg, args: Vec }` and + `McpRuntime::server_command()`. Every generated Claude/Gemini/Kimi JSON, + Codex TOML pair, and OpenCode command array uses that command field. + `RealMcpRuntime` resolves an explicit `FRESHELL_MCP_NODE` plus + `FRESHELL_MCP_ENTRY` pair first, production (`node` plus) + `dist/tools/freshell-mcp/server.js` second, and dev + `tools/freshell-mcp/server.ts` with the tsx loader third. Supplying only one + explicit variable is an error, not a fallback. +- During the intermediate Tasks 2-9 branch, the legacy backend's + `buildMcpServerCommandArgs` resolves the same `dist/tools`/`tools` entrypoints; + it never points at the deleted `server/mcp/server.ts` source. The whole config + writer disappears with the backend in Task 10. +- Retained Node programs are stdout-disciplined clients: CLI owns stdout UX; MCP + stdout is JSON-RPC only and diagnostics are structured JSONL on stderr. + +- [ ] **Step 1: Write the failing behavioral test** + + Move the MCP/CLI tests to their final paths and add assertions that imports + resolve under `tools/**`, `npm run build:tools` creates both final entrypoints, + `run`/`fresh-send` never invoke the fake HTTP client, `package.json#bin` is + outside `dist/server`, and `mcp_inject` prefers the explicit packaged pair and + rejects a half-configured pair. Change the retained config-writer tests to + require its production/dev injection paths under `dist/tools`/`tools` and no + path under `server/mcp`. Put `// @vitest-environment node` at the top of the + moved MCP tests so the default config runs their filesystem/stdio behavior + under the correct environment. Add `cli-rust.spec.ts` against an owned Rust + server and the compiled `dist/tools/freshell-cli/index.js`; its scenarios cover + health/list/create/mutate tab and pane operations, send/capture/wait, browser + navigation/screenshot, paged session listing/search, and the local unsupported + `run` result. Register it explicitly in the pre-collapse `rust-chromium` + `testMatch`. This replaces the two Express/Node-backend fake E2E files. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/cli test/unit/mcp --config config/vitest/vitest.config.ts + cargo test -p freshell-platform --locked mcp_inject + ``` + + Expected: FAIL because the tool sources/final imports do not exist and current + clients still call `/api/run` and `/api/fresh-agent/send`; the Rust test also + reports the old `server/mcp`/`dist/server/mcp` paths. + +- [ ] **Step 3: Add the minimal implementation** + + Move the CLI and only the stdio/client MCP modules. Extract config-dir, key + translation, and the raw-Codex-resume message to neutral modules; update the + legacy `agent-api/router.ts` and restore-decision module to consume/re-export + those neutral contracts so removing `server/cli/**` does not break the + intermediate branch. Leave `server/mcp/config-writer.ts` solely inside the + legacy backend until Task 10; do not copy it or any backend/provider module + into `tools/**`, but repoint its generated client command to the new tool + entrypoint so the intermediate backend remains buildable. Add the dedicated + tools TypeScript build and update all source/test/package/Rust-injection paths. + Implement deterministic local unsupported results for CLI `run` and MCP + `run`/`fresh-send`; remove their happy-path help and parameter schemas. Keep + `@modelcontextprotocol/sdk` as a production dependency of the retained MCP + program. Convert every Rust injection renderer from the old args-only, + hard-coded `node` contract to `McpServerCommand`, including WSL path conversion + of both the executable and every path argument. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run typecheck:tools + npm run build:tools + npm run build:server + test -f dist/tools/freshell-cli/index.js + test -f dist/tools/freshell-mcp/server.js + npm run test:vitest -- run test/unit/cli test/unit/mcp --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/server/mcp/config-writer.test.ts test/unit/server/mcp/config-writer-paths.test.ts --config config/vitest/vitest.server.config.ts + cargo test -p freshell-platform --locked mcp_inject + ``` + + Expected: PASS; both outputs exist outside `dist/server`, unsupported actions + produce the frozen local errors with zero HTTP calls, and every MCP injection + target points at `tools`/`dist/tools`. + +- [ ] **Step 5: Refactor while green** + + Consolidate CLI/MCP auth URL resolution in `tools/node-client-runtime/config.ts`, + make unsupported-action metadata a read-only table used by validation and help, + and remove duplicated path conversion in `mcp_inject.rs`. Add negative tests + proving neither executable opens a listening socket and MCP stderr remains + valid JSONL without corrupting stdout JSON-RPC. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + rg -n "server/(cli|mcp)|dist/server/(cli|mcp)|/api/(run|fresh-agent/send)" package.json tools crates/freshell-platform test/unit/cli test/unit/mcp test/e2e test/e2e-browser/helpers test/e2e-browser/specs/mcp-*.spec.ts + npm run test:e2e -- --project=rust-chromium test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts + ``` + + Expected: the search finds no old path or live missing-endpoint call (only + unsupported-action test literals are allowed); the configured E2E command runs + a nonzero test count, starts one owned Rust server, executes + `dist/tools/freshell-mcp/server.js`, and PASSes. Neither spec may be cloud-skipped. + +- [ ] **Step 7: Commit the task** + + ```bash + git add tools tsconfig.tools.json package.json package-lock.json crates/freshell-platform test/unit/cli test/unit/mcp test/unit/server/mcp/config-writer.test.ts test/unit/server/mcp/config-writer-paths.test.ts test/e2e test/e2e-browser/helpers/mcp-stdio-client.ts test/e2e-browser/playwright.config.ts test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts server/agent-api/router.ts server/coding-cli/codex-app-server/restore-decision.ts + git add -A server/cli server/mcp + git commit -m "refactor: separate Node clients from legacy server" + ``` + +### Task 3: Remove or Clearly Disable Rust-Absent Browser Actions + +**Files:** + +- Modify: `src/components/panes/BrowserPane.tsx` +- Modify: `src/components/fresh-agent/FreshAgentComposer.tsx` +- Modify: `src/components/fresh-agent/FreshAgentView.tsx` +- Modify: `src/components/fresh-agent/FreshAgentDiffPanel.tsx` +- Modify: `src/components/panes/EditorPane.tsx` +- Modify: `src/components/panes/ExtensionPane.tsx` +- Modify: `src/lib/pane-action-registry.ts` +- Modify: `src/components/context-menu/menu-defs.ts` +- Modify: `test/unit/client/components/panes/BrowserPane.test.tsx` +- Modify: `test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx` +- Modify: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- Modify: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` +- Replace: `test/unit/client/components/panes/EditorPane.openInEditor.test.tsx` with disabled-action assertions +- Modify: `test/unit/client/components/ExtensionPane.test.tsx` +- Modify: `test/unit/client/components/context-menu/menu-defs.test.ts` +- Modify: `test/e2e-browser/playwright.config.ts` +- Create: `test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts` + +**Interfaces:** + +- BrowserPane continues to proxy `http://localhost:` through + `/api/proxy/http//...` and loads ordinary non-loopback URLs directly. + A remote browser targeting `https://localhost` or Freshell's own loopback port + shows `Remote loopback forwarding is unavailable; use a localhost HTTP URL or open the URL on the server host.` It never POSTs/DELETEs `/api/proxy/forward`. +- Attachment selection is not rendered; `!command` shows + `Shell commands are unavailable here; open a shell pane instead` and does not + send or call REST; diff summaries are non-expandable and state that full diff + loading is unavailable. +- External editor/reveal menu actions and callbacks are removed; the embedded + editor's save/preview behavior remains and never calls `/api/files/open`. +- Client/server extension panes render an accessible unsupported-baseline panel + and never call lifecycle/asset endpoints. CLI-category extension behavior is + left unchanged. + +- [ ] **Step 1: Write the failing behavioral test** + + Change the seven focused component/menu tests to require the exact messages and zero + calls to `/api/proxy/forward`, `/api/fresh-agent/attachments`, + `/api/fresh-agent/exec`, `/api/fresh-agent/diff`, `/api/files/open`, and + `/api/extensions/:name/start`. Add one Rust-owned E2E spec with five scenarios: + localhost HTTP still uses the supported Rust proxy; remote HTTPS loopback + renders the baseline message with no raw-forward request; an editor pane's + context menu lacks external-open/reveal while save still works; a + server/client extension pane renders the accessible unsupported panel with no + start/asset request; and a fake-provider fresh-agent pane has no attachment + control, blocks `!command`, and cannot expand a diff without making any of the + three removed fresh-agent requests. Capture all page requests and fail on a + forbidden route. Register this Rust-only spec in the pre-collapse + `rust-chromium` `testMatch` and keep it out of `CLOUD_SKIP_SPECS`. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/client/components/panes/BrowserPane.test.tsx test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/EditorPane.openInEditor.test.tsx test/unit/client/components/ExtensionPane.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts --config config/vitest/vitest.config.ts + ``` + + Expected: FAIL because current components perform at least one listed + Rust-absent request or expose the active control. + +- [ ] **Step 3: Add the minimal implementation** + + Remove BrowserPane forwarding state/retry/cleanup and replace only the + unsupported remote-loopback branch with the explicit outcome. Remove attachment + upload state and file input. Keep `!` detection solely to block with the exact + notice. Render diff filenames/status as text, unregister external editor/reveal + callbacks and their menu entries, and short-circuit unsupported + extension categories before any request/iframe URL is constructed. Do not add + a replacement route or change Rust proxy security. + +- [ ] **Step 4: Run the focused GREEN command** + + Run the Step 2 command again. + + Expected: PASS; every disabled branch is accessible and deterministic, and the + fake API/fetch clients record zero missing-route calls. + +- [ ] **Step 5: Refactor while green** + + Extract a single `RUST_BASELINE_UNAVAILABLE` message map used by controls and + tests, remove dead upload/forward/diff loader types and retry state, and preserve + semantic buttons/`aria-disabled` for controls that remain visible. Keep the + normal localhost HTTP proxy helper independently testable. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "/api/(proxy/forward|fresh-agent/(attachments|exec|diff)|files/open|extensions/.*/start)" src + npm run typecheck:client + npm run lint + npm run test:e2e -- --project=rust-chromium test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts test/e2e-browser/specs/browser-pane.spec.ts + ``` + + Expected: the search returns no production caller; typecheck/lint PASS; the + configured E2E run reports a nonzero test count and PASSes against an owned Rust + server. Neither required spec appears in `CLOUD_SKIP_SPECS`. + +- [ ] **Step 7: Commit the task** + + ```bash + git add src/components src/lib/pane-action-registry.ts test/unit/client/components test/e2e-browser/playwright.config.ts test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts + git commit -m "fix: align browser actions with Rust baseline" + ``` + +### Task 4: Collapse Browser E2E to One Owned Rust Backend + +**Files:** + +- Modify: `test/e2e-browser/helpers/external-target.ts` +- Modify: `test/e2e-browser/helpers/fixtures.ts` +- Modify: `test/e2e-browser/helpers/rust-server.ts` +- Create: `test/e2e-browser/helpers/server-fixture-support.ts` +- Delete: `test/e2e-browser/helpers/test-server.ts` +- Delete: `test/e2e-browser/helpers/test-server.test.ts` +- Create: `test/e2e-browser/helpers/server-fixture-support.test.ts` +- Modify: `test/e2e-browser/helpers/rust-server.test.ts` +- Modify: `test/e2e-browser/playwright.config.ts` +- Delete: `test/e2e-browser/playwright.gate01.config.ts` +- Delete: `test/e2e-browser/gate01-run-slice.sh` +- Delete: `test/e2e-browser/helpers/gate01-collate.ts` +- Delete: `test/e2e-browser/helpers/gate01-collate.test.ts` +- Modify: `test/e2e-browser/playwright.cloud.config.ts` +- Modify: `test/e2e-browser/global-setup.ts` +- Modify: `test/e2e-browser/global-teardown.ts` +- Modify: `test/e2e-browser/perf/run-sample.ts` +- Modify: `test/e2e-browser/helpers/leak-metrics.ts` +- Modify: `test/e2e-browser/vitest.config.ts` +- Modify: `test/setup/e2e-browser-global-setup.ts` +- Modify: `test/e2e-electron/electron-app.test.ts` +- Modify: the closed current set of specs returned by + `rg -l '\be2eServerKind\b' test/e2e-browser/specs | sort`; remove the obsolete + fixture parameter and convert any executable legacy conditional to one + Rust-baseline assertion +- Modify: the closed current set returned by + `rg -l '\bTestServer\b|test-server\.js' test/e2e-browser/specs test/e2e-browser/perf test/e2e-electron | sort`; + direct owned constructors become `RustServer`, while shared types/port/home + helpers import from `server-fixture-support.ts` +- Create: `test/e2e-browser/helpers/selection-nonvacuity.test.ts` + +**Interfaces:** + +- `E2eServerKind` and the `e2eServerKind` fixture option are removed. The fixture + starts an owned `RustServer`; `createE2eServerHandle` returns that owned server + or a non-owned `ExternalServer` when an explicit external URL is configured. +- `server-fixture-support.ts` owns `E2eServerInfo`, ephemeral-port allocation, + isolated-home env construction, and setup-wizard seeding without any process + constructor. `test-server.ts` is deleted only after every direct constructor + and type/helper import in the two closed sets above has moved. +- `playwright.config.ts` exposes one application project named `chromium` with + Rust fixtures. There is no `legacy-chromium`, `rust-chromium`, `MATRIX_SPECS`, + or Node `TestServer`. +- Selection inspection requires at least 308 tests in at least 86 files (the + observed pre-retirement Rust floor), zero legacy projects, and zero required + specs intersecting `CLOUD_SKIP_SPECS`. +- `gate01-baseline.json` remains frozen audit evidence, but its Node/Rust slice + runner, alternate config, collator, and collator test are deleted so there is no + executable path that can regenerate it by launching Node. +- Owned-server readiness proves `/api/server-info` runtime/provenance identifies + `freshell-server`; unauthenticated health alone is insufficient. + +- [ ] **Step 1: Write the failing behavioral test** + + Add `selection-nonvacuity.test.ts` to import local/cloud configs and fixture + factories, asserting the one-project/literal-Rust contract, positive floors, + no legacy helper import (including the visible-first audit runner), no cloud + skip for Tasks 2-4 specs, and a provenance failure when a fake healthy process + reports a non-Rust runtime. Update current helper tests to expect only + `RustServer` construction. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:e2e:helpers + npm exec playwright -- test --config test/e2e-browser/playwright.config.ts --project=chromium --list + ``` + + Expected: FAIL because the default fixture is `legacy`, the `chromium` project + is not yet Rust-explicit, and legacy projects/helpers still exist. The list + command must not start a server. + +- [ ] **Step 3: Add the minimal implementation** + + Make Rust the only owned constructor, retain the external-target no-stop seam, + and move/rename shared types out of `test-server.ts`. Collapse Playwright to one + `chromium` project; convert conditional Rust branches to unconditional current + baseline assertions and delete legacy-only expectations/spec registrations. + Build `dist/client` and `target/release/freshell-server` in global setup. Point + Electron remote-connect E2E and `perf:audit:visible-first`'s owned sample server + at `RustServer`. Delete the completed GATE-01 executable/collator while retaining + its JSON as frozen historical evidence; update helper-config, teardown, and leak + comments/types to the new fixture names. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:e2e:helpers + npm exec playwright -- test --config test/e2e-browser/playwright.config.ts --project=chromium --list + ``` + + Expected: PASS; output names only `[chromium]`, reports at least 308 tests in at + least 86 files, and contains no `legacy-chromium` or zero-test warning. + +- [ ] **Step 5: Refactor while green** + + Rename matrix descriptions/comments to Rust-baseline language, deduplicate + owned/external server info types, and centralize exact-child stop/restart logic + in the Rust fixture. Preserve external-target non-ownership and add a test that + `stop()` never signals an external PID. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "legacy-chromium|e2eServerKind|TestServer|test-server\.js|dist/server/index" test/e2e-browser test/e2e-electron --glob '!gate01-baseline.json' + npm run test:e2e -- --project=chromium test/e2e-browser/specs/auth.spec.ts test/e2e-browser/specs/terminal-lifecycle.spec.ts test/e2e-browser/specs/server-restart-recovery.spec.ts test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts + ``` + + Expected: search returns no executable legacy path; configured E2E reports a + positive count and PASSes, and server-info provenance in every worker identifies + the owned Rust binary on a non-3001 port. + +- [ ] **Step 7: Commit the task** + + ```bash + git add test/e2e-browser test/setup/e2e-browser-global-setup.ts test/e2e-electron/electron-app.test.ts + git commit -m "test: make browser coverage Rust-only" + ``` + +### Task 5: Retire Dead Contracts and Rebase Active Port Oracles on Rust + +**Files:** + +- Modify: `shared/ws-protocol.ts` +- Modify: `crates/freshell-protocol/src/{client_messages,server_messages,common}.rs` +- Modify: `crates/freshell-protocol/tests/roundtrip.rs` +- Modify: `crates/freshell-ws/src/{terminal,reconcile}.rs` +- Modify: `crates/freshell-ws/tests/live_session_ref_guard.rs` +- Modify: `src/lib/api.ts` +- Delete: `src/store/freshAgentThunks.ts` +- Modify: `test/unit/client/lib/api.test.ts` +- Modify: `test/unit/client/lib/fresh-agent-ws.test.ts` +- Delete: `test/helpers/visible-first/protocol-harness.ts` +- Delete: `test/helpers/visible-first/read-model-route-harness.ts` +- Delete: `test/helpers/visible-first/terminal-mirror-fixture.ts` +- Delete: `test/unit/visible-first/protocol-harness.test.ts` +- Delete: `test/unit/visible-first/read-model-route-harness.test.ts` +- Delete: `test/unit/visible-first/terminal-mirror-fixture.test.ts` +- Modify: `test/unit/visible-first/acceptance-contract.test.ts` +- Modify: `port/contract/ws-message-inventory.json` +- Regenerate: `port/contract/ws-protocol.schema.json` +- Regenerate: `port/contract/ws-server-messages.schema.json` +- Delete: `port/contract/generate-manifest-oracle.ts` +- Modify: `port/contract/README.md` +- Modify: `crates/freshell-extensions/Cargo.toml` +- Modify: `crates/freshell-extensions/src/lib.rs` +- Modify: `crates/freshell-extensions/tests/oracle.rs` +- Delete: `port/oracle/baselines/batch/generate-batch-goldens.ts` +- Modify: `crates/freshell-terminal/tests/batch_wire_golden.rs` +- Modify: `port/oracle/harness/external-server.ts` +- Modify: `port/oracle/harness/normalize.ts` +- Modify: `port/oracle/harness/invariants.ts` +- Modify: `port/oracle/harness/t2-live.ts` +- Modify: `port/oracle/harness/t2-live-claude.ts` +- Modify: `port/oracle/harness/t2-live-codex.ts` +- Delete: `port/oracle/harness/opencode-warm-proxy.ts` +- Create: `test/unit/port/oracle/rust-only-oracle-boundary.test.ts` +- Modify: `test/unit/port/oracle/{external-handshake-t0,t0-equivalence-rust,t1-equivalence-rust,t1-batch-equivalence-rust,freshagent-wireshape-differential}.test.ts` +- Move: `test/unit/port/oracle/t2-opencode-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-opencode-rust-baseline.test.ts` +- Move: `test/unit/port/oracle/t2-claude-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-claude-rust-baseline.test.ts` +- Move: `test/unit/port/oracle/t2-codex-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-codex-rust-baseline.test.ts` +- Delete: `test/integration/port/oracle/{t2-claude-haiku,t2-codex-gptmini,t2-opencode-kimi}.test.ts` +- Modify: `config/vitest/vitest.oracle.config.ts` +- Delete: `config/vitest/vitest.oracle-t2.config.ts` +- Modify: `package.json` + +**Interfaces:** + +- `codingcli.create/input/kill` and `codingcli.created/event/exit/stderr/killed` + are absent from TS/Rust schemas, handlers, inventories, and generated schemas. +- `api.ts` no longer exports terminal viewport/paged-scrollback or paged + fresh-agent-turn helpers; no production caller exists. Whole-thread snapshots, + WS terminal replay, and terminal search remain. +- Client WS tests construct normalized Rust-baseline provider event frames + directly; they do not import Node SDK/OpenCode adapter implementations merely + to make test input. +- Oracle target selection is Rust-only. T0 asserts Rust schema conformance and + two-boot determinism; T1 asserts Rust bytes against committed goldens and keeps + mutation tests that prove comparisons bite; wire-shape checks compare current + Rust to a committed normalized Rust fixture with at least one captured frame. +- The gated T2 provider contracts have no `target`/warm-proxy switch and always + start an owned Rust server. They assert fatal lifecycle/persistence invariants, + positive event counts, request ceilings, isolated writes, and exact-child + teardown; they do not compare with or read the historical original-side T2 + JSON files. They prove ownership from their own PID ledger and never inspect, + connect to, or make assertions about a listener on port 3001. +- Historical reports/baselines stay untouched as provenance, but no active oracle + command can build or launch Node. +- `crates/freshell-extensions/fixtures/manifest-oracle.json` remains a frozen + migration artifact consumed by Rust mutation tests; its Node schema generator + and active regeneration claim are removed from the contract README, crate + metadata/docs, and oracle test. +- `port/oracle/baselines/batch/*.json` likewise remain frozen byte goldens for + `batch_wire_golden.rs`; the Node terminal-stream generator is deleted and the + Rust test's mutation assertion keeps the fixture non-vacuous. + +- [ ] **Step 1: Write the failing behavioral test** + + Tighten protocol/API tests to assert the dead discriminators and exports are + rejected/absent. Change T0/T1/wire-shape tests to request only an owned Rust + target, require a nonempty capture, compare two Rust boots or committed Rust + fixtures, and prove a one-field/one-byte mutation fails the comparator. Change + the visible-first acceptance test to require its focused lane to omit the + Node-backed protocol harness while retaining the static contract and report + tests. Tighten the Rust extension fixture test to require a nonempty fixture and + prove a changed expected verdict fails, without importing the deleted Node + manifest generator. Rewrite the client fresh-agent WS cases to feed literal + normalized Rust-baseline frames instead of importing Node provider adapters. + Add `rust-only-oracle-boundary.test.ts` as an always-running source/exports + guard: it rejects a `node` target, warm-proxy module, legacy build command, or + active read of `port/oracle/baselines/t2/*.json` even when live-provider gates + are off. It also rejects `listenersOn3001`/`ss`-based inspection; an assertion + that an allocated owned port is not 3001 remains allowed. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/client/lib/api.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/visible-first/acceptance-contract.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/port --config config/vitest/vitest.port.config.ts + cargo test -p freshell-protocol -p freshell-ws -p freshell-terminal -p freshell-extensions --locked + env -u FRESHELL_RUN_REAL_PROVIDER_CONTRACTS npm run test:oracle + ``` + + Expected: FAIL because dead messages/helpers still parse/export and the oracle + still constructs a Node target or compares against a live original. + +- [ ] **Step 3: Add the minimal implementation** + + Delete the caller-free client helpers/thunks and the full `codingcli.*` family + in both languages, regenerate the committed schemas/inventory, and remove the + Rust no-op/guard handlers. Delete the three self-testing visible-first harnesses + that instantiate Node `WsHandler`, Express routes for removed endpoints, or the + Node terminal replay ring; update `test:visible-first:contract` to select only + `acceptance-contract.test.ts` and `visible-first-acceptance-report.test.ts` + through `npm run test:vitest -- run ... --config + config/vitest/vitest.config.ts`. + Make the external oracle harness wrap the existing owned Rust fixture, delete + Node build/spawn/copy logic and original-side live generators, and reframe + current tests around Rust determinism plus committed goldens/fixtures. Preserve + mutation tests and nonempty-capture assertions. Delete the Node extension + manifest generator and document its committed output as frozen migration + provenance rather than an active regeneration workflow. Delete the Node batch + generator too and update the consuming Rust golden test's provenance comment; + keep its byte-mutation bite proof. Collapse each T2 harness to Rust-only owned + startup, delete the OpenCode warm proxy, rename the three gated tests to + Rust-baseline files, and replace original-fixture equality with invariant, + positive-event, isolation, cost-ceiling, and cleanup assertions. Keep the old + T2 JSON only as unreferenced historical evidence. Delete the original-side T2 + integration files, their dedicated config, and `test:oracle:t2`; the retained + Rust T2 contracts remain opt-in under `test:oracle`. Remove their snapshots of + port-3001 listeners; exact owned-PID teardown is the safety proof. + +- [ ] **Step 4: Run the focused GREEN command** + + Run the Step 2 commands again. + + Expected: PASS; schema generation has no drift, the Rust crates reject removed + messages, client exports are gone, and every active oracle starts/reaps only an + owned Rust process on a non-3001 port. + +- [ ] **Step 5: Refactor while green** + + Rename `equivalence` descriptions to `Rust baseline` or `determinism`, extract a + single Rust oracle boot helper, and retain the smallest committed fixtures that + exercise each comparator. Do not rewrite historical Markdown/PNG/JSON evidence + merely for mentioning the original server. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "codingcli\.|getTerminalViewport|getTerminalScrollback|loadFreshAgent(ThreadTurns|TurnBody)" shared src crates/freshell-protocol crates/freshell-ws port/contract test/unit/port + ! rg -n "target: ['\"]node|FRESHELL_ORACLE_TARGET|build:server|dist/server/index|new TestServer|warmProxy|opencode-warm-proxy|baselines/t2" port/oracle/harness config/vitest/vitest.oracle.config.ts test/unit/port/oracle package.json --glob '!rust-only-oracle-boundary.test.ts' + ! rg -n "listenersOn3001|ss .*3001|grep.*3001" port/oracle/harness test/unit/port/oracle --glob '!rust-only-oracle-boundary.test.ts' + ! rg -n "server/extension-manifest|generate-manifest-oracle" port/contract package.json + ! rg -n "server/terminal-stream|generate-batch-goldens" port/oracle/baselines/batch crates/freshell-terminal/tests/batch_wire_golden.rs + contract_hash_before="$(sha256sum port/contract/ws-message-inventory.json port/contract/ws-protocol.schema.json port/contract/ws-server-messages.schema.json)" + npm run contract:generate + contract_hash_after="$(sha256sum port/contract/ws-message-inventory.json port/contract/ws-protocol.schema.json port/contract/ws-server-messages.schema.json)" + test "$contract_hash_before" = "$contract_hash_after" + ``` + + Expected: all searches return no active match; generation completes and the + generated files are already up to date. + +- [ ] **Step 7: Commit the task** + + ```bash + git add shared/ws-protocol.ts crates/freshell-protocol crates/freshell-ws crates/freshell-terminal/tests/batch_wire_golden.rs crates/freshell-extensions/Cargo.toml crates/freshell-extensions/src/lib.rs crates/freshell-extensions/tests/oracle.rs src/lib/api.ts src/store/freshAgentThunks.ts test/unit/client/lib/api.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/helpers/visible-first test/unit/visible-first port/contract port/oracle/baselines/batch port/oracle/harness test/unit/port/oracle test/integration/port/oracle config/vitest/vitest.oracle.config.ts config/vitest/vitest.oracle-t2.config.ts package.json + git commit -m "refactor: retire Node-only contracts and oracles" + ``` + +### Task 6: Make Source Build, Start, and Broad Tests Rust-First and Non-Vacuous + +**Files:** + +- Create: `scripts/start-rust-server.ts` +- Create: `scripts/testing/run-rust-tests.ts` +- Create: `test/unit/tooling/testing/test-selection.test.ts` +- Create: `test/integration/tooling/source-runtime-rust.test.ts` +- Modify: `package.json` +- Modify: `scripts/launch.sh` +- Modify: `scripts/launch-rust.sh` +- Modify: `scripts/run-standard-tests.ts` +- Modify: `scripts/testing/coordinator-command-matrix.ts` +- Modify: `scripts/testing/test-coordinator.ts` +- Modify: `scripts/vitest-cloud.sh` +- Modify: `scripts/test/cloud-vitest-wrapper.test.sh` +- Modify: `docker/cloud-run/entrypoint.sh` +- Modify: `config/vitest/vitest.config.ts` +- Modify: `test/unit/vite-config.test.ts` +- Delete: `config/vitest/vitest.server.config.ts` +- Delete: `config/vitest/vitest.codex-real-provider-smoke.config.ts` +- Delete: `config/vitest/vitest.opencode-serve-real-provider-smoke.config.ts` +- Delete: `test/setup/server-global-setup.ts` +- Delete: `tsconfig.server.json` +- Delete: `test/integration/real/codex-app-server-fork-shape-contract.test.ts` +- Delete: `test/integration/real/codex-app-server-readiness-contract.test.ts` +- Delete: `test/integration/real/codex-remote-fork-contract.test.ts` +- Delete: `test/integration/real/coding-cli-session-contract.test.ts` +- Delete: `test/helpers/coding-cli/real-session-contract-harness.ts` +- Delete: `test/integration/extension-system.test.ts` +- Move: retained files from `test/unit/server/claude-sidecar/**` to `test/unit/claude-sidecar/**` +- Move: retained coordinator/global-setup tests from `test/unit/server/testing/**` to `test/unit/tooling/testing/**` +- Move: `test/unit/server/deploy-tab-diff-coverage-gate.test.ts` to `test/unit/tooling/deploy-tab-diff-coverage-gate.test.ts` +- Move: `test/unit/server/prebuild-guard.test.ts` to `test/unit/tooling/prebuild-guard.test.ts` +- Move: `test/unit/server/run-standard-tests.test.ts` to `test/unit/tooling/run-standard-tests.test.ts` +- Move: `test/unit/server/opencode-rebind-plugin.test.ts` to `test/unit/extensions/opencode-rebind-plugin.test.ts` +- Move: `test/unit/server/rust-claude-snapshot-contract.test.ts` to `test/unit/contracts/rust-claude-snapshot-contract.test.ts` +- Move: `test/unit/server/amplifier-cli-isolation.test.ts` to `test/unit/provider-fixtures/amplifier-cli-isolation.test.ts` +- Modify: `crates/freshell-tauri/tests/server_spawn_smoke.rs` +- Modify: `.github/workflows/rust-clippy.yml` + +**Interfaces:** + +- `dev:server` runs `cargo run -p freshell-server --locked`; `dev` runs Vite plus + that Rust server. `build:rust`, `check:rust`, and `test:rust` are explicit. + `build` produces client, tools, and release `freshell-server`; `start` executes + the release Rust binary through the cross-platform signal-forwarding script. +- `scripts/launch.sh` is a compatibility forwarder to the safe Rust launcher; + `launch-rust.sh` remains canonical and exact-PID verified. +- Broad `npm test`/`npm run check`/`npm run verify` cover retained default Vitest, + `cargo test --workspace --locked`, and Electron Vitest under one coordinator + gate. `test:server` runs the `freshell-server` crate; `test:integration` runs + `cargo test --workspace --tests --locked`; `test:unit` remains default + `test/unit`. +- No required runner uses `--passWithNoTests`. Cloud Vitest runs only the retained + default config; `--config=server` is rejected with exit 2 and a Rust-lane hint. + Cargo runs in the Rust lane. +- The default config stops excluding + `test/unit/visible-first/cli-command-harness.test.ts` and its selection is + asserted. Its two obsolete Node route/mirror siblings were deleted in Task 5. +- Tauri `server_spawn_smoke` hard-fails when the explicit/sibling Rust binary is + absent; `run-rust-tests.ts` builds it and sets `FRESHELL_SERVER_BIN` before the + workspace tests. +- `source-runtime-rust.test.ts` spawns `npm start` on an OS-assigned non-3001 + port with an isolated `FRESHELL_HOME`, explicit test-only `AUTH_TOKEN`, and + absolute built-client path, then requires the SPA response and authenticated + server-info provenance to identify the exact release + `freshell-server` child before exact-PID teardown. It uses + `// @vitest-environment node` because it owns a child process and filesystem + fixture. + +- [ ] **Step 1: Write the failing behavioral test** + + Add `test-selection.test.ts` and update coordinator/runner tests to require + client+Rust+Electron broad phases, the script meanings above, absence of the + server/real-provider Vitest configs and `--passWithNoTests`, removal of their + now-invalid package scripts, and rejection of a simulated zero selected-test + result. Require the retained visible-first CLI harness to be selected by the + default lane. Add the owned source-runtime integration test described above. Change + the Tauri smoke unit path to panic, not print SKIP, when no binary can be + resolved. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run build:client + npm run build:server + npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling/run-standard-tests.test.ts test/unit/vite-config.test.ts test/integration/tooling/source-runtime-rust.test.ts --config config/vitest/vitest.config.ts + bash scripts/test/cloud-vitest-wrapper.test.sh + cargo test -p freshell-tauri --locked --test server_spawn_smoke app_bound_spawn_health_reap_end_to_end -- --exact --nocapture + ``` + + Expected: FAIL because current plans are client+server+Electron Vitest, use + `--passWithNoTests`, and the Tauri smoke can soft-skip. + +- [ ] **Step 3: Add the minimal implementation** + + Move retained non-server tests before removing exclusions/config. Implement the + Rust phases and source scripts, delete server TypeScript build/typecheck/start + scripts/config/global setup, and make cloud Vitest one truthful default-config + lane. Delete the four opt-in provider contracts and PTY harness that import the + legacy Codex/Claude/OpenCode runtime; they test external-provider or Node + implementation behavior, not Freshell's retained Rust backend. Delete the two + dedicated Node-backend real-provider configs/scripts; keep the two independent + Amplifier contracts excluded and opt-in. The start wrapper resolves `.exe` on + Windows, forwards argv/signals, + inherits stdio, emits structured JSONL only for wrapper errors, and never + backgrounds or kills an unowned PID. Add the explicit build+env Tauri test + wrapper and matching CI step. Delete the Node-only extension-system integration + from the default lane; current Rust extension crate/browser coverage is the + baseline. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run build:client + npm run build:tools + cargo build --release -p freshell-server --locked + npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling test/unit/claude-sidecar test/unit/contracts test/unit/provider-fixtures test/unit/visible-first/cli-command-harness.test.ts test/unit/vite-config.test.ts test/integration/tooling/source-runtime-rust.test.ts --config config/vitest/vitest.config.ts + bash scripts/test/cloud-vitest-wrapper.test.sh + cargo build -p freshell-server --locked + FRESHELL_SERVER_BIN="$PWD/target/debug/freshell-server" cargo test -p freshell-tauri --locked --test server_spawn_smoke app_bound_spawn_health_reap_end_to_end -- --exact --nocapture + ``` + + Expected: PASS; the Tauri smoke starts/reaps the exact binary on an ephemeral + port, and no required test selector is empty. + +- [ ] **Step 5: Refactor while green** + + Extract typed phase builders for `vitest|cargo|npm`, centralize structured + child-process logging, and keep Cargo argument routing separate from Vitest file + filters. Remove old `client|server|electron` naming from status receipts and + make zero-selection errors include the requested selectors and selected phase. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "vitest\.(server|codex-real-provider-smoke|opencode-serve-real-provider-smoke)|server-global-setup|tsconfig\.server|tsx watch server|dist/server/index|--passWithNoTests|test:real:coding-cli-contracts|test:codex-real-provider-smoke|test:opencode-serve-smoke" package.json config scripts docker/cloud-run test/setup test/unit/tooling .github/workflows/rust-clippy.yml + test ! -f tsconfig.server.json + test ! -f config/vitest/vitest.server.config.ts + test ! -f test/setup/server-global-setup.ts + npm run typecheck + FRESHELL_TEST_SUMMARY="retire Node server: Rust broad gate" npm test + ``` + + Expected: search returns no match; absence checks succeed; typecheck and the + coordinated broad test PASS with nonzero retained Vitest, Rust workspace, and + Electron phase counts. + +- [ ] **Step 7: Commit the task** + + ```bash + git add -A package.json package-lock.json scripts config/vitest test/setup test/unit test/integration test/helpers crates/freshell-tauri .github/workflows/rust-clippy.yml tsconfig.server.json docker/cloud-run/entrypoint.sh + git commit -m "build: make Rust the default server and test lane" + ``` + +### Task 7: Cut Electron App-Bound and Daemon Lifecycles Over to Rust + +**Files:** + +- Modify: `electron/server-spawner.ts` +- Modify: `electron/startup.ts` +- Modify: `electron/entry.ts` +- Modify: `electron/daemon/daemon-manager.ts` +- Create: `electron/daemon/runtime-env.ts` +- Modify: `electron/daemon/{systemd,launchd,windows-service}.ts` +- Modify: `installers/systemd/freshell.service.template` +- Modify: `installers/launchd/com.freshell.server.plist.template` +- Modify: `installers/windows/freshell-task.xml.template` +- Modify: `test/unit/electron/server-spawner.test.ts` +- Modify: `test/unit/electron/startup.test.ts` +- Modify: `test/unit/electron/daemon/**` +- Create: `test/unit/electron/daemon/runtime-env.test.ts` +- Create: `test/fixtures/electron/daemon-env-edge-cases.env` +- Create: `crates/freshell-server/tests/electron_daemon_env.rs` +- Create: `test/e2e-electron/app-bound-rust-server.test.ts` +- Create: `test/e2e-electron/daemon-rust-command.test.ts` + +**Interfaces:** + +- `ServerSpawnResources` and `DaemonPaths` contain `serverBinary`, `clientDir`, + `claudeNodeBinary`, `claudeSidecarEntry`, `mcpNodeBinary`, `mcpEntry`, + `homeDir`, `configDir`, and `logDir`. No `nodeBinary`, `serverEntry`, native + modules, server modules, or `NODE_PATH` exists. Startup derives `homeDir` as + the parent of its existing absolute `configDir` and rejects a config directory + whose basename is not `.freshell`; `logDir` is `configDir/logs`. +- App-bound spawn env sets `PORT`, `FRESHELL_HOME`, `FRESHELL_CLIENT_DIR`, + `FRESHELL_CLAUDE_NODE`, `FRESHELL_CLAUDE_SIDECAR`, `FRESHELL_MCP_NODE`, and + `FRESHELL_MCP_ENTRY`; `FRESHELL_HOME` is exactly `homeDir`. The child/service + working directory is exactly `configDir`, so the Rust binary loads + `AUTH_TOKEN` from the existing config-directory `.env`; token values are never + logged. +- Daemon install atomically patches those same seven non-secret runtime keys into + `configDir/.env` with temp-file + rename while byte-preserving `AUTH_TOKEN` and + unrelated assignments/comments. This lets all three service templates execute + the Rust binary directly with `configDir` as cwd; no Windows `cmd.exe`, inline + shell env, or secret-bearing service definition remains. Parser/serializer + fixtures include CRLF, comments, quoted spaces, `#`, `=`, backslashes, and a + Rust `dotenvy` round trip. +- Dev uses `target/debug/freshell-server`; packaged mode uses + `resources/bin/freshell-server[.exe]`. The health gate also verifies + authenticated server-info provenance before declaring readiness. +- Service templates execute the Rust binary directly. Windows status/stop + identifies `freshell-server.exe` at the exact configured path; Unix managers + retain exact service/label ownership. + +- [ ] **Step 1: Write the failing behavioral test** + + Change spawner/startup/daemon/template tests to assert the exact Rust command + and env above and to reject `server/index.js`, `NODE_PATH`, `node-pty`, and + `server-node-modules`. Add runtime-env tests that prove atomic merge, + preservation, Windows-path serialization, and Rust `dotenvy` parity. Add + app-bound E2E that launches Electron with staged + Rust/MCP/Claude fixtures, authenticates, verifies server-info runtime/commit, + exits the app, and proves the exact Rust child is gone while foreign processes + are untouched. Add a native daemon-command E2E that renders the platform's + service definition from packaged resource paths, executes that rendered Rust + command directly in a disposable home, verifies server-info provenance, and + reaps the exact child; it does not register a persistent host service. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:electron -- test/unit/electron/server-spawner.test.ts test/unit/electron/startup.test.ts test/unit/electron/daemon + cargo test -p freshell-server --locked --test electron_daemon_env + ``` + + Expected: FAIL because Electron currently plans bundled Node plus + `resources/server/index.js` and templates set `NODE_PATH`. + +- [ ] **Step 3: Add the minimal implementation** + + Replace the spawn/resource types atomically, invoke the Rust binary with no + server script argument, set only the explicit Rust/client/MCP/Claude env, and + retain cwd, log piping, health timeout, double-start handling, graceful + exact-child stop, and structured errors. Implement the atomic daemon `.env` + merger, then rewrite all three service templates and managers to direct Rust + execution, config-directory cwd, and exact binary identity. Dev startup + requires the Task 6 debug build; it never falls back to tsx/Node backend. + +- [ ] **Step 4: Run the focused GREEN command** + + Run the Step 2 command again. + + Expected: PASS; every captured command begins with `freshell-server[.exe]`, + required env paths are absolute, and forbidden Node-server fields are absent. + +- [ ] **Step 5: Refactor while green** + + Extract one `resolveDesktopRuntimeResources(resourcesPath, platform, isDev)` + pure function consumed by app-bound and daemon paths. Deduplicate template env + construction and process-identity checks. Preserve Windows escaping and Unix + path-with-spaces tests, and ensure all new lifecycle logs are redacted JSONL. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "server/index|NODE_PATH|server-node-modules|nativeModules|nodeBinary|serverEntry" electron installers + cargo build -p freshell-server --locked + npm run build:electron + npm run test:e2e:electron -- test/e2e-electron/app-bound-rust-server.test.ts test/e2e-electron/daemon-rust-command.test.ts + ``` + + Expected: search returns no active match; Electron build and E2E PASS; the E2E + receipt identifies a non-3001 owned `freshell-server` PID and zero orphaned + children. + +- [ ] **Step 7: Commit the task** + + ```bash + git add electron installers test/unit/electron test/fixtures/electron/daemon-env-edge-cases.env test/e2e-electron crates/freshell-server/tests/electron_daemon_env.rs + git commit -m "feat: run Electron and daemon backends in Rust" + ``` + +### Task 8: Package the Rust Server and Only Sanctioned Node Runtimes in Electron + +**Files:** + +- Create: `scripts/prepare-electron-runtime.ts` +- Create: `scripts/verify-electron-artifact.ts` +- Create: `test/unit/electron/prepare-electron-runtime.test.ts` +- Create: `test/unit/electron/verify-electron-artifact.test.ts` +- Modify: `scripts/prepare-bundled-node.ts` by extracting reusable Node-download code, then delete it +- Modify: `scripts/bundled-node-version.json` +- Modify: `scripts/assert-native-windows-build.ts` +- Modify: `config/electron-builder.yml` +- Modify: `package.json` +- Modify: `package-lock.json` +- Delete after migration: generated/staging assumptions for `server-node-modules` and `bundled-node/native-modules` +- Delete/replace: `test/unit/electron/prepare-bundled-node.test.ts` + +**Interfaces:** + +- `prepare-electron-runtime --platform --arch ` + stages `electron-runtime/bin/freshell-server[.exe]`, `dist/client`, + `electron-runtime/node/bin/node[.exe]`, + `electron-runtime/claude-sidecar/**`, and + `electron-runtime/mcp/**`. The MCP directory contains + `dist/tools/freshell-mcp`, shared compiled client modules, and only the locked + production dependency closure for `@modelcontextprotocol/sdk` and `zod`. +- The Node binary is sanctioned for the Claude sidecar and standalone MCP client + only. Staging contains no `node-pty`, Freshell Node backend entrypoint, + `dist/server`, `server-node-modules`, or native-module rebuild output. The MCP + SDK's locked closure may include dormant HTTP-framework libraries such as + Express; structural and execution tests prove that the stdio MCP entrypoint + never listens or becomes Freshell's backend. +- Node archive extraction invokes the host's standard `tar` executable with an + argv array. The staging script probes the required archive format first + (`.tar.xz` on Unix hosts and `.zip` via Windows' bsdtar-compatible `tar`) and + fails with a clear structured diagnostic if that host capability is absent; + it checks extraction exit status and emits redacted JSONL failure context. + Root `extract-zip` and `tar` packages are not retained. +- `verify-electron-artifact(path, platform)` fails unless the native Rust binary, + client index, MCP entry/dependencies, Claude entry/dependencies, and Node runtime + exist; it fails on any forbidden artifact or if the Rust binary cannot be + executed on the native host. Its bounded execution probe uses an empty temporary + cwd, removes `AUTH_TOKEN`, `.env` discovery, and inherited Freshell config env, + and requires exit code 1 plus + `AUTH_TOKEN is required. Refusing to start without authentication.` before any + listen event; it never starts a listening service. Foreign-platform + artifacts receive structural format checks locally and the native CI matrix + performs the execution probe. +- `electron:build`/`:win` build the host-native Rust server and tools, stage the + runtime, package, and verify the unpacked artifact before installers upload. + +- [ ] **Step 1: Write the failing behavioral test** + + Add staging and artifact tests with an injected binary-probe runner and temporary + fake resource tree. Require the exact allowlist, assert each forbidden name + fails verification, and assert the probe runs in an empty cwd with auth/config + env removed and a deadline. Change the Windows platform check message to require + native Rust `.exe` production, not native `node-pty` compilation. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:electron -- test/unit/electron/prepare-electron-runtime.test.ts test/unit/electron/verify-electron-artifact.test.ts test/unit/electron/native-windows-build-script.test.ts + ``` + + Expected: FAIL because staging/verifier modules do not exist and builder config + still requires Node-server/native-module resources. + +- [ ] **Step 3: Add the minimal implementation** + + Refactor the verified Node download to the new staging script, delete header and + `node-pty` rebuild/pruned-server-dependency logic, copy the host-native Cargo + binary, build/copy `dist/tools`, and stage the two permitted Node consumers with + their locked dependency closures. Replace the npm archive libraries with the + host `tar` argv contract above. Rewrite electron-builder resources and npm + Electron scripts to use the staging directory and invoke the verifier on the + unpacked result. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:electron -- test/unit/electron/prepare-electron-runtime.test.ts test/unit/electron/verify-electron-artifact.test.ts test/unit/electron/native-windows-build-script.test.ts + npm run build:client + npm run build:tools + cargo build --release -p freshell-server --locked + npm run prepare:electron-runtime + ``` + + Expected: PASS; staging contains every allowlisted resource and none of the + forbidden Node-server/native-module paths. + +- [ ] **Step 5: Refactor while green** + + Split pure layout planning, dependency-closure calculation, and filesystem copy + execution. Add stable sorted JSONL receipts with file hashes and `severity` but + no tokens. Make the verifier share the same declarative allowlist without + allowing the producer to suppress forbidden-file checks. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "dist/server|server-node-modules|node-pty|native-modules|prepare-bundled-node" config/electron-builder.yml scripts package.json --glob '!verify-electron-artifact.ts' --glob '!prepare-electron-runtime.ts' + npm run electron:build + npm run verify:electron-artifact + ``` + + Expected: search returns no match; the native host build/verification PASS and + reports a runnable `freshell-server`, client, MCP, and Claude sidecar, with zero + forbidden artifacts. This command does not launch or deploy a server. + +- [ ] **Step 7: Commit the task** + + ```bash + git add scripts/prepare-electron-runtime.ts scripts/verify-electron-artifact.ts scripts/assert-native-windows-build.ts scripts/bundled-node-version.json config/electron-builder.yml package.json package-lock.json test/unit/electron + git add -u scripts/prepare-bundled-node.ts + git commit -m "build: package Rust backend in Electron" + ``` + +### Task 9: Make Containers, CI, and Release Artifacts Rust-Only + +**Files:** + +- Modify: `examples/docker/Dockerfile` +- Modify: `docker/cloud-run/Dockerfile` +- Modify: `docker/cloud-run/entrypoint.sh` +- Modify: `docker/cloud-run/test-durations.txt` +- Modify: `.github/workflows/rust-clippy.yml` +- Modify: `.github/workflows/typecheck-client.yml` +- Modify: `.github/workflows/electron-build.yml` +- Modify: `.github/workflows/electron-release.yml` +- Create: `test/unit/tooling/distribution-runtime.test.ts` +- Create: `scripts/verify-container-layout.sh` +- Create: `test/fixtures/distribution/rust-only/**` +- Create: `test/fixtures/distribution/node-server/**` + +**Interfaces:** + +- The example image is a Rust server + built client example and no longer claims + Node-only extension lifecycle support. Its final command is + `/app/freshell-server`; Node is present at runtime only when the staged Claude + sidecar/MCP client is included and is never the container entrypoint. +- The Cloud E2E image builds/copies `freshell-server`, `dist/client`, and + `dist/tools`; it does not compile/copy `dist/server` or install native build + prerequisites for `node-pty`. Until Task 10 removes the legacy dependencies + from the root lock, its Node tooling stage uses `npm ci --ignore-scripts` and a + declared removal/assertion step for the exact Task 10 backend-only dependency + directories before copying `node_modules`; the final image contains none of + them. The intermediate E2E image still contains the tracked legacy source so + the runtime-boundary test observes the same tree as local Vitest; Task 11 + rebuilds after Task 10 and proves that source is absent from final images. +- Required CI runs `cargo fmt`, clippy including real-transport feature lanes, + `cargo build -p freshell-server`, and `cargo test --workspace --locked` with + `FRESHELL_SERVER_BIN` set for the non-skipping Tauri smoke. Retained Vitest and + Electron tests have required jobs: `typecheck-client.yml` runs client + typecheck plus the nonempty default Vitest lane, `rust-clippy.yml` owns Cargo, + and `electron-build.yml` runs Electron Vitest before packaging on every matrix + OS. +- Electron build/release matrix installs Rust 1.96.0, builds the native server, + verifies each unpacked artifact, and uploads only verified installers. The build + workflow supports `workflow_dispatch` so this branch can prove native artifacts + without creating a PR. + +- [ ] **Step 1: Write the failing behavioral test** + + Add `distribution-runtime.test.ts` to parse Dockerfiles/workflows and require + Rust entrypoints/build/test jobs, Electron `crates/**` path triggers, + `workflow_dispatch`, artifact verification, and absence of Node-server build or + artifact names. Add `verify-container-layout.sh` fixture tests that fail a + staged `dist/server/index.js` and accept the Rust/client/tools layout. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/tooling/distribution-runtime.test.ts --config config/vitest/vitest.config.ts + ``` + + Expected: FAIL because the example CMD is Node, Cloud Docker builds + `dist/server`, CI lacks workspace Cargo tests, and Electron workflows lack Rust + prerequisites/artifact verification. + +- [ ] **Step 3: Add the minimal implementation** + + Convert both container builds/entrypoints, remove server Vitest vocabulary and + `--passWithNoTests` from cloud execution, and make empty discovery a hard + failure. Make the cloud Node stage ignore lifecycle scripts, remove and assert + absence of the explicit backend-only dependency directories before runtime + copy; Task 10's lockfile pruning makes that transitional removal a no-op. Add + the Cargo test job and native Electron Rust setup/build/verify steps. Expand + Electron path filters to `crates/**`, `Cargo.toml`, `Cargo.lock`, tools, and + runtime scripts. Keep the permitted Node test/browser/MCP/Claude runtimes + explicit in comments and image checks. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:vitest -- run test/unit/tooling/distribution-runtime.test.ts --config config/vitest/vitest.config.ts + bash scripts/verify-container-layout.sh --fixture test/fixtures/distribution/rust-only + docker build --tag freshell-retire-node-server-v2-cloud --file docker/cloud-run/Dockerfile . + docker build --tag freshell-retire-node-server-v2-example --file examples/docker/Dockerfile . + docker image inspect freshell-retire-node-server-v2-cloud --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' + docker image inspect freshell-retire-node-server-v2-example --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-cloud -c 'test -x /app/target/release/freshell-server && test -f /app/dist/client/index.html && test -f /app/dist/tools/freshell-mcp/server.js && test ! -e /app/dist/server && test ! -e /app/node_modules/node-pty && test ! -e /app/node_modules/express' + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-example -c 'test -x /app/freshell-server && test -f /app/dist/client/index.html && test ! -e /app/dist/server && test ! -e /app/node_modules' + ``` + + Expected: PASS; the example image command is `/app/freshell-server`; the cloud + image retains only its E2E shard entrypoint, whose Rust-only fixture contract is + asserted by `distribution-runtime.test.ts`. The two non-server container probes + find the required Rust/client/tool artifacts with no `dist/server`. Neither + probe starts Freshell or binds a port. + +- [ ] **Step 5: Refactor while green** + + Reuse the artifact forbidden/required-name list in container and Electron + verification, pin Rust/Node versions in one documented workflow location, and + make shell verifier diagnostics structured JSONL with `severity`, `event`, and + sorted path evidence. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "node dist/server|build:server|dist/server|server-node-modules|node-pty|vitest\.server|--passWithNoTests" examples/docker docker/cloud-run .github/workflows + cargo fmt --all --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo test --workspace --locked + ``` + + Expected: search returns no match and all local Rust checks PASS. The Tauri + smoke output names the explicit built server and contains no SKIP. + +- [ ] **Step 7: Commit the task** + + ```bash + git add examples/docker docker/cloud-run .github/workflows test/unit/tooling/distribution-runtime.test.ts scripts/verify-container-layout.sh test/fixtures/distribution + git commit -m "ci: enforce Rust-only backend artifacts" + ``` + +### Task 10: Delete the Legacy Node Backend, Tests, Scripts, and Dependencies + +**Files:** + +- Delete: `server/**` +- Delete: `test/server/**` +- Delete: remaining `test/unit/server/**` +- Delete: `test/integration/server/**` +- Delete: `test/integration/{session-repair,session-search-e2e}.test.ts` +- Delete: `test/helpers/coding-cli/fake-codex-launch-planner.ts` +- Delete: `test/fixtures/fresh-agent/claude/thread.ts` +- Delete: `scripts/{find-corrupted,repair-one,repair-all}.ts` +- Delete: `scripts/proofs/terminal-catchup-pty-metrics.ts` +- Delete: `port/oracle/interchange/*.mjs` +- Delete: `port/oracle/matrix/*.mjs` +- Delete: `port/oracle/rest-parity/sweep.mjs` +- Delete: `port/oracle/robustness/kill-probe.mjs` +- Delete: `port/oracle/indexer/{sd-probe.mjs,seed.sh}` +- Delete: `port/oracle/t3/{gen-summary.mjs,global-setup.target.ts,playwright.target.config.ts}` +- Modify: `package.json` +- Regenerate: `package-lock.json` +- Modify: `.gitignore` only for obsolete generated Node-server directories +- Modify: `scripts/retirement/runtime-boundary.ts` +- Modify: `test/unit/architecture/rust-only-server-runtime.test.ts` + +**Interfaces:** + +- The tracked `server/` directory does not exist. No package/config/script/test + compiles, emits, imports, or launches it. +- Root production dependencies remove Node-backend-only + `@ai-sdk/google`, root `@anthropic-ai/claude-agent-sdk`, `ai`, `chokidar`, + `cookie-parser`, `dotenv`, `express`, `express-rate-limit`, `glob`, `node-pty`, + `pino`, `rotating-file-stream`, and `is-port-reachable`; dev dependencies remove + `@types/cookie-parser`, `@types/express`, `@types/supertest`, `supertest`, + `superwstest`, and `pino-pretty`. Task 8 also removes + `extract-zip` and `tar` after replacing their only staging use. Keep `diff` for + the client and `@modelcontextprotocol/sdk` for the retained MCP client. The + Claude SDK remains only in `crates/freshell-claude-sidecar/package*.json`. + Transitive packages required by the retained MCP SDK may remain in the lock; + the forbidden set is absent from the root's direct dependency ownership and + no retained entrypoint imports it as a Freshell backend. +- Deleted Node tests are not mechanically ported. Retained behavior stays covered + by current Rust crate tests, default Vitest, Rust Playwright, Electron tests, + and Tasks 1-9 regression tests. +- Runtime guard debt shrinks to active documentation-only items left for Task 11; + `unexpectedNodeBackend` stays empty. + +- [ ] **Step 1: Write the failing behavioral test** + + Tighten the runtime/dependency test to require `server/` and every Node-server + test/config/script category absent, require the explicit forbidden dependency + set absent from root direct dependencies, and require zero imports into + `server/**`. Add a fixture that proves the allowed CLI/MCP/Claude Node packages + and their locked transitive dependencies do not satisfy a Node-backend + detector unless an entrypoint actually listens or owns backend state. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts --config config/vitest/vitest.config.ts + ``` + + Expected: FAIL with concrete `server/**`, Node-test-tree, dependency, and legacy + maintenance-script debt entries. + +- [ ] **Step 3: Add the minimal implementation** + + First run a retained-fixture import scan and move any provider fixture still + consumed by Rust/E2E to `test/fixtures/**`; Task 6 already removed the + Node-runtime provider contracts while preserving the independent Amplifier + contracts. Then delete the exact legacy trees and scripts, prune the listed + dependencies/types, and regenerate the root lock with + `npm install --package-lock-only`. Do not delete shared contracts, + `dist/tools` sources, Electron, test fixtures used by Rust, or + `crates/freshell-claude-sidecar`. Do not edit historical plans/reports merely to + erase references. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + test ! -d server + npm install --package-lock-only + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts --config config/vitest/vitest.config.ts + npm run typecheck + ``` + + Expected: all commands PASS; the guard reports only active docs/process wording + reserved for Task 11, with no implementation/build/test dependency on Node + backend code. + +- [ ] **Step 5: Refactor while green** + + Remove newly unreachable exclusions/path classifiers, collapse empty legacy + directories, and sort package entries. Replace stale implementation comments in + active source only when they imply a runnable Node path; keep useful historical + semantic provenance in Rust comments and committed port reports. + +- [ ] **Step 6: Run impacted-test verification** + + Run: + + ```bash + ! rg -n "from ['\"][^'\"]*server/|import\(['\"][^'\"]*server/|server/index\.(ts|js)|dist/server|tsconfig\.server|node-pty" src shared tools config scripts electron installers docker examples .github test/e2e-browser test/e2e-electron test/integration test/helpers --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' + npm run build + FRESHELL_TEST_SUMMARY="legacy Node backend deleted" npm test + ``` + + Expected: search returns no active import/launch/artifact match; build and broad + coordinated tests PASS with the Rust workspace included and positive counts. + +- [ ] **Step 7: Commit the task** + + ```bash + git add -A + git diff --cached --check + git commit -m "refactor: delete legacy Node application server" + ``` + +### Task 11: Update Active Documentation, Repeat Gap Triage, and Prove the Cutover + +**Files:** + +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `.env.example` +- Modify: `docs/development/windows-electron-build.md` +- Modify: `docs/development/test-sandbox.md` +- Modify: `scripts/retirement/runtime-boundary.ts` +- Modify: `test/unit/architecture/rust-only-server-runtime.test.ts` +- Create outside the worktree during execution: + `/home/dan/code/freshell/.worktrees/.the-usual-logs/retire-node-server-v2/reports/final-node-feature-triage.md` +- Do not modify: `docs/index.html` +- Do not modify: `.kata.toml` unless a real Kata configuration change is independently required + +**Interfaces:** + +- README describes Rust server install/dev/build/start/serve, standalone Node + CLI/MCP clients, Electron's packaged Rust backend, the isolated Claude sidecar, + and the accepted unavailable features without advertising deterministic 404s. +- AGENTS command/test/Electron/service guidance matches final scripts and keeps the + port-3001 approval rule. `.env.example` says Rust server and documents explicit + packaged MCP/Claude env only where operators can set them. Windows guide builds + native `freshell-server.exe` and verifies the installer; it no longer mentions + `conpty.node`/Node backend compilation. +- The sandbox guide retains its destructive-test safety contract but replaces the + obsolete `node-pty` rationale with current process-kill/config-corruption/restart + examples. +- Final runtime guard requires `legacyDebt=[]` and + `unexpectedNodeBackend=[]`, scans active README/process/release paths, and retains + historical-plan exclusions. +- The external triage receipt records the final source/caller inventory and + Kata/GitHub/checklist owner searches. Expected result: every important residual + remains owned by #624/checklist or another listed issue, so no Kata is created. + +- [ ] **Step 1: Write the failing behavioral test** + + Tighten `rust-only-server-runtime.test.ts` to require both arrays empty and add + active-document assertions for Rust commands, retained standalone clients, and + forbidden Node-backend wording/paths. Add assertions that `docs/index.html` and + `.kata.toml` are unchanged from `origin/main`. + +- [ ] **Step 2: Run the test and verify the intended RED** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts --config config/vitest/vitest.config.ts + ``` + + Expected: FAIL on current README/AGENTS/.env/Windows-guide legacy statements or + remaining temporary debt allowlist entries, not on historical plans. + +- [ ] **Step 3: Add the minimal implementation** + + Update the five active documents and remove the temporary debt list so the guard + requires zero. Then create the external triage receipt with exact command, + timestamp, commit, result, and owner sections. Re-run source/caller searches for + attachments, exec/diff/send, editor open, extension lifecycle/assets, raw/WS + browser forwarding, `/api/run`, paged turns, viewport/scrollback, + `codingcli.*`, and incident dump. For every reachable Rust-absent capability, + run targeted `kata search --workspace "$PWD" --lexical --limit 20`, + `kata list --workspace "$PWD" --json`, + `gh issue list --repo danshapiro/freshell --state all --limit 500 --search`, and + `rg` over the parity checklist/plans; record the output summary. The expected + conclusion is `no important untracked residual; no Kata filed`. + + Use this fixed final inventory/owner-search command set and record every command, + exit code, and summarized result in the receipt: + + ```bash + rg -n "/api/(fresh-agent/(attachments|exec|diff|send)|files/open|extensions/.*/(start|assets)|proxy/forward|run)|codingcli\.|getTerminalViewport|getTerminalScrollback|loadFreshAgent(ThreadTurns|TurnBody)|debug/fresh-agent" src tools shared crates README.md AGENTS.md + kata list --workspace "$PWD" --json + triage_terms=("fresh agent attachments" "fresh agent exec diff" "fresh agent send" "api run automation" "external editor reveal" "extension lifecycle assets" "browser proxy forwarding websocket" "session repair" "fresh agent paged turns" "terminal viewport scrollback" "codingcli websocket" "fresh agent incident") + for triage_term in "${triage_terms[@]}"; do + kata search --workspace "$PWD" --lexical --limit 20 "$triage_term" --agent + gh issue list --repo danshapiro/freshell --state all --limit 500 --search "$triage_term in:title,body" --json number,title,state,url + done + gh issue view 624 --repo danshapiro/freshell --json number,title,state,url,body + gh issue view 165 --repo danshapiro/freshell --json number,title,state,url,body + gh issue view 6 --repo danshapiro/freshell --json number,title,state,url,body + rg -n "AGENT-(09|11|13|20)|AUTO-(11|12|13)|BROWSER-0[2-4]|EXT-0[3-9]|FILE-04|SESSION-(11|16|21)|TERM-21" docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md + ``` + + The first source/caller search may return only deliberate unsupported-result + messages/tests represented in active source; the receipt classifies each match + and fails if it finds a request sender or advertised supported action. + + If and only if contrary implementation evidence establishes a reachable or + safety-critical gap with independent Rust-only product value and all three + owner searches are empty, create one acceptance-sized Kata using priority 1 + (`0` only for demonstrated security/data-loss/foreign-process risk), labels + `enhancement` and `rust-gap`, metadata + `source=retire-node-server-v2`. Derive the idempotency-key slug from the + lowercase ASCII capability name, collapse non-alphanumerics to single hyphens, + trim boundary hyphens, and truncate to 48 characters; concatenate + `freshell-retire-node-server-v2-`, that slug, and `-20260826`. Store its + triage/body receipts beside the final receipt, verify it with `kata show` plus + `kata events`, and verify `.kata.toml` remains unchanged. Do not touch `g8d3`. + +- [ ] **Step 4: Run the focused GREEN command** + + Run: + + ```bash + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts --config config/vitest/vitest.config.ts + git diff --exit-code origin/main -- docs/index.html .kata.toml + test -s /home/dan/code/freshell/.worktrees/.the-usual-logs/retire-node-server-v2/reports/final-node-feature-triage.md + ``` + + Expected: PASS; both guard arrays are empty, protected files match + `origin/main`, the receipt is nonempty and concludes no new Kata unless it names + and verifies one evidence-backed discovery. + +- [ ] **Step 5: Refactor while green** + + Deduplicate README/AGENTS command tables by linking contributor details from + README rather than copying them, normalize final scanner diagnostics, and remove + obsolete `legacy`, `original`, and `port` naming only from active commands and + config. Preserve historical plan/report provenance and the first run's worktree. + +- [ ] **Step 6: Run full impacted and non-vacuity verification** + + Run from the v2 worktree without any server on port 3001: + + ```bash + npm run test:status + FRESHELL_TEST_SUMMARY="retire Node server: final Rust-only proof" npm run check + cargo fmt --all --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo clippy -p freshell-codex --features real-transport --all-targets --locked -- -D warnings + cargo clippy -p freshell-opencode --features real-transport --all-targets --locked -- -D warnings + cargo test --workspace --locked + npm run lint + env -u FRESHELL_RUN_REAL_PROVIDER_CONTRACTS npm run test:oracle + npm run test:e2e:helpers + npm exec playwright -- test --config test/e2e-browser/playwright.config.ts --project=chromium --list + npm run test:e2e -- --project=chromium + npm run test:electron + npm run test:e2e:electron + npm run electron:build + npm run verify:electron-artifact + docker build --tag freshell-retire-node-server-v2-cloud --file docker/cloud-run/Dockerfile . + docker build --tag freshell-retire-node-server-v2-example --file examples/docker/Dockerfile . + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-cloud -c 'test -x /app/target/release/freshell-server && test -f /app/dist/client/index.html && test -f /app/dist/tools/freshell-mcp/server.js && test ! -e /app/dist/server && test ! -e /app/server && test ! -e /app/node_modules/node-pty' + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-example -c 'test -x /app/freshell-server && test -f /app/dist/client/index.html && test ! -e /app/dist/server && test ! -e /app/server && test ! -e /app/node_modules' + ! rg -n "dist/server|server/index\.(ts|js)|tsx watch server|tsconfig\.server|server-node-modules|node-pty|legacy-chromium" package.json config scripts tools electron installers docker examples .github test/e2e-browser test/e2e-electron README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' + test ! -d server + test ! -d dist/server + test ! -f tsconfig.server.json + test ! -f config/vitest/vitest.server.config.ts + git diff --exit-code origin/main -- docs/index.html .kata.toml + ``` + + Expected: all commands PASS; Playwright lists at least 308 tests in at least 86 + files and no legacy project; full configured E2E has nonzero executed tests and + zero required skips; Electron artifact has a runnable Rust server and no + forbidden path; rebuilt final container images contain no legacy source, + compiled Node server, or Node-backend-only native dependency; final `rg` has no output; + absence/protected-file checks pass. + Any selected destructive lifecycle suite runs via `scripts/sandbox-test.sh`, + never directly on the host. + + For native cross-platform packaging, push only this feature branch, then run: + + ```bash + git push -u origin the-usual/retire-node-server-v2 + gh workflow run electron-build.yml --ref the-usual/retire-node-server-v2 + electron_run_id="$(gh run list --workflow electron-build.yml --branch the-usual/retire-node-server-v2 --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId')" + test -n "$electron_run_id" + gh run watch "$electron_run_id" --exit-status + ``` + + Expected matrix result is green on `macos-15-intel`, `macos-latest`, `ubuntu-latest`, and + `windows-2022`, with each job's artifact verifier reporting native + `freshell-server[.exe]` present and all forbidden Node-server artifacts absent. + This branch push creates no PR and performs no deployment. + +- [ ] **Step 7: Commit the task** + + ```bash + git add README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md scripts/retirement/runtime-boundary.ts test/unit/architecture/rust-only-server-runtime.test.ts + if ! git diff --quiet -- .kata.toml; then git add .kata.toml; fi + git commit -m "docs: declare the Rust-only backend" + ``` + + Expected final state: the worktree is clean after the commit; the external + triage receipt remains outside tracked worktree history; no PR exists; port + 3001 was never contacted or restarted; the first retirement run remains intact. From 6dfd5cba4d14cf4e20e90751ed8d9f29f07d0746 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:46:45 -0700 Subject: [PATCH 02/64] docs: harden Rust-only server retirement plan --- .../plans/2026-08-26-retire-node-server-v2.md | 509 +++++++++++------- 1 file changed, 309 insertions(+), 200 deletions(-) diff --git a/docs/plans/2026-08-26-retire-node-server-v2.md b/docs/plans/2026-08-26-retire-node-server-v2.md index b5416faea..975006d33 100644 --- a/docs/plans/2026-08-26-retire-node-server-v2.md +++ b/docs/plans/2026-08-26-retire-node-server-v2.md @@ -35,7 +35,7 @@ Retire Freshell's legacy Node.js application server so the Rust server is the on **Goal:** `freshell-server` is the only executable that listens on Freshell's HTTP/WebSocket port, owns Freshell PTYs, or composes backend state. Browser, -Electron, daemon/service, container, test, and release paths all start that Rust +Electron, standalone service, container, test, and release paths all start that Rust binary. Node remains only in the explicitly permitted frontend/build/test, Electron-shell, standalone CLI/MCP-client, and Claude-sidecar roles. @@ -64,9 +64,6 @@ Docker, and GitHub Actions. forwarding, WebSocket proxy upgrades, `/api/run`, paged transcript turns, terminal viewport/paged scrollback, `codingcli.*`, or the incident dump merely to delete Node. Existing parity issue #624/checklist items retain ownership. -- The separately tracked same-origin BrowserPane concern `freshell#g8d3` is not - modified, related, closed, or redesigned here. Ordinary Rust localhost HTTP - proxying remains the supported BrowserPane behavior. - Never contact, stop, restart, or health-check port 3001. Every executable test owns an isolated `HOME`/`FRESHELL_HOME`, token, PID, and OS-assigned or unique non-3001 loopback port. Lifecycle/restart-storm tests use @@ -79,8 +76,9 @@ Docker, and GitHub Actions. `event`, and non-secret context. New Rust logs use the configured structured `tracing` subscriber. Never log tokens, authorization headers, prompts, attachment/file bodies, or sidecar payloads. -- No task starts a PR. A branch push is permitted only when needed for the native - artifact workflow; never push to `origin/main`. Do not deploy the result. +- No task starts a PR. A branch push is permitted for the final review handoff; + never push to `origin/main`. Native required checks run only after the user + explicitly approves PR creation. Do not deploy the result. - `.kata.toml` is expected to remain byte-identical. If implementation really changes it, include it in the focused task commit. Normal Kata create/search operations must not change it. @@ -91,10 +89,14 @@ Docker, and GitHub Actions. ## File Responsibility and Interface Map -- `scripts/retirement/runtime-boundary.ts` owns the permanent classification of - supported runtime paths. It scans active code/config/test/release surfaces, - ignores historical `docs/plans/**` and frozen evidence, and reports sorted - `legacyDebt` and `unexpectedNodeBackend` entries. +- `scripts/retirement/runtime-surfaces.json` is the checked-in, closed inventory + of every supported launch, service, packaging, container, test-fixture, and + release owner, including root executables and surviving `port/**` bootstrap + scripts. `scripts/retirement/runtime-boundary.ts` reconciles the manifest in + both directions: every discovered owner maps to exactly one row and every row + resolves to tracked evidence. It ignores historical `docs/plans/**` and frozen + evidence, and reports sorted `manifestDrift`, `legacyDebt`, and + `unexpectedNodeBackend` entries. - `shared/tab-registry-types.ts` and `shared/freshell-home.ts` own application contracts formerly imported from the Node backend. `config/vite/get-network-host.ts` owns Vite's bind-host lookup. @@ -120,15 +122,19 @@ Docker, and GitHub Actions. `config/vitest/vitest.electron.config.ts` own the broad gate: retained Vitest, the Rust workspace, and Electron. Required lanes reject zero selection and do not use `--passWithNoTests`. -- `scripts/start-rust-server.ts`, `scripts/launch.sh`, and - `scripts/launch-rust.sh` own source start/serve lifecycle. They launch only - `target/{debug,release}/freshell-server` and preserve exact-PID safety. -- `electron/server-spawner.ts` owns the Electron app-bound Rust child. - `electron/daemon/**` and installer templates own persistent Rust service - registration. Their process contract is `serverBinary`, `clientDir`, - `claudeNodeBinary`, `claudeSidecarEntry`, `mcpNodeBinary`, `mcpEntry`, - `homeDir`, `configDir`, and `logDir`; it has no Node server entry or - `NODE_PATH`. +- `scripts/start-rust-server.ts`, `scripts/launch.sh`, + `scripts/launch-rust.sh`, root `run-rust-server.sh`, and retained + `port/**` bootstrap scripts own source start/serve lifecycle. They launch or + build only `target/{debug,release}/freshell-server` and preserve exact-PID + safety. +- `electron/server-spawner.ts` owns the Electron app-bound Rust child. Electron + supports app-bound and remote modes; the advertised but never provisioned + Electron daemon mode and its service managers/templates are removed. The + standalone `installers/systemd/freshell-rust.service` remains the supported + Rust service path. The app-bound process contract has `serverBinary`, + `clientDir`, `claudeNodeBinary`, `claudeSidecarEntry`, `mcpNodeBinary`, + `mcpEntry`, `homeDir`, `configDir`, and `logDir`; it has no Node server entry + or `NODE_PATH`. - `scripts/prepare-electron-runtime.ts` stages the host-native Rust server, built client, compiled MCP bridge plus its minimal production dependency closure, and the isolated Claude Node/sidecar runtime. `config/electron-builder.yml` @@ -144,11 +150,11 @@ Docker, and GitHub Actions. | Requirement | Delivering tasks | Proof | | --- | --- | --- | -| Rust is the sole backend/server | 1, 4, 6-11 | Runtime guard reaches zero debt; source/browser/Electron/container/release provenance names `freshell-server`; `server/` and `dist/server/` do not exist. | +| Rust is the sole backend/server | 1, 4, 6-11 | Closed runtime manifest has zero drift/debt; source/browser/Electron/container/release provenance names `freshell-server`; `server/` and `dist/server/` do not exist. | | CLI/MCP remain standalone Node clients | 2, 7-8, 10 | Sources and output are `tools/**`/`dist/tools/**`; MCP injection and package bin use them; unit and live Rust E2E pass; no client listens or imports backend code. | -| Rust-absent actions are honest | 2-3, 5 | CLI/MCP reject `run`/`fresh-send` without HTTP; browser client makes no missing-route requests; dead REST/WS declarations disappear. | +| Rust-absent actions are honest | 2-3, 5 | A 33-action/14-alias table rejects every unsupported action or argument locally without HTTP; browser client makes no missing-route requests; dead REST/WS declarations disappear. | | Browser uses only Rust | 3-5, 11 | One `chromium` project, Rust fixture provenance, at least 308 tests in at least 86 files, zero legacy project/kind, and configured E2E green. | -| Electron/daemon use packaged Rust | 7-9, 11 | Spawn/template unit tests, app-bound Electron E2E, native artifact inspection, and all-OS CI receipts show the Rust binary and reject Node backend artifacts. | +| Electron/service use packaged Rust | 7-9, 11 | Electron daemon mode is absent; app-bound Electron E2E, standalone-service inspection, checkout-free native artifact acceptance, and all-OS CI receipts show the Rust binary and reject Node backend artifacts. | | Test/build/release proof is non-vacuous | 4, 6, 9, 11 | No `--passWithNoTests`; Cargo workspace is in the broad gate; Tauri smoke fails without a binary; selection/artifact floors and provenance assertions pass. | | Node-only gaps are triaged, not silently ported | 3, 5, 11 | Final external receipt repeats source/caller/Kata/GitHub/checklist searches; expected result is no important untracked gap; a Kata is filed only on contrary evidence. | | Safety/docs/process constraints | all, especially 11 | Isolated ports/PIDs, no port-3001 contact, README/active guides updated, `docs/index.html` untouched, `.kata.toml` unchanged or committed. | @@ -159,6 +165,7 @@ Docker, and GitHub Actions. **Files:** +- Create: `scripts/retirement/runtime-surfaces.json` - Create: `scripts/retirement/runtime-boundary.ts` - Create: `test/unit/architecture/rust-only-server-runtime.test.ts` - Create: `shared/tab-registry-types.ts` @@ -177,13 +184,16 @@ Docker, and GitHub Actions. **Interfaces:** -- `analyzeRuntimeBoundary(root): Promise<{ legacyDebt: string[]; - unexpectedNodeBackend: string[] }>` returns stable sorted repo-relative - evidence and ignores historical plans, generated output, dependencies, and - frozen screenshots/reports. Its sanctioned Node roles are explicit - entrypoint/module rules, not directory-wide exclusions: backend listeners, - WebSocket servers, Freshell PTY ownership, or imports from `server/**` still - fail when placed under Electron, tools, scripts, or tests. +- `analyzeRuntimeBoundary(root): Promise<{ manifestDrift: string[]; + legacyDebt: string[]; unexpectedNodeBackend: string[] }>` loads a closed + manifest seeded from the load-bearing review's 44 runtime/resource owners and + returns stable sorted repo-relative evidence. Every tracked executable, + package command, service/template, container entrypoint, fixture server, + release job, root launcher, and surviving `port/**` bootstrap owner must map to + exactly one manifest row; every row must resolve. Sanctioned Node roles are + explicit entrypoint/module rules, not directory-wide exclusions: backend + listeners, WebSocket servers, Freshell PTY ownership, or imports from + `server/**` still fail when placed under Electron, tools, scripts, or tests. - `getFreshellHomeDir(env)` and `getFreshellConfigDir(env)` preserve the current `FRESHELL_HOME`-then-home behavior without relying on the `NodeJS` global type; the two legacy `server/**` modules are temporary re-exports until Task 10. @@ -200,10 +210,14 @@ Docker, and GitHub Actions. Add `rust-only-server-runtime.test.ts` with a synthetic-tree test proving an invented Node HTTP listener is `unexpectedNodeBackend`, an allowlist test for - Vite/Vitest/Electron-main/CLI/MCP/Claude-sidecar Node roles, and a current-tree - test requiring the known debt entries `server/index.ts`, - `package.json:scripts.start`, `config/electron-builder.yml:dist/server`, and - `test/e2e-browser/playwright.config.ts:legacy-chromium`. Extend the existing + Vite/Vitest/Electron-main/CLI/MCP/Claude-sidecar Node roles, and manifest + reconciliation tests for an unlisted tracked owner, a stale row, and duplicate + ownership. The current-tree test requires the known debt entries + `server/index.ts`, `package.json:scripts.start`, + `config/electron-builder.yml:dist/server`, + `test/e2e-browser/playwright.config.ts:legacy-chromium`, the stale legacy + comment in root `run-rust-server.sh`, and the inherited build path in + `port/laptop-bootstrap/2-bootstrap-wsl.sh`. Extend existing Vite/coordinator/tab-registry tests to import only the new neutral paths. - [ ] **Step 2: Run the test and verify the intended RED** @@ -220,15 +234,20 @@ Docker, and GitHub Actions. - [ ] **Step 3: Add the minimal implementation** - Implement the scanner and move the neutral code without changing its data - semantics. Replace the coordinator import of `server/coding-cli/utils.ts`, the + Check in the closed manifest and implement two-way reconciliation before + moving the neutral code without changing its data semantics. Discovery is + deliberately broader than the manifest and fails closed on a new root + executable, package script, service resource, container command, test server, + workflow launch step, or retained `port/**` bootstrap path. Replace the + coordinator import of `server/coding-cli/utils.ts`, the Vite import of `server/get-network-host.ts`, and the client import of `server/tabs-registry/types.ts`. Make `server/freshell-home.ts` and `server/tabs-registry/types.ts` temporary NodeNext `.js` re-exports from the neutral owners so the intermediate backend consumes the same contracts. Remove only the update-check block/import from `scripts/precheck.ts`; preserve its serve-branch and port protections. Keep a temporary explicit debt list so - later tasks can remove entries one by one. + later tasks can remove entries one by one; manifest rows remain after their + classification changes from legacy debt to Rust or sanctioned Node client. - [ ] **Step 4: Run the focused GREEN command** @@ -240,16 +259,19 @@ Docker, and GitHub Actions. ``` Expected: PASS; synthetic Node listener rejection bites, sanctioned tooling is - accepted, and the current legacy debt is enumerated rather than hidden. + accepted, manifest drift is empty, and current legacy debt is enumerated rather + than hidden. - [ ] **Step 5: Refactor while green** Deduplicate path normalization/file walking, sort every diagnostic, and extract pure adapters around process env/filesystem access. Preserve public schema/type - names so client persistence does not migrate. Add a fixture showing that a file + names so client persistence does not migrate. Add fixtures showing that a file under `docs/plans/**` is ignored while the same text under `scripts/**` is debt, - plus a fake `tools/` or `electron/` Node HTTP listener proving sanctioned - directory names cannot bypass capability detection. + that root and `port/**` executable owners cannot escape inventory, and that a + fake `tools/` or `electron/` Node HTTP listener cannot bypass capability + detection. Keep semantic listener detection as defense in depth behind the + closed surface manifest. - [ ] **Step 6: Run impacted-test verification** @@ -278,7 +300,7 @@ Docker, and GitHub Actions. - Create: `tools/freshell-cli/**` from retained `server/cli/**` - Create: `tools/freshell-mcp/{server.ts,freshell-tool.ts,http-client.ts}` -- Create: `tools/node-client-runtime/{config,keys,codex-restore-contract}.ts` +- Create: `tools/node-client-runtime/{action-capabilities,config,keys,codex-restore-contract}.ts` - Create: `tsconfig.tools.json` - Move: `test/unit/server/mcp/{freshell-tool,http-client,server}.test.ts` to `test/unit/mcp/` - Modify: `test/unit/cli/**` @@ -299,6 +321,7 @@ Docker, and GitHub Actions. - Modify: `test/e2e-browser/playwright.config.ts` - Modify: `test/e2e-browser/specs/mcp-bridge-rust.spec.ts` - Modify: `test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts` +- Create: `test/fixtures/tools/rust-action-capability-matrix.json` - Retain until Task 10: `server/mcp/config-writer.ts` as part of the still-pending legacy backend only; it is not copied into `tools/**` **Interfaces:** @@ -308,12 +331,19 @@ Docker, and GitHub Actions. NodeNext/NodeNext, `rootDir: "tools"`, `outDir: "dist/tools"`, and includes only `tools/**/*.ts`. Tool-relative runtime imports carry `.js`; no tool emits under `dist/server` or requires a compiled `shared/**` tree. -- CLI `run` returns exit code 2 and - `run is unavailable on the Rust backend; create a shell pane and use send-keys` - without an HTTP request. -- MCP `run` and `fresh-send` return structured `{ error, hint }` unsupported - results without an HTTP request; help/description no longer advertise them as - supported. All other current actions retain request paths and output shapes. +- A checked-in capability matrix contains all 33 canonical actions and 14 + aliases. Validation, CLI help, MCP schema/description, and tests consume the + same table; unclassified or duplicate actions fail the build. Supported rows + preserve current Rust request paths/output shapes. Unsupported rows return a + deterministic local exit-code-2 or `{ error, hint }` result and make zero HTTP + requests. +- Unsupported rows/variants are: `run`; `fresh-send`; `attach`; `new-tab` with + `agent` other than Rust-supported `opencode`; `split-pane` with any of + `agent`, `model`, or `effort`; `wait-for` without a pattern or with + `stable|exit|prompt`; and legacy `capture` `J`/`e` arguments whose semantics + Rust ignores. Help and MCP parameter schemas do not advertise them. Direct + Claude/Codex terminals continue through supported `mode` values rather than + the rejected `agent` sugar. - Replace the hard-coded-`node` args-only seam with `McpServerCommand { command: McpServerArg, args: Vec }` and `McpRuntime::server_command()`. Every generated Claude/Gemini/Kimi JSON, @@ -322,7 +352,10 @@ Docker, and GitHub Actions. `FRESHELL_MCP_ENTRY` pair first, production (`node` plus) `dist/tools/freshell-mcp/server.js` second, and dev `tools/freshell-mcp/server.ts` with the tsx loader third. Supplying only one - explicit variable is an error, not a fallback. + explicit variable is an error, not a fallback. Command-aware conversion covers + both the executable and every path-valued argument/config selector in native + Linux, macOS, and Windows plus WSL-to-Windows and Windows-to-WSL crossings; + conversion failure is fatal. - During the intermediate Tasks 2-9 branch, the legacy backend's `buildMcpServerCommandArgs` resolves the same `dist/tools`/`tools` entrypoints; it never points at the deleted `server/mcp/server.ts` source. The whole config @@ -334,9 +367,10 @@ Docker, and GitHub Actions. Move the MCP/CLI tests to their final paths and add assertions that imports resolve under `tools/**`, `npm run build:tools` creates both final entrypoints, - `run`/`fresh-send` never invoke the fake HTTP client, `package.json#bin` is - outside `dist/server`, and `mcp_inject` prefers the explicit packaged pair and - rejects a half-configured pair. Change the retained config-writer tests to + the complete 33-action/14-alias matrix is classified exactly once, every + unsupported row/variant above makes zero fake-HTTP calls, `package.json#bin` + is outside `dist/server`, and `mcp_inject` prefers the explicit packaged pair + and rejects a half-configured pair. Change retained config-writer tests to require its production/dev injection paths under `dist/tools`/`tools` and no path under `server/mcp`. Put `// @vitest-environment node` at the top of the moved MCP tests so the default config runs their filesystem/stdio behavior @@ -363,16 +397,17 @@ Docker, and GitHub Actions. - [ ] **Step 3: Add the minimal implementation** Move the CLI and only the stdio/client MCP modules. Extract config-dir, key - translation, and the raw-Codex-resume message to neutral modules; update the - legacy `agent-api/router.ts` and restore-decision module to consume/re-export + translation, the action-capability table, and the raw-Codex-resume message to + neutral modules; update the legacy `agent-api/router.ts` and restore-decision + module to consume/re-export those neutral contracts so removing `server/cli/**` does not break the intermediate branch. Leave `server/mcp/config-writer.ts` solely inside the legacy backend until Task 10; do not copy it or any backend/provider module into `tools/**`, but repoint its generated client command to the new tool entrypoint so the intermediate backend remains buildable. Add the dedicated tools TypeScript build and update all source/test/package/Rust-injection paths. - Implement deterministic local unsupported results for CLI `run` and MCP - `run`/`fresh-send`; remove their happy-path help and parameter schemas. Keep + Implement deterministic local unsupported results for every listed + action/variant; remove their happy-path help and parameter schemas. Keep `@modelcontextprotocol/sdk` as a production dependency of the retained MCP program. Convert every Rust injection renderer from the old args-only, hard-coded `node` contract to `McpServerCommand`, including WSL path conversion @@ -395,34 +430,39 @@ Docker, and GitHub Actions. Expected: PASS; both outputs exist outside `dist/server`, unsupported actions produce the frozen local errors with zero HTTP calls, and every MCP injection - target points at `tools`/`dist/tools`. + target points at `tools`/`dist/tools`; the full action table is reconciled. - [ ] **Step 5: Refactor while green** Consolidate CLI/MCP auth URL resolution in `tools/node-client-runtime/config.ts`, make unsupported-action metadata a read-only table used by validation and help, - and remove duplicated path conversion in `mcp_inject.rs`. Add negative tests - proving neither executable opens a listening socket and MCP stderr remains - valid JSONL without corrupting stdout JSON-RPC. + and remove duplicated path conversion in `mcp_inject.rs`. Add parse/round-trip + goldens for every provider renderer with command plus args, spaces, quotes, + backslashes, native Linux/macOS/Windows paths, and both WSL crossing directions; + convert config selector paths as well and fail on conversion errors. Add + negative tests proving neither executable opens a listening socket and MCP + stderr remains valid JSONL without corrupting stdout JSON-RPC. - [ ] **Step 6: Run impacted-test verification** Run: ```bash - rg -n "server/(cli|mcp)|dist/server/(cli|mcp)|/api/(run|fresh-agent/send)" package.json tools crates/freshell-platform test/unit/cli test/unit/mcp test/e2e test/e2e-browser/helpers test/e2e-browser/specs/mcp-*.spec.ts - npm run test:e2e -- --project=rust-chromium test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts + ! rg -n "server/(cli|mcp)|dist/server/(cli|mcp)" package.json tools crates/freshell-platform test/unit/cli test/unit/mcp test/e2e test/e2e-browser/helpers test/e2e-browser/specs/mcp-*.spec.ts + FRESHELL_E2E_BACKEND=local npm run test:e2e:local -- --project=rust-chromium test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts ``` - Expected: the search finds no old path or live missing-endpoint call (only - unsupported-action test literals are allowed); the configured E2E command runs - a nonzero test count, starts one owned Rust server, executes - `dist/tools/freshell-mcp/server.js`, and PASSes. Neither spec may be cloud-skipped. + Expected: the search finds no old path; unit tests prove every unsupported + variant has zero transport. The explicit local E2E command avoids the current + cloud skip during this pre-collapse task, runs a nonzero test count, starts one + owned Rust server, executes + `dist/tools/freshell-mcp/server.js`, and PASSes. Task 4 removes any transitional + cloud skip before these specs join configured broad coverage. - [ ] **Step 7: Commit the task** ```bash - git add tools tsconfig.tools.json package.json package-lock.json crates/freshell-platform test/unit/cli test/unit/mcp test/unit/server/mcp/config-writer.test.ts test/unit/server/mcp/config-writer-paths.test.ts test/e2e test/e2e-browser/helpers/mcp-stdio-client.ts test/e2e-browser/playwright.config.ts test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts server/agent-api/router.ts server/coding-cli/codex-app-server/restore-decision.ts + git add tools tsconfig.tools.json package.json package-lock.json crates/freshell-platform test/fixtures/tools test/unit/cli test/unit/mcp test/unit/server/mcp/config-writer.test.ts test/unit/server/mcp/config-writer-paths.test.ts test/e2e test/e2e-browser/helpers/mcp-stdio-client.ts test/e2e-browser/playwright.config.ts test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts server/agent-api/router.ts server/coding-cli/codex-app-server/restore-decision.ts git add -A server/cli server/mcp git commit -m "refactor: separate Node clients from legacy server" ``` @@ -475,7 +515,9 @@ Docker, and GitHub Actions. renders the baseline message with no raw-forward request; an editor pane's context menu lacks external-open/reveal while save still works; a server/client extension pane renders the accessible unsupported panel with no - start/asset request; and a fake-provider fresh-agent pane has no attachment + start/asset request; an actual markdown file is read, edited, saved, verified + on disk, and rendered in preview through Rust's supported editor routes; and a + fake-provider fresh-agent pane has no attachment control, blocks `!command`, and cannot expand a diff without making any of the three removed fresh-agent requests. Capture all page requests and fail on a forbidden route. Register this Rust-only spec in the pre-collapse @@ -499,8 +541,8 @@ Docker, and GitHub Actions. upload state and file input. Keep `!` detection solely to block with the exact notice. Render diff filenames/status as text, unregister external editor/reveal callbacks and their menu entries, and short-circuit unsupported - extension categories before any request/iframe URL is constructed. Do not add - a replacement route or change Rust proxy security. + extension categories before any request/iframe URL is constructed. Preserve + Rust's existing localhost proxy and editor read/save/preview behavior. - [ ] **Step 4: Run the focused GREEN command** @@ -529,7 +571,8 @@ Docker, and GitHub Actions. Expected: the search returns no production caller; typecheck/lint PASS; the configured E2E run reports a nonzero test count and PASSes against an owned Rust - server. Neither required spec appears in `CLOUD_SKIP_SPECS`. + server, including the disk-verified editor round trip. Neither required spec + appears in `CLOUD_SKIP_SPECS`. - [ ] **Step 7: Commit the task** @@ -734,6 +777,10 @@ Docker, and GitHub Actions. teardown; they do not compare with or read the historical original-side T2 JSON files. They prove ownership from their own PID ledger and never inspect, connect to, or make assertions about a listener on port 3001. +- Those real-provider T2 contracts remain explicitly opt-in and may skip when + `FRESHELL_RUN_REAL_PROVIDER_CONTRACTS` is unset. They are useful supplemental + provider checks, not required replacement coverage for any deleted Node test; + always-running fake/provider-shape Rust tests own retirement closure. - Historical reports/baselines stay untouched as provenance, but no active oracle command can build or launch Node. - `crates/freshell-extensions/fixtures/manifest-oracle.json` remains a frozen @@ -808,8 +855,9 @@ Docker, and GitHub Actions. Run the Step 2 commands again. Expected: PASS; schema generation has no drift, the Rust crates reject removed - messages, client exports are gone, and every active oracle starts/reaps only an - owned Rust process on a non-3001 port. + messages, client exports are gone, and every active always-running oracle + starts/reaps only an owned Rust process on a non-3001 port. Opt-in T2 skips are + reported as supplemental and are not counted as replacement coverage. - [ ] **Step 5: Refactor while green** @@ -855,6 +903,8 @@ Docker, and GitHub Actions. - Modify: `package.json` - Modify: `scripts/launch.sh` - Modify: `scripts/launch-rust.sh` +- Modify: `run-rust-server.sh` +- Modify: `port/laptop-bootstrap/2-bootstrap-wsl.sh` - Modify: `scripts/run-standard-tests.ts` - Modify: `scripts/testing/coordinator-command-matrix.ts` - Modify: `scripts/testing/test-coordinator.ts` @@ -892,7 +942,10 @@ Docker, and GitHub Actions. `build` produces client, tools, and release `freshell-server`; `start` executes the release Rust binary through the cross-platform signal-forwarding script. - `scripts/launch.sh` is a compatibility forwarder to the safe Rust launcher; - `launch-rust.sh` remains canonical and exact-PID verified. + `launch-rust.sh` remains canonical and exact-PID verified. Root + `run-rust-server.sh` no longer advertises the Node command, and the retained + laptop bootstrap invokes the Rust-inclusive build/start contract rather than + inheriting a Node-server build path. - Broad `npm test`/`npm run check`/`npm run verify` cover retained default Vitest, `cargo test --workspace --locked`, and Electron Vitest under one coordinator gate. `test:server` runs the `freshell-server` crate; `test:integration` runs @@ -922,9 +975,10 @@ Docker, and GitHub Actions. server/real-provider Vitest configs and `--passWithNoTests`, removal of their now-invalid package scripts, and rejection of a simulated zero selected-test result. Require the retained visible-first CLI harness to be selected by the - default lane. Add the owned source-runtime integration test described above. Change - the Tauri smoke unit path to panic, not print SKIP, when no binary can be - resolved. + default lane. Require the closed runtime manifest to reconcile root launchers + and `port/**` bootstrap owners. Add the owned source-runtime integration test + described above. Change the Tauri smoke unit path to panic, not print SKIP, + when no binary can be resolved. - [ ] **Step 2: Run the test and verify the intended RED** @@ -953,7 +1007,9 @@ Docker, and GitHub Actions. Amplifier contracts excluded and opt-in. The start wrapper resolves `.exe` on Windows, forwards argv/signals, inherits stdio, emits structured JSONL only for wrapper errors, and never - backgrounds or kills an unowned PID. Add the explicit build+env Tauri test + backgrounds or kills an unowned PID. Update root `run-rust-server.sh` and the + retained laptop bootstrap to the same Rust-only build/start contract and + reclassify their manifest rows. Add the explicit build+env Tauri test wrapper and matching CI step. Delete the Node-only extension-system integration from the default lane; current Rust extension crate/browser coverage is the baseline. @@ -970,6 +1026,8 @@ Docker, and GitHub Actions. bash scripts/test/cloud-vitest-wrapper.test.sh cargo build -p freshell-server --locked FRESHELL_SERVER_BIN="$PWD/target/debug/freshell-server" cargo test -p freshell-tauri --locked --test server_spawn_smoke app_bound_spawn_health_reap_end_to_end -- --exact --nocapture + cargo test -p freshell-codex --features real-transport --locked + cargo test -p freshell-opencode --features real-transport --locked ``` Expected: PASS; the Tauri smoke starts/reaps the exact binary on an ephemeral @@ -987,7 +1045,7 @@ Docker, and GitHub Actions. Run: ```bash - ! rg -n "vitest\.(server|codex-real-provider-smoke|opencode-serve-real-provider-smoke)|server-global-setup|tsconfig\.server|tsx watch server|dist/server/index|--passWithNoTests|test:real:coding-cli-contracts|test:codex-real-provider-smoke|test:opencode-serve-smoke" package.json config scripts docker/cloud-run test/setup test/unit/tooling .github/workflows/rust-clippy.yml + ! rg -n "vitest\.(server|codex-real-provider-smoke|opencode-serve-real-provider-smoke)|server-global-setup|tsconfig\.server|tsx watch server|dist/server/index|--passWithNoTests|test:real:coding-cli-contracts|test:codex-real-provider-smoke|test:opencode-serve-smoke|npm start" package.json config scripts run-rust-server.sh port/laptop-bootstrap docker/cloud-run test/setup test/unit/tooling .github/workflows/rust-clippy.yml test ! -f tsconfig.server.json test ! -f config/vitest/vitest.server.config.ts test ! -f test/setup/server-global-setup.ts @@ -1002,35 +1060,39 @@ Docker, and GitHub Actions. - [ ] **Step 7: Commit the task** ```bash - git add -A package.json package-lock.json scripts config/vitest test/setup test/unit test/integration test/helpers crates/freshell-tauri .github/workflows/rust-clippy.yml tsconfig.server.json docker/cloud-run/entrypoint.sh + git add -A package.json package-lock.json scripts run-rust-server.sh port/laptop-bootstrap/2-bootstrap-wsl.sh config/vitest test/setup test/unit test/integration test/helpers crates/freshell-tauri .github/workflows/rust-clippy.yml tsconfig.server.json docker/cloud-run/entrypoint.sh git commit -m "build: make Rust the default server and test lane" ``` -### Task 7: Cut Electron App-Bound and Daemon Lifecycles Over to Rust +### Task 7: Cut Electron App-Bound Lifecycle Over to Rust and Retire Dead Daemon Mode **Files:** - Modify: `electron/server-spawner.ts` - Modify: `electron/startup.ts` - Modify: `electron/entry.ts` -- Modify: `electron/daemon/daemon-manager.ts` -- Create: `electron/daemon/runtime-env.ts` -- Modify: `electron/daemon/{systemd,launchd,windows-service}.ts` -- Modify: `installers/systemd/freshell.service.template` -- Modify: `installers/launchd/com.freshell.server.plist.template` -- Modify: `installers/windows/freshell-task.xml.template` -- Modify: `test/unit/electron/server-spawner.test.ts` -- Modify: `test/unit/electron/startup.test.ts` -- Modify: `test/unit/electron/daemon/**` -- Create: `test/unit/electron/daemon/runtime-env.test.ts` -- Create: `test/fixtures/electron/daemon-env-edge-cases.env` -- Create: `crates/freshell-server/tests/electron_daemon_env.rs` +- Modify: `electron/{types,desktop-config,launch-policy,preload}.ts` +- Modify: `electron/setup-wizard/{wizard-logic,wizard}.tsx` +- Delete: `electron/daemon/**` +- Delete: `installers/systemd/freshell.service.template` +- Delete: `installers/launchd/com.freshell.server.plist.template` +- Delete: `installers/windows/freshell-task.xml.template` +- Modify: `config/electron-builder.yml` +- Modify: `test/unit/electron/{server-spawner,startup,desktop-config,launch-policy,preload}.test.ts` +- Modify: `test/unit/electron/setup-wizard/wizard.test.tsx` +- Delete: `test/unit/electron/daemon/**` - Create: `test/e2e-electron/app-bound-rust-server.test.ts` -- Create: `test/e2e-electron/daemon-rust-command.test.ts` +- Modify: Electron tests/fixtures whose config union currently names `daemon` **Interfaces:** -- `ServerSpawnResources` and `DaemonPaths` contain `serverBinary`, `clientDir`, +- Electron's supported `ServerMode` is `app-bound | remote`. The setup wizard no + longer advertises “Always-running daemon,” startup creates no daemon manager, + and packaged resources contain no Electron-owned launchd/systemd/Task + Scheduler templates. A persisted `serverMode: "daemon"` is migrated once to + `app-bound`, written back atomically, and surfaced through a clear structured + migration notice; all other persisted fields remain unchanged. +- `ServerSpawnResources` contains `serverBinary`, `clientDir`, `claudeNodeBinary`, `claudeSidecarEntry`, `mcpNodeBinary`, `mcpEntry`, `homeDir`, `configDir`, and `logDir`. No `nodeBinary`, `serverEntry`, native modules, server modules, or `NODE_PATH` exists. Startup derives `homeDir` as @@ -1038,94 +1100,97 @@ Docker, and GitHub Actions. whose basename is not `.freshell`; `logDir` is `configDir/logs`. - App-bound spawn env sets `PORT`, `FRESHELL_HOME`, `FRESHELL_CLIENT_DIR`, `FRESHELL_CLAUDE_NODE`, `FRESHELL_CLAUDE_SIDECAR`, `FRESHELL_MCP_NODE`, and - `FRESHELL_MCP_ENTRY`; `FRESHELL_HOME` is exactly `homeDir`. The child/service - working directory is exactly `configDir`, so the Rust binary loads - `AUTH_TOKEN` from the existing config-directory `.env`; token values are never - logged. -- Daemon install atomically patches those same seven non-secret runtime keys into - `configDir/.env` with temp-file + rename while byte-preserving `AUTH_TOKEN` and - unrelated assignments/comments. This lets all three service templates execute - the Rust binary directly with `configDir` as cwd; no Windows `cmd.exe`, inline - shell env, or secret-bearing service definition remains. Parser/serializer - fixtures include CRLF, comments, quoted spaces, `#`, `=`, backslashes, and a - Rust `dotenvy` round trip. -- Dev uses `target/debug/freshell-server`; packaged mode uses - `resources/bin/freshell-server[.exe]`. The health gate also verifies - authenticated server-info provenance before declaring readiness. -- Service templates execute the Rust binary directly. Windows status/stop - identifies `freshell-server.exe` at the exact configured path; Unix managers - retain exact service/label ownership. + `FRESHELL_MCP_ENTRY`; `FRESHELL_HOME` is exactly `homeDir`. The child working + directory is exactly `configDir`, so Rust loads `AUTH_TOKEN` from the existing + `.env`; token values are never logged. Dev uses + `target/debug/freshell-server`; packaged mode uses + `resources/bin/freshell-server[.exe]`. Readiness verifies authenticated + server-info provenance. +- App-bound ownership is the exact `ChildProcess` returned by spawn. Close/error + handlers clear that reference. Stop signals only that child, waits to a fixed + first deadline, escalates only that same PID, waits to a second fixed deadline, + and reports failure if it is still alive. No path/command-line scan or broad + kill is permitted. “Stopped” means the owned backend process exited; this task + adds no descendant-survival or restart-continuity guarantee. +- `installers/systemd/freshell-rust.service` remains the supported standalone + Rust service and is not an Electron daemon resource. - [ ] **Step 1: Write the failing behavioral test** - Change spawner/startup/daemon/template tests to assert the exact Rust command - and env above and to reject `server/index.js`, `NODE_PATH`, `node-pty`, and - `server-node-modules`. Add runtime-env tests that prove atomic merge, - preservation, Windows-path serialization, and Rust `dotenvy` parity. Add - app-bound E2E that launches Electron with staged - Rust/MCP/Claude fixtures, authenticates, verifies server-info runtime/commit, - exits the app, and proves the exact Rust child is gone while foreign processes - are untouched. Add a native daemon-command E2E that renders the platform's - service definition from packaged resource paths, executes that rendered Rust - command directly in a disposable home, verifies server-info provenance, and - reaps the exact child; it does not register a persistent host service. + Change spawner/startup/config/wizard tests to require the exact Rust command + and env, reject every Node-server field, reject daemon as a new configuration, + and prove a persisted daemon value migrates atomically to app-bound. Add + lifecycle tests with two same-path fake server processes: stopping Electron + reaps only its captured child, clears the reference on close/error, waits after + escalation, and reports a second-deadline failure. Add app-bound E2E that + launches Electron with staged Rust/MCP/Claude fixtures, authenticates, verifies + server-info runtime/commit, exits the app, and proves the exact Rust child is + gone while the foreign same-path process remains. - [ ] **Step 2: Run the test and verify the intended RED** Run: ```bash - npm run test:electron -- test/unit/electron/server-spawner.test.ts test/unit/electron/startup.test.ts test/unit/electron/daemon - cargo test -p freshell-server --locked --test electron_daemon_env + npm run test:electron -- test/unit/electron/server-spawner.test.ts test/unit/electron/startup.test.ts test/unit/electron/desktop-config.test.ts test/unit/electron/launch-policy.test.ts test/unit/electron/setup-wizard/wizard.test.tsx test/unit/electron/daemon ``` Expected: FAIL because Electron currently plans bundled Node plus - `resources/server/index.js` and templates set `NODE_PATH`. + `resources/server/index.js`, advertises daemon mode, constructs a daemon + manager, and Windows daemon stop can target a foreign same-path process. - [ ] **Step 3: Add the minimal implementation** Replace the spawn/resource types atomically, invoke the Rust binary with no server script argument, set only the explicit Rust/client/MCP/Claude env, and - retain cwd, log piping, health timeout, double-start handling, graceful - exact-child stop, and structured errors. Implement the atomic daemon `.env` - merger, then rewrite all three service templates and managers to direct Rust - execution, config-directory cwd, and exact binary identity. Dev startup - requires the Task 6 debug build; it never falls back to tsx/Node backend. + preserve cwd, redacted JSONL log piping, health timeout, and double-start + handling. Implement exact captured-child bounded stop. Remove daemon from the + schema/wizard/startup/IPC surface, migrate persisted daemon config to + app-bound, delete `electron/daemon/**` and its three templates/tests, and remove + those resources from electron-builder. Dev startup requires the Task 6 debug + build; it never falls back to tsx/Node backend. - [ ] **Step 4: Run the focused GREEN command** - Run the Step 2 command again. + Run the Step 2 command without the now-deleted `test/unit/electron/daemon` + selector. - Expected: PASS; every captured command begins with `freshell-server[.exe]`, - required env paths are absolute, and forbidden Node-server fields are absent. + Expected: PASS; every captured backend command begins with + `freshell-server[.exe]`, required env paths are absolute, exact-child stop is + bounded, daemon config migrates, and daemon cannot be newly selected. - [ ] **Step 5: Refactor while green** - Extract one `resolveDesktopRuntimeResources(resourcesPath, platform, isDev)` - pure function consumed by app-bound and daemon paths. Deduplicate template env - construction and process-identity checks. Preserve Windows escaping and Unix - path-with-spaces tests, and ensure all new lifecycle logs are redacted JSONL. + Extract `resolveDesktopRuntimeResources(resourcesPath, platform, isDev)` as a + pure app-bound function and a reusable exact-child wait helper. Keep process + identity tied to the spawn handle, preserve paths-with-spaces cases on every + platform, and ensure lifecycle/migration logs are redacted structured JSONL. + Remove dead daemon-only preload/launch-policy branches and fixtures. - [ ] **Step 6: Run impacted-test verification** Run: ```bash - ! rg -n "server/index|NODE_PATH|server-node-modules|nativeModules|nodeBinary|serverEntry" electron installers + ! rg -n "server/index|NODE_PATH|server-node-modules|nativeModules|nodeBinary|serverEntry|serverMode.*daemon|Always-running daemon|createDaemonManager" electron config/electron-builder.yml + test ! -d electron/daemon + test ! -e installers/systemd/freshell.service.template + test -f installers/systemd/freshell-rust.service cargo build -p freshell-server --locked npm run build:electron - npm run test:e2e:electron -- test/e2e-electron/app-bound-rust-server.test.ts test/e2e-electron/daemon-rust-command.test.ts + npm run test:e2e:electron -- test/e2e-electron/app-bound-rust-server.test.ts ``` - Expected: search returns no active match; Electron build and E2E PASS; the E2E - receipt identifies a non-3001 owned `freshell-server` PID and zero orphaned - children. + Expected: search and absence checks PASS; the standalone Rust service remains; + Electron build/E2E authenticate to a non-3001 owned `freshell-server`, stop + that backend PID exactly, and leave the foreign same-path fixture alive until + the fixture performs its own exact cleanup. - [ ] **Step 7: Commit the task** ```bash - git add electron installers test/unit/electron test/fixtures/electron/daemon-env-edge-cases.env test/e2e-electron crates/freshell-server/tests/electron_daemon_env.rs - git commit -m "feat: run Electron and daemon backends in Rust" + git add -A electron installers config/electron-builder.yml test/unit/electron test/e2e-electron + git commit -m "feat: run Electron app-bound backend in Rust" ``` ### Task 8: Package the Rust Server and Only Sanctioned Node Runtimes in Electron @@ -1136,6 +1201,7 @@ Docker, and GitHub Actions. - Create: `scripts/verify-electron-artifact.ts` - Create: `test/unit/electron/prepare-electron-runtime.test.ts` - Create: `test/unit/electron/verify-electron-artifact.test.ts` +- Create: `test/integration/electron/checkout-free-runtime.test.ts` - Modify: `scripts/prepare-bundled-node.ts` by extracting reusable Node-download code, then delete it - Modify: `scripts/bundled-node-version.json` - Modify: `scripts/assert-native-windows-build.ts` @@ -1160,12 +1226,10 @@ Docker, and GitHub Actions. SDK's locked closure may include dormant HTTP-framework libraries such as Express; structural and execution tests prove that the stdio MCP entrypoint never listens or becomes Freshell's backend. -- Node archive extraction invokes the host's standard `tar` executable with an - argv array. The staging script probes the required archive format first - (`.tar.xz` on Unix hosts and `.zip` via Windows' bsdtar-compatible `tar`) and - fails with a clear structured diagnostic if that host capability is absent; - it checks extraction exit status and emits redacted JSONL failure context. - Root `extract-zip` and `tar` packages are not retained. +- Node archive extraction retains the existing locked `extract-zip` and `tar` + libraries, their integrity checks, and cross-platform error handling. The + retirement does not introduce a host-`tar` prerequisite merely to remove the + Node backend; failures emit redacted structured JSONL context. - `verify-electron-artifact(path, platform)` fails unless the native Rust binary, client index, MCP entry/dependencies, Claude entry/dependencies, and Node runtime exist; it fails on any forbidden artifact or if the Rust binary cannot be @@ -1178,14 +1242,22 @@ Docker, and GitHub Actions. performs the execution probe. - `electron:build`/`:win` build the host-native Rust server and tools, stage the runtime, package, and verify the unpacked artifact before installers upload. +- `checkout-free-runtime.test.ts` copies the staged runtime to a temporary root + outside the checkout, runs with empty cwd/`NODE_PATH` and no root + `node_modules`, authenticates to Rust server-info, fetches the SPA plus a real + hashed asset, exercises the fake-Claude hook, speaks stdio JSON-RPC to the + compiled MCP entry with no listening socket, and reaps every exact owned child. - [ ] **Step 1: Write the failing behavioral test** Add staging and artifact tests with an injected binary-probe runner and temporary fake resource tree. Require the exact allowlist, assert each forbidden name fails verification, and assert the probe runs in an empty cwd with auth/config - env removed and a deadline. Change the Windows platform check message to require - native Rust `.exe` production, not native `node-pty` compilation. + env removed and a deadline. Add the checkout-free acceptance test above, with + deliberate failures when it can see checkout files/root `node_modules`, MCP + writes non-JSON-RPC stdout, or any owned PID survives. Change the Windows + platform check message to require native Rust `.exe` production, not native + `node-pty` compilation. - [ ] **Step 2: Run the test and verify the intended RED** @@ -1203,10 +1275,10 @@ Docker, and GitHub Actions. Refactor the verified Node download to the new staging script, delete header and `node-pty` rebuild/pruned-server-dependency logic, copy the host-native Cargo binary, build/copy `dist/tools`, and stage the two permitted Node consumers with - their locked dependency closures. Replace the npm archive libraries with the - host `tar` argv contract above. Rewrite electron-builder resources and npm - Electron scripts to use the staging directory and invoke the verifier on the - unpacked result. + their locked dependency closures. Preserve the locked archive libraries and + extraction checks. Rewrite electron-builder resources and npm Electron scripts + to use the staging directory and invoke the verifier on the unpacked result; + package only app-bound resources, with no Electron daemon templates. - [ ] **Step 4: Run the focused GREEN command** @@ -1218,10 +1290,12 @@ Docker, and GitHub Actions. npm run build:tools cargo build --release -p freshell-server --locked npm run prepare:electron-runtime + npm run test:vitest -- run test/integration/electron/checkout-free-runtime.test.ts --config config/vitest/vitest.electron.config.ts ``` Expected: PASS; staging contains every allowlisted resource and none of the - forbidden Node-server/native-module paths. + forbidden Node-server/native-module paths, and the copied runtime works without + checkout or root dependency access. - [ ] **Step 5: Refactor while green** @@ -1247,7 +1321,7 @@ Docker, and GitHub Actions. - [ ] **Step 7: Commit the task** ```bash - git add scripts/prepare-electron-runtime.ts scripts/verify-electron-artifact.ts scripts/assert-native-windows-build.ts scripts/bundled-node-version.json config/electron-builder.yml package.json package-lock.json test/unit/electron + git add scripts/prepare-electron-runtime.ts scripts/verify-electron-artifact.ts scripts/assert-native-windows-build.ts scripts/bundled-node-version.json config/electron-builder.yml package.json package-lock.json test/unit/electron test/integration/electron git add -u scripts/prepare-bundled-node.ts git commit -m "build: package Rust backend in Electron" ``` @@ -1292,17 +1366,20 @@ Docker, and GitHub Actions. and `electron-build.yml` runs Electron Vitest before packaging on every matrix OS. - Electron build/release matrix installs Rust 1.96.0, builds the native server, - verifies each unpacked artifact, and uploads only verified installers. The build - workflow supports `workflow_dispatch` so this branch can prove native artifacts - without creating a PR. + verifies each unpacked artifact, runs the checkout-free authenticated runtime + acceptance (server-info, SPA asset, PTY creation/I/O, fake Claude, stdio MCP, + exact cleanup), and uploads only verified installers. Required PR checks own + this proof on `macos-15-intel`, `macos-latest`, `ubuntu-latest`, and + `windows-2022`; the plan does not add a branch-only dispatch path. - [ ] **Step 1: Write the failing behavioral test** Add `distribution-runtime.test.ts` to parse Dockerfiles/workflows and require Rust entrypoints/build/test jobs, Electron `crates/**` path triggers, - `workflow_dispatch`, artifact verification, and absence of Node-server build or - artifact names. Add `verify-container-layout.sh` fixture tests that fail a - staged `dist/server/index.js` and accept the Rust/client/tools layout. + the four-target required native acceptance, artifact verification, and absence + of Node-server build or artifact names. Add `verify-container-layout.sh` + fixture tests that fail a staged `dist/server/index.js` and accept the + Rust/client/tools layout. - [ ] **Step 2: Run the test and verify the intended RED** @@ -1325,7 +1402,9 @@ Docker, and GitHub Actions. copy; Task 10's lockfile pruning makes that transitional removal a no-op. Add the Cargo test job and native Electron Rust setup/build/verify steps. Expand Electron path filters to `crates/**`, `Cargo.toml`, `Cargo.lock`, tools, and - runtime scripts. Keep the permitted Node test/browser/MCP/Claude runtimes + runtime scripts. Run Task 8's checkout-free acceptance against the unpacked + native artifact in every matrix job, including an authenticated PTY round trip + and exact cleanup. Keep the permitted Node test/browser/MCP/Claude runtimes explicit in comments and image checks. - [ ] **Step 4: Run the focused GREEN command** @@ -1339,7 +1418,7 @@ Docker, and GitHub Actions. docker build --tag freshell-retire-node-server-v2-example --file examples/docker/Dockerfile . docker image inspect freshell-retire-node-server-v2-cloud --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' docker image inspect freshell-retire-node-server-v2-example --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' - docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-cloud -c 'test -x /app/target/release/freshell-server && test -f /app/dist/client/index.html && test -f /app/dist/tools/freshell-mcp/server.js && test ! -e /app/dist/server && test ! -e /app/node_modules/node-pty && test ! -e /app/node_modules/express' + docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-cloud -c 'test -x /app/target/release/freshell-server && test -f /app/dist/client/index.html && test -f /app/dist/tools/freshell-mcp/server.js && test ! -e /app/dist/server && test ! -e /app/node_modules/node-pty' docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-example -c 'test -x /app/freshell-server && test -f /app/dist/client/index.html && test ! -e /app/dist/server && test ! -e /app/node_modules' ``` @@ -1401,6 +1480,9 @@ Docker, and GitHub Actions. - Modify: `.gitignore` only for obsolete generated Node-server directories - Modify: `scripts/retirement/runtime-boundary.ts` - Modify: `test/unit/architecture/rust-only-server-runtime.test.ts` +- Create: `scripts/retirement/node-test-disposition.json` +- Create: `scripts/retirement/verify-node-test-disposition.ts` +- Create: `test/unit/architecture/node-test-disposition.test.ts` **Interfaces:** @@ -1411,9 +1493,9 @@ Docker, and GitHub Actions. `cookie-parser`, `dotenv`, `express`, `express-rate-limit`, `glob`, `node-pty`, `pino`, `rotating-file-stream`, and `is-port-reachable`; dev dependencies remove `@types/cookie-parser`, `@types/express`, `@types/supertest`, `supertest`, - `superwstest`, and `pino-pretty`. Task 8 also removes - `extract-zip` and `tar` after replacing their only staging use. Keep `diff` for - the client and `@modelcontextprotocol/sdk` for the retained MCP client. The + `superwstest`, and `pino-pretty`. Keep `extract-zip` and `tar` for reliable + cross-platform Electron runtime staging, `diff` for the client, and + `@modelcontextprotocol/sdk` for the retained MCP client. The Claude SDK remains only in `crates/freshell-claude-sidecar/package*.json`. Transitive packages required by the retained MCP SDK may remain in the lock; the forbidden set is absent from the root's direct dependency ownership and @@ -1421,6 +1503,14 @@ Docker, and GitHub Actions. - Deleted Node tests are not mechanically ported. Retained behavior stays covered by current Rust crate tests, default Vitest, Rust Playwright, Electron tests, and Tasks 1-9 regression tests. +- `node-test-disposition.json` is a committed deletion ledger for the complete + 346-file Task 5/6/10 candidate universe identified by the load-bearing review + before deletion. Every old test path + and every independently meaningful subject in a mixed test has a row with the + old path/title/subject, retained-or-deleted decision, exact surviving test, + required lane, selector, and latest receipt. Optional real-provider T2 checks + are marked supplemental and cannot satisfy a required replacement. Unknown, + duplicate, stale, or unresolved rows block deletion and the final gate. - Runtime guard debt shrinks to active documentation-only items left for Task 11; `unexpectedNodeBackend` stays empty. @@ -1431,7 +1521,10 @@ Docker, and GitHub Actions. set absent from root direct dependencies, and require zero imports into `server/**`. Add a fixture that proves the allowed CLI/MCP/Claude Node packages and their locked transitive dependencies do not satisfy a Node-backend - detector unless an entrypoint actually listens or owns backend state. + detector unless an entrypoint actually listens or owns backend state. Add the + disposition verifier with a synthetic mixed test whose second subject is + unresolved, a zero-test selector receipt, and a skipped optional T2 receipt; + all three must fail required replacement closure. - [ ] **Step 2: Run the test and verify the intended RED** @@ -1446,7 +1539,12 @@ Docker, and GitHub Actions. - [ ] **Step 3: Add the minimal implementation** - First run a retained-fixture import scan and move any provider fixture still + Before deleting anything, generate and review the complete committed + disposition ledger from the closed Task 5/6/10 universe. Split mixed files by + title/subject, bind each retained subject to an exact surviving test/lane and a + positive-count receipt, mark obsolete Node-implementation subjects explicitly, + and resolve every row; the verifier refuses an unresolved or vacuous row. Then + run a retained-fixture import scan and move any provider fixture still consumed by Rust/E2E to `test/fixtures/**`; Task 6 already removed the Node-runtime provider contracts while preserving the independent Amplifier contracts. Then delete the exact legacy trees and scripts, prune the listed @@ -1463,13 +1561,14 @@ Docker, and GitHub Actions. ```bash test ! -d server npm install --package-lock-only - npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/architecture/rust-only-server-runtime.test.ts test/unit/architecture/node-test-disposition.test.ts --config config/vitest/vitest.config.ts + node --import tsx scripts/retirement/verify-node-test-disposition.ts npm run typecheck ``` - Expected: all commands PASS; the guard reports only active docs/process wording - reserved for Task 11, with no implementation/build/test dependency on Node - backend code. + Expected: all commands PASS; the disposition has zero unresolved/vacuous rows + and the runtime guard reports only active docs/process wording reserved for + Task 11, with no implementation/build/test dependency on Node backend code. - [ ] **Step 5: Refactor while green** @@ -1484,12 +1583,16 @@ Docker, and GitHub Actions. ```bash ! rg -n "from ['\"][^'\"]*server/|import\(['\"][^'\"]*server/|server/index\.(ts|js)|dist/server|tsconfig\.server|node-pty" src shared tools config scripts electron installers docker examples .github test/e2e-browser test/e2e-electron test/integration test/helpers --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' + node --import tsx scripts/retirement/verify-node-test-disposition.ts + cargo test -p freshell-codex --features real-transport --locked + cargo test -p freshell-opencode --features real-transport --locked npm run build FRESHELL_TEST_SUMMARY="legacy Node backend deleted" npm test ``` - Expected: search returns no active import/launch/artifact match; build and broad - coordinated tests PASS with the Rust workspace included and positive counts. + Expected: search returns no active import/launch/artifact match; the disposition + ledger has zero unresolved rows; feature-gated transports, build, and broad + coordinated tests PASS with positive counts. - [ ] **Step 7: Commit the task** @@ -1518,8 +1621,9 @@ Docker, and GitHub Actions. **Interfaces:** - README describes Rust server install/dev/build/start/serve, standalone Node - CLI/MCP clients, Electron's packaged Rust backend, the isolated Claude sidecar, - and the accepted unavailable features without advertising deterministic 404s. + CLI/MCP clients, Electron's packaged app-bound Rust backend, the standalone + Rust systemd service, the isolated Claude sidecar, and accepted unavailable + features without advertising deterministic 404s or Electron daemon mode. - AGENTS command/test/Electron/service guidance matches final scripts and keeps the port-3001 approval rule. `.env.example` says Rust server and documents explicit packaged MCP/Claude env only where operators can set them. Windows guide builds @@ -1528,16 +1632,17 @@ Docker, and GitHub Actions. - The sandbox guide retains its destructive-test safety contract but replaces the obsolete `node-pty` rationale with current process-kill/config-corruption/restart examples. -- Final runtime guard requires `legacyDebt=[]` and - `unexpectedNodeBackend=[]`, scans active README/process/release paths, and retains - historical-plan exclusions. +- Final runtime guard requires `manifestDrift=[]`, `legacyDebt=[]`, and + `unexpectedNodeBackend=[]`, scans active README/process/release paths, and + retains historical-plan exclusions. The committed test-disposition verifier + also requires zero unresolved or vacuous replacement rows. - The external triage receipt records the final source/caller inventory and Kata/GitHub/checklist owner searches. Expected result: every important residual remains owned by #624/checklist or another listed issue, so no Kata is created. - [ ] **Step 1: Write the failing behavioral test** - Tighten `rust-only-server-runtime.test.ts` to require both arrays empty and add + Tighten `rust-only-server-runtime.test.ts` to require all three arrays empty and add active-document assertions for Rust commands, retained standalone clients, and forbidden Node-backend wording/paths. Add assertions that `docs/index.html` and `.kata.toml` are unchanged from `origin/main`. @@ -1590,15 +1695,14 @@ Docker, and GitHub Actions. If and only if contrary implementation evidence establishes a reachable or safety-critical gap with independent Rust-only product value and all three - owner searches are empty, create one acceptance-sized Kata using priority 1 - (`0` only for demonstrated security/data-loss/foreign-process risk), labels + owner searches are empty, create one acceptance-sized Kata using priority 1, labels `enhancement` and `rust-gap`, metadata `source=retire-node-server-v2`. Derive the idempotency-key slug from the lowercase ASCII capability name, collapse non-alphanumerics to single hyphens, trim boundary hyphens, and truncate to 48 characters; concatenate `freshell-retire-node-server-v2-`, that slug, and `-20260826`. Store its triage/body receipts beside the final receipt, verify it with `kata show` plus - `kata events`, and verify `.kata.toml` remains unchanged. Do not touch `g8d3`. + `kata events`, and verify `.kata.toml` remains unchanged. - [ ] **Step 4: Run the focused GREEN command** @@ -1610,7 +1714,7 @@ Docker, and GitHub Actions. test -s /home/dan/code/freshell/.worktrees/.the-usual-logs/retire-node-server-v2/reports/final-node-feature-triage.md ``` - Expected: PASS; both guard arrays are empty, protected files match + Expected: PASS; all guard arrays are empty, protected files match `origin/main`, the receipt is nonempty and concludes no new Kata unless it names and verifies one evidence-backed discovery. @@ -1633,6 +1737,8 @@ Docker, and GitHub Actions. cargo clippy -p freshell-codex --features real-transport --all-targets --locked -- -D warnings cargo clippy -p freshell-opencode --features real-transport --all-targets --locked -- -D warnings cargo test --workspace --locked + cargo test -p freshell-codex --features real-transport --locked + cargo test -p freshell-opencode --features real-transport --locked npm run lint env -u FRESHELL_RUN_REAL_PROVIDER_CONTRACTS npm run test:oracle npm run test:e2e:helpers @@ -1642,11 +1748,13 @@ Docker, and GitHub Actions. npm run test:e2e:electron npm run electron:build npm run verify:electron-artifact + npm run test:vitest -- run test/integration/electron/checkout-free-runtime.test.ts --config config/vitest/vitest.electron.config.ts + node --import tsx scripts/retirement/verify-node-test-disposition.ts docker build --tag freshell-retire-node-server-v2-cloud --file docker/cloud-run/Dockerfile . docker build --tag freshell-retire-node-server-v2-example --file examples/docker/Dockerfile . docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-cloud -c 'test -x /app/target/release/freshell-server && test -f /app/dist/client/index.html && test -f /app/dist/tools/freshell-mcp/server.js && test ! -e /app/dist/server && test ! -e /app/server && test ! -e /app/node_modules/node-pty' docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-example -c 'test -x /app/freshell-server && test -f /app/dist/client/index.html && test ! -e /app/dist/server && test ! -e /app/server && test ! -e /app/node_modules' - ! rg -n "dist/server|server/index\.(ts|js)|tsx watch server|tsconfig\.server|server-node-modules|node-pty|legacy-chromium" package.json config scripts tools electron installers docker examples .github test/e2e-browser test/e2e-electron README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' + ! rg -n "dist/server|server/index\.(ts|js)|tsx watch server|tsconfig\.server|server-node-modules|node-pty|legacy-chromium|npm start" package.json config scripts run-rust-server.sh port/laptop-bootstrap tools electron installers docker examples .github test/e2e-browser test/e2e-electron README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' test ! -d server test ! -d dist/server test ! -f tsconfig.server.json @@ -1656,32 +1764,33 @@ Docker, and GitHub Actions. Expected: all commands PASS; Playwright lists at least 308 tests in at least 86 files and no legacy project; full configured E2E has nonzero executed tests and - zero required skips; Electron artifact has a runnable Rust server and no - forbidden path; rebuilt final container images contain no legacy source, + zero required skips; optional real-provider T2 tests are reported as + supplemental rather than replacement coverage; Electron artifact works from a + checkout-free staged copy with a runnable Rust server and no forbidden path; + the disposition ledger has zero unresolved rows; rebuilt final container images contain no legacy source, compiled Node server, or Node-backend-only native dependency; final `rg` has no output; absence/protected-file checks pass. Any selected destructive lifecycle suite runs via `scripts/sandbox-test.sh`, never directly on the host. - For native cross-platform packaging, push only this feature branch, then run: + Native cross-platform acceptance is a required PR check, not a pre-PR dispatch. + After the final commit, push only this feature branch: ```bash git push -u origin the-usual/retire-node-server-v2 - gh workflow run electron-build.yml --ref the-usual/retire-node-server-v2 - electron_run_id="$(gh run list --workflow electron-build.yml --branch the-usual/retire-node-server-v2 --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId')" - test -n "$electron_run_id" - gh run watch "$electron_run_id" --exit-status ``` - Expected matrix result is green on `macos-15-intel`, `macos-latest`, `ubuntu-latest`, and - `windows-2022`, with each job's artifact verifier reporting native - `freshell-server[.exe]` present and all forbidden Node-server artifacts absent. - This branch push creates no PR and performs no deployment. + Then stop and request the user's explicit approval to create the PR. Once + approved, the normal required PR matrix must be green on `macos-15-intel`, + `macos-latest`, `ubuntu-latest`, and `windows-2022`; each job reports native + `freshell-server[.exe]`, authenticated server-info/SPA/PTY acceptance, stdio + MCP/fake-Claude acceptance, exact cleanup, and no forbidden Node-server + artifact. The branch push itself creates no PR and performs no deployment. - [ ] **Step 7: Commit the task** ```bash - git add README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md scripts/retirement/runtime-boundary.ts test/unit/architecture/rust-only-server-runtime.test.ts + git add README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md scripts/retirement/runtime-boundary.ts scripts/retirement/runtime-surfaces.json test/unit/architecture/rust-only-server-runtime.test.ts if ! git diff --quiet -- .kata.toml; then git add .kata.toml; fi git commit -m "docs: declare the Rust-only backend" ``` From d01716fd597b85ce377d28ba3c4b31785d8713af Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:46:10 -0700 Subject: [PATCH 03/64] docs: repair Rust retirement execution plan --- .../plans/2026-08-26-retire-node-server-v2.md | 269 +++++++++++++----- 1 file changed, 198 insertions(+), 71 deletions(-) diff --git a/docs/plans/2026-08-26-retire-node-server-v2.md b/docs/plans/2026-08-26-retire-node-server-v2.md index 975006d33..3e708b297 100644 --- a/docs/plans/2026-08-26-retire-node-server-v2.md +++ b/docs/plans/2026-08-26-retire-node-server-v2.md @@ -63,7 +63,9 @@ Docker, and GitHub Actions. exec/diff/send, external editor opening, extension lifecycle/assets, raw TCP forwarding, WebSocket proxy upgrades, `/api/run`, paged transcript turns, terminal viewport/paged scrollback, `codingcli.*`, or the incident dump merely - to delete Node. Existing parity issue #624/checklist items retain ownership. + to delete Node. The interactive precheck self-update prompt is likewise + triaged, not silently equated with Rust's server-side update check. Existing + parity issue #624/checklist items retain ownership. - Never contact, stop, restart, or health-check port 3001. Every executable test owns an isolated `HOME`/`FRESHELL_HOME`, token, PID, and OS-assigned or unique non-3001 loopback port. Lifecycle/restart-storm tests use @@ -119,9 +121,12 @@ Docker, and GitHub Actions. and `playwright.config.ts` own one Rust-backed browser lane. An external target is read-only and never stopped; an owned target records/reaps its exact PID. - `scripts/testing/**`, `config/vitest/vitest.config.ts`, and - `config/vitest/vitest.electron.config.ts` own the broad gate: retained Vitest, - the Rust workspace, and Electron. Required lanes reject zero selection and do - not use `--passWithNoTests`. + the dedicated `vitest.runtime.config.ts`, `vitest.electron.config.ts`, and + `vitest.electron-runtime.config.ts` own the broad/artifact gates: retained + default Vitest, source-runtime smoke, the Rust workspace, Electron unit tests, + and staged Electron runtime acceptance. Artifact-dependent trees are excluded + from default discovery; required lanes reject zero selection and do not use + `--passWithNoTests`. - `scripts/start-rust-server.ts`, `scripts/launch.sh`, `scripts/launch-rust.sh`, root `run-rust-server.sh`, and retained `port/**` bootstrap scripts own source start/serve lifecycle. They launch or @@ -203,8 +208,10 @@ Docker, and GitHub Actions. - `resolveGitRepoRoot`, `resolveGitCheckoutRoot`, and cache reset remain available to the coordinator from `scripts/testing/repo-context.ts`. - `scripts/precheck.ts` retains branch confirmation, dependency checks, and port - conflict checks; it drops the duplicate Node-updater invocation because Rust - already owns update behavior. + conflict checks. It retires the interactive Node precheck self-update prompt; + Rust retains its distinct server-side update-check behavior, and Task 11 + explicitly triages whether the removed interactive flow has an existing owner + or needs a Kata. - [ ] **Step 1: Write the failing behavioral test** @@ -218,7 +225,10 @@ Docker, and GitHub Actions. `test/e2e-browser/playwright.config.ts:legacy-chromium`, the stale legacy comment in root `run-rust-server.sh`, and the inherited build path in `port/laptop-bootstrap/2-bootstrap-wsl.sh`. Extend existing - Vite/coordinator/tab-registry tests to import only the new neutral paths. + Vite/coordinator/tab-registry tests to import only the new neutral paths. Rework + `session-corpus.test.ts` so it tests corpus writer/file invariants without + importing the soon-to-be-deleted Node Amplifier/OpenCode production readers; + Rust-owned browser/API corpus specs remain the production ingestion proof. - [ ] **Step 2: Run the test and verify the intended RED** @@ -244,8 +254,12 @@ Docker, and GitHub Actions. `server/tabs-registry/types.ts`. Make `server/freshell-home.ts` and `server/tabs-registry/types.ts` temporary NodeNext `.js` re-exports from the neutral owners so the intermediate backend consumes the same contracts. Remove - only the update-check block/import from `scripts/precheck.ts`; preserve its - serve-branch and port protections. Keep a temporary explicit debt list so + only the interactive update-check block/import from `scripts/precheck.ts`; + preserve its serve-branch and port protections and record the removed flow for + Task 11 triage. Remove the two Node provider-reader imports/assertions from the + session-corpus helper test while preserving writer/schema/hash coverage; do not + move deleted backend readers into a neutral namespace. Keep a temporary + explicit debt list so later tasks can remove entries one by one; manifest rows remain after their classification changes from legacy debt to Rust or sanctioned Node client. @@ -280,7 +294,8 @@ Docker, and GitHub Actions. ```bash ! rg -n "server/(tabs-registry/types|get-network-host|coding-cli/utils|updater)" src config scripts test/e2e-browser test/unit --glob '!test/unit/server/**' npm run typecheck:client - npm run test:vitest -- run test/unit/architecture test/unit/vite-config.test.ts test/unit/server/testing test/unit/server/prebuild-guard.test.ts --config config/vitest/vitest.server.config.ts + npm run test:vitest -- run test/unit/architecture test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/server/testing test/unit/server/prebuild-guard.test.ts --config config/vitest/vitest.server.config.ts ``` Expected: the search returns no retained consumer of those paths; typecheck and @@ -316,6 +331,7 @@ Docker, and GitHub Actions. - Modify: `test/unit/server/mcp/config-writer-paths.test.ts` - Modify: `crates/freshell-platform/src/mcp_inject.rs` - Modify: `crates/freshell-platform/src/mcp_inject_tests.rs` +- Modify: `crates/freshell-platform/src/cli_launch.rs` - Modify: `crates/freshell-platform/src/cli_launch_goldens.rs` - Modify: `test/e2e-browser/helpers/mcp-stdio-client.ts` - Modify: `test/e2e-browser/playwright.config.ts` @@ -327,7 +343,8 @@ Docker, and GitHub Actions. **Interfaces:** - `package.json#bin.freshell` points to `dist/tools/freshell-cli/index.js`. - `build:tools` runs `tsc -p tsconfig.tools.json`; that config uses + `typecheck:tools` runs that config with `--noEmit`; `build:tools` runs + `tsc -p tsconfig.tools.json`. The config uses NodeNext/NodeNext, `rootDir: "tools"`, `outDir: "dist/tools"`, and includes only `tools/**/*.ts`. Tool-relative runtime imports carry `.js`; no tool emits under `dist/server` or requires a compiled `shared/**` tree. @@ -379,7 +396,12 @@ Docker, and GitHub Actions. health/list/create/mutate tab and pane operations, send/capture/wait, browser navigation/screenshot, paged session listing/search, and the local unsupported `run` result. Register it explicitly in the pre-collapse `rust-chromium` - `testMatch`. This replaces the two Express/Node-backend fake E2E files. + `testMatch` and in the pre-collapse `RUST_ONLY_SPECS` exclusion so the legacy + `chromium` project cannot also collect it. This replaces the two + Express/Node-backend fake E2E files. Keep `mcp-qa-smoke-rust.spec.ts` explicitly + local-only because its codex-binary contract is unavailable in cloud E2E; its + positive local receipt is required and the cloud skip is not counted as + replacement coverage. - [ ] **Step 2: Run the test and verify the intended RED** @@ -411,7 +433,9 @@ Docker, and GitHub Actions. `@modelcontextprotocol/sdk` as a production dependency of the retained MCP program. Convert every Rust injection renderer from the old args-only, hard-coded `node` contract to `McpServerCommand`, including WSL path conversion - of both the executable and every path argument. + of both the executable and every path argument. Update retained + `cli_launch.rs` documentation so the old `server/mcp` path cannot trip the + final structural gate. - [ ] **Step 4: Run the focused GREEN command** @@ -456,8 +480,9 @@ Docker, and GitHub Actions. variant has zero transport. The explicit local E2E command avoids the current cloud skip during this pre-collapse task, runs a nonzero test count, starts one owned Rust server, executes - `dist/tools/freshell-mcp/server.js`, and PASSes. Task 4 removes any transitional - cloud skip before these specs join configured broad coverage. + `dist/tools/freshell-mcp/server.js`, and PASSes. Task 4 removes the temporary + dual-project registration but preserves the explicitly owned local-only cloud + skip for `mcp-qa-smoke-rust.spec.ts`. - [ ] **Step 7: Commit the task** @@ -606,6 +631,9 @@ Docker, and GitHub Actions. - Modify: `test/e2e-browser/vitest.config.ts` - Modify: `test/setup/e2e-browser-global-setup.ts` - Modify: `test/e2e-electron/electron-app.test.ts` +- Modify: `port/oracle/harness/external-server.ts` +- Create temporarily: `port/oracle/harness/legacy-node-server.ts` from the + oracle-only process-owning portion of `test-server.ts`; Task 5 deletes it - Modify: the closed current set of specs returned by `rg -l '\be2eServerKind\b' test/e2e-browser/specs | sort`; remove the obsolete fixture parameter and convert any executable legacy conditional to one @@ -614,6 +642,10 @@ Docker, and GitHub Actions. `rg -l '\bTestServer\b|test-server\.js' test/e2e-browser/specs test/e2e-browser/perf test/e2e-electron | sort`; direct owned constructors become `RustServer`, while shared types/port/home helpers import from `server-fixture-support.ts` +- Modify: the closed current comment/config set returned by + `rg -l 'legacy-chromium|dist/server/index' test/e2e-browser test/e2e-electron | sort`; + remove stale executable-path/project claims so structural gates do not confuse + historical comments with active owners - Create: `test/e2e-browser/helpers/selection-nonvacuity.test.ts` **Interfaces:** @@ -624,13 +656,21 @@ Docker, and GitHub Actions. - `server-fixture-support.ts` owns `E2eServerInfo`, ephemeral-port allocation, isolated-home env construction, and setup-wizard seeding without any process constructor. `test-server.ts` is deleted only after every direct constructor - and type/helper import in the two closed sets above has moved. -- `playwright.config.ts` exposes one application project named `chromium` with - Rust fixtures. There is no `legacy-chromium`, `rust-chromium`, `MATRIX_SPECS`, - or Node `TestServer`. + and type/helper import in the browser/Electron sets and the oracle harness has + moved. The oracle keeps its Node constructor temporarily under + `port/oracle/harness/legacy-node-server.ts` so Task 4 stays green; Task 5 + deletes that explicitly while converting oracles to Rust. +- `playwright.config.ts` exposes one primary application project named + `chromium` with Rust fixtures. CI-only `firefox`/`webkit` projects inherit the + same Rust fixture contract, and `continuity-smoke` remains a Rust-only + specialized project without `e2eServerKind`; none is a Node/Rust split lane. + There is no `legacy-chromium`, `rust-chromium`, `MATRIX_SPECS`, or browser-E2E + Node `TestServer`. - Selection inspection requires at least 308 tests in at least 86 files (the - observed pre-retirement Rust floor), zero legacy projects, and zero required - specs intersecting `CLOUD_SKIP_SPECS`. + observed pre-retirement Rust floor), zero legacy projects, and zero unexplained + required specs intersecting `CLOUD_SKIP_SPECS`. The codex-binary-dependent + `mcp-qa-smoke-rust.spec.ts` remains explicitly local-only with a required + positive local receipt; cloud never substitutes for it. - `gate01-baseline.json` remains frozen audit evidence, but its Node/Rust slice runner, alternate config, collator, and collator test are deleted so there is no executable path that can regenerate it by launching Node. @@ -640,11 +680,13 @@ Docker, and GitHub Actions. - [ ] **Step 1: Write the failing behavioral test** Add `selection-nonvacuity.test.ts` to import local/cloud configs and fixture - factories, asserting the one-project/literal-Rust contract, positive floors, - no legacy helper import (including the visible-first audit runner), no cloud - skip for Tasks 2-4 specs, and a provenance failure when a fake healthy process - reports a non-Rust runtime. Update current helper tests to expect only - `RustServer` construction. + factories, asserting the primary-project/literal-Rust contract across + chromium/firefox/webkit/continuity projects, positive floors, no browser legacy + helper import (including the visible-first audit runner), no unexplained cloud + skip, and a provenance failure when a fake healthy process reports a non-Rust + runtime. Require the mcp-qa skip to carry its local-only classification and + local test selector. Update browser helper tests to expect only `RustServer` + construction. - [ ] **Step 2: Run the test and verify the intended RED** @@ -655,9 +697,10 @@ Docker, and GitHub Actions. npm exec playwright -- test --config test/e2e-browser/playwright.config.ts --project=chromium --list ``` - Expected: FAIL because the default fixture is `legacy`, the `chromium` project - is not yet Rust-explicit, and legacy projects/helpers still exist. The list - command must not start a server. + Expected: the helper test FAILS because the default fixture is `legacy` and + legacy projects/helpers still exist. The list command may already pass before + implementation; it is a non-starting baseline/selection receipt, not the RED + assertion. - [ ] **Step 3: Add the minimal implementation** @@ -667,7 +710,11 @@ Docker, and GitHub Actions. baseline assertions and delete legacy-only expectations/spec registrations. Build `dist/client` and `target/release/freshell-server` in global setup. Point Electron remote-connect E2E and `perf:audit:visible-first`'s owned sample server - at `RustServer`. Delete the completed GATE-01 executable/collator while retaining + at `RustServer`. Move the oracle-only Node process constructor beside the oracle + and move its free-port/isolated-home imports to `server-fixture-support.ts`, so + deleting the browser `test-server.ts` does not break the intermediate commit. + Remove stale project/server comments from the third closed set. Delete the + completed GATE-01 executable/collator while retaining its JSON as frozen historical evidence; update helper-config, teardown, and leak comments/types to the new fixture names. @@ -680,8 +727,10 @@ Docker, and GitHub Actions. npm exec playwright -- test --config test/e2e-browser/playwright.config.ts --project=chromium --list ``` - Expected: PASS; output names only `[chromium]`, reports at least 308 tests in at - least 86 files, and contains no `legacy-chromium` or zero-test warning. + Expected: PASS; the explicitly selected output names `[chromium]`, reports at + least 308 tests in at least 86 files, and contains no `legacy-chromium` or + zero-test warning. Config inspection separately proves every retained project + is Rust-only. - [ ] **Step 5: Refactor while green** @@ -696,6 +745,7 @@ Docker, and GitHub Actions. ```bash ! rg -n "legacy-chromium|e2eServerKind|TestServer|test-server\.js|dist/server/index" test/e2e-browser test/e2e-electron --glob '!gate01-baseline.json' + npm run test:vitest -- run test/unit/port/oracle/external-handshake-t0.test.ts --config config/vitest/vitest.port.config.ts npm run test:e2e -- --project=chromium test/e2e-browser/specs/auth.spec.ts test/e2e-browser/specs/terminal-lifecycle.spec.ts test/e2e-browser/specs/server-restart-recovery.spec.ts test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts ``` @@ -706,7 +756,7 @@ Docker, and GitHub Actions. - [ ] **Step 7: Commit the task** ```bash - git add test/e2e-browser test/setup/e2e-browser-global-setup.ts test/e2e-electron/electron-app.test.ts + git add test/e2e-browser test/setup/e2e-browser-global-setup.ts test/e2e-electron/electron-app.test.ts port/oracle/harness/external-server.ts port/oracle/harness/legacy-node-server.ts git commit -m "test: make browser coverage Rust-only" ``` @@ -746,9 +796,15 @@ Docker, and GitHub Actions. - Modify: `port/oracle/harness/t2-live.ts` - Modify: `port/oracle/harness/t2-live-claude.ts` - Modify: `port/oracle/harness/t2-live-codex.ts` +- Delete: `port/oracle/harness/legacy-node-server.ts` - Delete: `port/oracle/harness/opencode-warm-proxy.ts` +- Delete: `port/oracle/baselines/pty/generate-pty-goldens.ts` +- Delete: `port/oracle/fixtures/generate-handshake-fixture.ts` - Create: `test/unit/port/oracle/rust-only-oracle-boundary.test.ts` - Modify: `test/unit/port/oracle/{external-handshake-t0,t0-equivalence-rust,t1-equivalence-rust,t1-batch-equivalence-rust,freshagent-wireshape-differential}.test.ts` +- Modify: `test/unit/port/oracle/{handshake-determinism-t0,pty-determinism-t1,t0-known-providers-discovery-rust}.test.ts` +- Modify: `test/unit/port/normalize.test.ts` +- Modify: `port/contract/nondeterministic-fields.md` - Move: `test/unit/port/oracle/t2-opencode-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-opencode-rust-baseline.test.ts` - Move: `test/unit/port/oracle/t2-claude-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-claude-rust-baseline.test.ts` - Move: `test/unit/port/oracle/t2-codex-equivalence-rust.test.ts` to `test/unit/port/oracle/t2-codex-rust-baseline.test.ts` @@ -790,6 +846,10 @@ Docker, and GitHub Actions. - `port/oracle/baselines/batch/*.json` likewise remain frozen byte goldens for `batch_wire_golden.rs`; the Node terminal-stream generator is deleted and the Rust test's mutation assertion keeps the fixture non-vacuous. +- Handshake and PTY fixtures likewise become frozen Rust-baseline provenance; + their Node-default generators are deleted rather than silently retargeted to + Rust. Determinism/discovery tests name Rust explicitly, and active protocol + documentation removes the retired `codingcli.*` family. - [ ] **Step 1: Write the failing behavioral test** @@ -806,7 +866,9 @@ Docker, and GitHub Actions. Add `rust-only-oracle-boundary.test.ts` as an always-running source/exports guard: it rejects a `node` target, warm-proxy module, legacy build command, or active read of `port/oracle/baselines/t2/*.json` even when live-provider gates - are off. It also rejects `listenersOn3001`/`ss`-based inspection; an assertion + are off. It also rejects the temporary oracle-local Node constructor and any + active handshake/PTY fixture generator, plus `listenersOn3001`/`ss`-based + inspection; an assertion that an allocated owned port is not 3001 remains allowed. - [ ] **Step 2: Run the test and verify the intended RED** @@ -835,13 +897,17 @@ Docker, and GitHub Actions. through `npm run test:vitest -- run ... --config config/vitest/vitest.config.ts`. Make the external oracle harness wrap the existing owned Rust fixture, delete - Node build/spawn/copy logic and original-side live generators, and reframe + its temporary `legacy-node-server.ts`, all Node build/spawn/copy logic, and + original-side live generators, and reframe current tests around Rust determinism plus committed goldens/fixtures. Preserve mutation tests and nonempty-capture assertions. Delete the Node extension manifest generator and document its committed output as frozen migration - provenance rather than an active regeneration workflow. Delete the Node batch - generator too and update the consuming Rust golden test's provenance comment; - keep its byte-mutation bite proof. Collapse each T2 harness to Rust-only owned + provenance rather than an active regeneration workflow. Delete the Node batch, + handshake, and PTY generators too and update consuming Rust tests/docs to call + those committed fixtures frozen provenance; keep byte/field-mutation bite + proofs. Update the explicitly listed determinism/discovery/normalize tests and + nondeterministic-field documentation so no Node default or `codingcli.*` + vocabulary remains active. Collapse each T2 harness to Rust-only owned startup, delete the OpenCode warm proxy, rename the three gated tests to Rust-baseline files, and replace original-fixture equality with invariant, positive-event, isolation, cost-ceiling, and cleanup assertions. Keep the old @@ -871,11 +937,12 @@ Docker, and GitHub Actions. Run: ```bash - ! rg -n "codingcli\.|getTerminalViewport|getTerminalScrollback|loadFreshAgent(ThreadTurns|TurnBody)" shared src crates/freshell-protocol crates/freshell-ws port/contract test/unit/port + ! rg -n "codingcli\.|getTerminalViewport|getTerminalScrollback|loadFreshAgent(ThreadTurns|TurnBody)" shared src crates/freshell-protocol crates/freshell-ws port/contract test/unit/port --glob '!oracle/rust-only-oracle-boundary.test.ts' ! rg -n "target: ['\"]node|FRESHELL_ORACLE_TARGET|build:server|dist/server/index|new TestServer|warmProxy|opencode-warm-proxy|baselines/t2" port/oracle/harness config/vitest/vitest.oracle.config.ts test/unit/port/oracle package.json --glob '!rust-only-oracle-boundary.test.ts' ! rg -n "listenersOn3001|ss .*3001|grep.*3001" port/oracle/harness test/unit/port/oracle --glob '!rust-only-oracle-boundary.test.ts' ! rg -n "server/extension-manifest|generate-manifest-oracle" port/contract package.json ! rg -n "server/terminal-stream|generate-batch-goldens" port/oracle/baselines/batch crates/freshell-terminal/tests/batch_wire_golden.rs + ! rg -n "legacy-node-server|generate-(pty-goldens|handshake-fixture)|target: ['\"]node" port/oracle test/unit/port port/contract --glob '!oracle/rust-only-oracle-boundary.test.ts' contract_hash_before="$(sha256sum port/contract/ws-message-inventory.json port/contract/ws-protocol.schema.json port/contract/ws-server-messages.schema.json)" npm run contract:generate contract_hash_after="$(sha256sum port/contract/ws-message-inventory.json port/contract/ws-protocol.schema.json port/contract/ws-server-messages.schema.json)" @@ -888,7 +955,7 @@ Docker, and GitHub Actions. - [ ] **Step 7: Commit the task** ```bash - git add shared/ws-protocol.ts crates/freshell-protocol crates/freshell-ws crates/freshell-terminal/tests/batch_wire_golden.rs crates/freshell-extensions/Cargo.toml crates/freshell-extensions/src/lib.rs crates/freshell-extensions/tests/oracle.rs src/lib/api.ts src/store/freshAgentThunks.ts test/unit/client/lib/api.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/helpers/visible-first test/unit/visible-first port/contract port/oracle/baselines/batch port/oracle/harness test/unit/port/oracle test/integration/port/oracle config/vitest/vitest.oracle.config.ts config/vitest/vitest.oracle-t2.config.ts package.json + git add shared/ws-protocol.ts crates/freshell-protocol crates/freshell-ws crates/freshell-terminal/tests/batch_wire_golden.rs crates/freshell-extensions/Cargo.toml crates/freshell-extensions/src/lib.rs crates/freshell-extensions/tests/oracle.rs src/lib/api.ts src/store/freshAgentThunks.ts test/unit/client/lib/api.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/helpers/visible-first test/unit/visible-first port/contract port/oracle test/unit/port test/integration/port/oracle config/vitest/vitest.oracle.config.ts config/vitest/vitest.oracle-t2.config.ts package.json git commit -m "refactor: retire Node-only contracts and oracles" ``` @@ -898,6 +965,8 @@ Docker, and GitHub Actions. - Create: `scripts/start-rust-server.ts` - Create: `scripts/testing/run-rust-tests.ts` +- Create: `scripts/testing/run-source-runtime-tests.ts` +- Create: `config/vitest/vitest.runtime.config.ts` - Create: `test/unit/tooling/testing/test-selection.test.ts` - Create: `test/integration/tooling/source-runtime-rust.test.ts` - Modify: `package.json` @@ -910,6 +979,7 @@ Docker, and GitHub Actions. - Modify: `scripts/testing/test-coordinator.ts` - Modify: `scripts/vitest-cloud.sh` - Modify: `scripts/test/cloud-vitest-wrapper.test.sh` +- Modify: `scripts/test/cloud-vitest-entrypoint.test.sh` - Modify: `docker/cloud-run/entrypoint.sh` - Modify: `config/vitest/vitest.config.ts` - Modify: `test/unit/vite-config.test.ts` @@ -947,13 +1017,21 @@ Docker, and GitHub Actions. laptop bootstrap invokes the Rust-inclusive build/start contract rather than inheriting a Node-server build path. - Broad `npm test`/`npm run check`/`npm run verify` cover retained default Vitest, - `cargo test --workspace --locked`, and Electron Vitest under one coordinator - gate. `test:server` runs the `freshell-server` crate; `test:integration` runs + an artifact-owning source-runtime phase, `cargo test --workspace --locked`, and + Electron Vitest under one coordinator gate. `test:server` runs the + `freshell-server` crate; `test:integration` runs `cargo test --workspace --tests --locked`; `test:unit` remains default `test/unit`. - No required runner uses `--passWithNoTests`. Cloud Vitest runs only the retained default config; `--config=server` is rejected with exit 2 and a Rust-lane hint. Cargo runs in the Rust lane. +- Default Vitest explicitly excludes `test/integration/tooling/**` and + `test/integration/electron/**`. `vitest.runtime.config.ts` includes only the + source-runtime integration tree and rejects zero selection. The + `test:source-runtime` wrapper builds `dist/client`, `dist/tools`, and release + `freshell-server` before running that config. Thus the Node-only + `typecheck-client.yml` default lane never inherits Rust/artifact prerequisites, + while the broad coordinator still owns the source runtime smoke explicitly. - The default config stops excluding `test/unit/visible-first/cli-command-harness.test.ts` and its selection is asserted. Its two obsolete Node route/mirror siblings were deleted in Task 5. @@ -975,7 +1053,9 @@ Docker, and GitHub Actions. server/real-provider Vitest configs and `--passWithNoTests`, removal of their now-invalid package scripts, and rejection of a simulated zero selected-test result. Require the retained visible-first CLI harness to be selected by the - default lane. Require the closed runtime manifest to reconcile root launchers + default lane and both artifact-dependent integration trees to be excluded from + it. Require the dedicated runtime config/wrapper to select the source smoke and + the broad coordinator to execute that phase. Require the closed runtime manifest to reconcile root launchers and `port/**` bootstrap owners. Add the owned source-runtime integration test described above. Change the Tauri smoke unit path to panic, not print SKIP, when no binary can be resolved. @@ -987,7 +1067,8 @@ Docker, and GitHub Actions. ```bash npm run build:client npm run build:server - npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling/run-standard-tests.test.ts test/unit/vite-config.test.ts test/integration/tooling/source-runtime-rust.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling/run-standard-tests.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/integration/tooling/source-runtime-rust.test.ts --config config/vitest/vitest.runtime.config.ts bash scripts/test/cloud-vitest-wrapper.test.sh cargo test -p freshell-tauri --locked --test server_spawn_smoke app_bound_spawn_health_reap_end_to_end -- --exact --nocapture ``` @@ -1000,7 +1081,11 @@ Docker, and GitHub Actions. Move retained non-server tests before removing exclusions/config. Implement the Rust phases and source scripts, delete server TypeScript build/typecheck/start scripts/config/global setup, and make cloud Vitest one truthful default-config - lane. Delete the four opt-in provider contracts and PTY harness that import the + lane. Exclude artifact-dependent integration trees from default discovery; + create the source-runtime-only config and prerequisite-owning wrapper, and add + that wrapper as a positive-count broad phase. Update both cloud wrapper and + cloud entrypoint shell tests to reject, rather than require, + `--passWithNoTests`. Delete the four opt-in provider contracts and PTY harness that import the legacy Codex/Claude/OpenCode runtime; they test external-provider or Node implementation behavior, not Freshell's retained Rust backend. Delete the two dedicated Node-backend real-provider configs/scripts; keep the two independent @@ -1022,7 +1107,8 @@ Docker, and GitHub Actions. npm run build:client npm run build:tools cargo build --release -p freshell-server --locked - npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling test/unit/claude-sidecar test/unit/contracts test/unit/provider-fixtures test/unit/visible-first/cli-command-harness.test.ts test/unit/vite-config.test.ts test/integration/tooling/source-runtime-rust.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling test/unit/claude-sidecar test/unit/contracts test/unit/provider-fixtures test/unit/visible-first/cli-command-harness.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:source-runtime bash scripts/test/cloud-vitest-wrapper.test.sh cargo build -p freshell-server --locked FRESHELL_SERVER_BIN="$PWD/target/debug/freshell-server" cargo test -p freshell-tauri --locked --test server_spawn_smoke app_bound_spawn_health_reap_end_to_end -- --exact --nocapture @@ -1045,7 +1131,7 @@ Docker, and GitHub Actions. Run: ```bash - ! rg -n "vitest\.(server|codex-real-provider-smoke|opencode-serve-real-provider-smoke)|server-global-setup|tsconfig\.server|tsx watch server|dist/server/index|--passWithNoTests|test:real:coding-cli-contracts|test:codex-real-provider-smoke|test:opencode-serve-smoke|npm start" package.json config scripts run-rust-server.sh port/laptop-bootstrap docker/cloud-run test/setup test/unit/tooling .github/workflows/rust-clippy.yml + ! rg -n "vitest\.(server|codex-real-provider-smoke|opencode-serve-real-provider-smoke)|server-global-setup|tsconfig\.server|tsx watch server|dist/server/index|--passWithNoTests|test:real:coding-cli-contracts|test:codex-real-provider-smoke|test:opencode-serve-smoke" package.json config scripts run-rust-server.sh port/laptop-bootstrap docker/cloud-run test/setup test/unit/tooling .github/workflows/rust-clippy.yml test ! -f tsconfig.server.json test ! -f config/vitest/vitest.server.config.ts test ! -f test/setup/server-global-setup.ts @@ -1054,8 +1140,8 @@ Docker, and GitHub Actions. ``` Expected: search returns no match; absence checks succeed; typecheck and the - coordinated broad test PASS with nonzero retained Vitest, Rust workspace, and - Electron phase counts. + coordinated broad test PASS with nonzero retained Vitest, source-runtime, + Rust workspace, and Electron phase counts. - [ ] **Step 7: Commit the task** @@ -1072,7 +1158,8 @@ Docker, and GitHub Actions. - Modify: `electron/startup.ts` - Modify: `electron/entry.ts` - Modify: `electron/{types,desktop-config,launch-policy,preload}.ts` -- Modify: `electron/setup-wizard/{wizard-logic,wizard}.tsx` +- Modify: `electron/setup-wizard/wizard-logic.ts` +- Modify: `electron/setup-wizard/wizard.tsx` - Delete: `electron/daemon/**` - Delete: `installers/systemd/freshell.service.template` - Delete: `installers/launchd/com.freshell.server.plist.template` @@ -1172,7 +1259,8 @@ Docker, and GitHub Actions. Run: ```bash - ! rg -n "server/index|NODE_PATH|server-node-modules|nativeModules|nodeBinary|serverEntry|serverMode.*daemon|Always-running daemon|createDaemonManager" electron config/electron-builder.yml + ! rg -n "server/index|NODE_PATH|server-node-modules|nativeModules|nodeBinary|serverEntry" electron + ! rg -n "serverMode.*daemon|Always-running daemon|createDaemonManager|electron/daemon|freshell\.(service\.template|task\.xml)|com\.freshell\.server" electron config/electron-builder.yml test ! -d electron/daemon test ! -e installers/systemd/freshell.service.template test -f installers/systemd/freshell-rust.service @@ -1202,12 +1290,14 @@ Docker, and GitHub Actions. - Create: `test/unit/electron/prepare-electron-runtime.test.ts` - Create: `test/unit/electron/verify-electron-artifact.test.ts` - Create: `test/integration/electron/checkout-free-runtime.test.ts` +- Create: `config/vitest/vitest.electron-runtime.config.ts` - Modify: `scripts/prepare-bundled-node.ts` by extracting reusable Node-download code, then delete it - Modify: `scripts/bundled-node-version.json` - Modify: `scripts/assert-native-windows-build.ts` - Modify: `config/electron-builder.yml` - Modify: `package.json` - Modify: `package-lock.json` +- Modify: `.gitignore` - Delete after migration: generated/staging assumptions for `server-node-modules` and `bundled-node/native-modules` - Delete/replace: `test/unit/electron/prepare-bundled-node.test.ts` @@ -1247,6 +1337,11 @@ Docker, and GitHub Actions. `node_modules`, authenticates to Rust server-info, fetches the SPA plus a real hashed asset, exercises the fake-Claude hook, speaks stdio JSON-RPC to the compiled MCP entry with no listening socket, and reaps every exact owned child. +- `vitest.electron-runtime.config.ts` includes only + `test/integration/electron/**`, uses the Node environment, and rejects zero + selection. `test:electron:runtime` requires the producer-owned staged runtime + and runs that config; default Vitest continues to exclude this artifact-bound + tree. `electron-runtime/` is ignored as generated staging output. - [ ] **Step 1: Write the failing behavioral test** @@ -1257,7 +1352,8 @@ Docker, and GitHub Actions. deliberate failures when it can see checkout files/root `node_modules`, MCP writes non-JSON-RPC stdout, or any owned PID survives. Change the Windows platform check message to require native Rust `.exe` production, not native - `node-pty` compilation. + `node-pty` compilation. Assert the dedicated config selects this integration + test and the default config does not; assert the staging directory is ignored. - [ ] **Step 2: Run the test and verify the intended RED** @@ -1265,10 +1361,12 @@ Docker, and GitHub Actions. ```bash npm run test:electron -- test/unit/electron/prepare-electron-runtime.test.ts test/unit/electron/verify-electron-artifact.test.ts test/unit/electron/native-windows-build-script.test.ts + npm run test:vitest -- run test/integration/electron/checkout-free-runtime.test.ts --config config/vitest/vitest.electron-runtime.config.ts ``` Expected: FAIL because staging/verifier modules do not exist and builder config - still requires Node-server/native-module resources. + still requires Node-server/native-module resources; the dedicated runtime + config/script is not implemented yet. - [ ] **Step 3: Add the minimal implementation** @@ -1278,7 +1376,9 @@ Docker, and GitHub Actions. their locked dependency closures. Preserve the locked archive libraries and extraction checks. Rewrite electron-builder resources and npm Electron scripts to use the staging directory and invoke the verifier on the unpacked result; - package only app-bound resources, with no Electron daemon templates. + package only app-bound resources, with no Electron daemon templates. Add the + isolated Electron-runtime Vitest config/script and ignore generated + `electron-runtime/` staging. - [ ] **Step 4: Run the focused GREEN command** @@ -1290,7 +1390,7 @@ Docker, and GitHub Actions. npm run build:tools cargo build --release -p freshell-server --locked npm run prepare:electron-runtime - npm run test:vitest -- run test/integration/electron/checkout-free-runtime.test.ts --config config/vitest/vitest.electron.config.ts + npm run test:electron:runtime ``` Expected: PASS; staging contains every allowlisted resource and none of the @@ -1310,8 +1410,10 @@ Docker, and GitHub Actions. ```bash ! rg -n "dist/server|server-node-modules|node-pty|native-modules|prepare-bundled-node" config/electron-builder.yml scripts package.json --glob '!verify-electron-artifact.ts' --glob '!prepare-electron-runtime.ts' + git check-ignore electron-runtime/ npm run electron:build npm run verify:electron-artifact + npm run test:electron:runtime ``` Expected: search returns no match; the native host build/verification PASS and @@ -1321,7 +1423,7 @@ Docker, and GitHub Actions. - [ ] **Step 7: Commit the task** ```bash - git add scripts/prepare-electron-runtime.ts scripts/verify-electron-artifact.ts scripts/assert-native-windows-build.ts scripts/bundled-node-version.json config/electron-builder.yml package.json package-lock.json test/unit/electron test/integration/electron + git add scripts/prepare-electron-runtime.ts scripts/verify-electron-artifact.ts scripts/assert-native-windows-build.ts scripts/bundled-node-version.json config/electron-builder.yml config/vitest/vitest.electron-runtime.config.ts package.json package-lock.json .gitignore test/unit/electron test/integration/electron git add -u scripts/prepare-bundled-node.ts git commit -m "build: package Rust backend in Electron" ``` @@ -1362,9 +1464,11 @@ Docker, and GitHub Actions. `cargo build -p freshell-server`, and `cargo test --workspace --locked` with `FRESHELL_SERVER_BIN` set for the non-skipping Tauri smoke. Retained Vitest and Electron tests have required jobs: `typecheck-client.yml` runs client - typecheck plus the nonempty default Vitest lane, `rust-clippy.yml` owns Cargo, - and `electron-build.yml` runs Electron Vitest before packaging on every matrix - OS. + typecheck plus the nonempty default Vitest lane, whose config explicitly + excludes artifact-dependent integration trees; `rust-clippy.yml` owns Cargo + plus the prerequisite-owning source-runtime smoke; and `electron-build.yml` + runs Electron unit tests, stages the artifact, then runs the isolated + checkout-free Electron runtime lane on every matrix OS. - Electron build/release matrix installs Rust 1.96.0, builds the native server, verifies each unpacked artifact, runs the checkout-free authenticated runtime acceptance (server-info, SPA asset, PTY creation/I/O, fake Claude, stdio MCP, @@ -1377,7 +1481,10 @@ Docker, and GitHub Actions. Add `distribution-runtime.test.ts` to parse Dockerfiles/workflows and require Rust entrypoints/build/test jobs, Electron `crates/**` path triggers, the four-target required native acceptance, artifact verification, and absence - of Node-server build or artifact names. Add `verify-container-layout.sh` + of Node-server build or artifact names. Require the typecheck workflow's + default lane to exclude artifact integrations, the Rust job to run the source + runtime wrapper, and Electron jobs to stage before the dedicated runtime lane. + Add `verify-container-layout.sh` fixture tests that fail a staged `dist/server/index.js` and accept the Rust/client/tools layout. @@ -1404,7 +1511,9 @@ Docker, and GitHub Actions. Electron path filters to `crates/**`, `Cargo.toml`, `Cargo.lock`, tools, and runtime scripts. Run Task 8's checkout-free acceptance against the unpacked native artifact in every matrix job, including an authenticated PTY round trip - and exact cleanup. Keep the permitted Node test/browser/MCP/Claude runtimes + and exact cleanup. Run Task 6's source-runtime wrapper in the Rust job after + its explicit build; do not add Rust/artifact prerequisites to the default + typecheck-client Vitest job. Keep the permitted Node test/browser/MCP/Claude runtimes explicit in comments and image checks. - [ ] **Step 4: Run the focused GREEN command** @@ -1464,7 +1573,10 @@ Docker, and GitHub Actions. - Delete: `test/server/**` - Delete: remaining `test/unit/server/**` - Delete: `test/integration/server/**` -- Delete: `test/integration/{session-repair,session-search-e2e}.test.ts` +- Delete: `test/integration/session-repair.test.ts` +- Move: `test/unit/server/title-utils.test.ts` to + `test/unit/shared/title-utils.test.ts` +- Modify: `test/unit/architecture/fresh-agent-only-runtime.test.ts` - Delete: `test/helpers/coding-cli/fake-codex-launch-planner.ts` - Delete: `test/fixtures/fresh-agent/claude/thread.ts` - Delete: `scripts/{find-corrupted,repair-one,repair-all}.ts` @@ -1503,6 +1615,12 @@ Docker, and GitHub Actions. - Deleted Node tests are not mechanically ported. Retained behavior stays covered by current Rust crate tests, default Vitest, Rust Playwright, Electron tests, and Tasks 1-9 regression tests. +- A test's directory does not decide its fate. Before deleting + `test/unit/server/**`, any subject whose implementation owner survives under + `shared/**`, `tools/**`, or another retained namespace is re-homed and kept; + `title-utils.test.ts` is the first explicit case. The disposition verifier + rejects treating a retained shared subject as obsolete merely because its old + test lived under `server/`. - `node-test-disposition.json` is a committed deletion ledger for the complete 346-file Task 5/6/10 candidate universe identified by the load-bearing review before deletion. Every old test path @@ -1524,7 +1642,9 @@ Docker, and GitHub Actions. detector unless an entrypoint actually listens or owns backend state. Add the disposition verifier with a synthetic mixed test whose second subject is unresolved, a zero-test selector receipt, and a skipped optional T2 receipt; - all three must fail required replacement closure. + all three must fail required replacement closure. Update + `fresh-agent-only-runtime.test.ts` expectations to remove `server` from the + required roots/allowances and prove every remaining scanned root exists. - [ ] **Step 2: Run the test and verify the intended RED** @@ -1543,7 +1663,11 @@ Docker, and GitHub Actions. disposition ledger from the closed Task 5/6/10 universe. Split mixed files by title/subject, bind each retained subject to an exact surviving test/lane and a positive-count receipt, mark obsolete Node-implementation subjects explicitly, - and resolve every row; the verifier refuses an unresolved or vacuous row. Then + and resolve every row; the verifier refuses an unresolved or vacuous row or a + deleted test of retained shared behavior. Re-home `title-utils.test.ts` and any + other ledger-identified retained shared/tool subject before the blanket delete. + Update the fresh-agent architecture walk to scan only existing retained roots + and remove server-only allowances. Then run a retained-fixture import scan and move any provider fixture still consumed by Rust/E2E to `test/fixtures/**`; Task 6 already removed the Node-runtime provider contracts while preserving the independent Amplifier @@ -1665,7 +1789,8 @@ Docker, and GitHub Actions. timestamp, commit, result, and owner sections. Re-run source/caller searches for attachments, exec/diff/send, editor open, extension lifecycle/assets, raw/WS browser forwarding, `/api/run`, paged turns, viewport/scrollback, - `codingcli.*`, and incident dump. For every reachable Rust-absent capability, + `codingcli.*`, incident dump, and the removed interactive precheck self-update + flow. For every reachable Rust-absent capability, run targeted `kata search --workspace "$PWD" --lexical --limit 20`, `kata list --workspace "$PWD" --json`, `gh issue list --repo danshapiro/freshell --state all --limit 500 --search`, and @@ -1676,9 +1801,9 @@ Docker, and GitHub Actions. exit code, and summarized result in the receipt: ```bash - rg -n "/api/(fresh-agent/(attachments|exec|diff|send)|files/open|extensions/.*/(start|assets)|proxy/forward|run)|codingcli\.|getTerminalViewport|getTerminalScrollback|loadFreshAgent(ThreadTurns|TurnBody)|debug/fresh-agent" src tools shared crates README.md AGENTS.md + rg -n "/api/(fresh-agent/(attachments|exec|diff|send)|files/open|extensions/.*/(start|assets)|proxy/forward|run)|codingcli\.|getTerminalViewport|getTerminalScrollback|loadFreshAgent(ThreadTurns|TurnBody)|debug/fresh-agent|runUpdateCheck|shouldSkipUpdateCheck" src tools shared crates scripts README.md AGENTS.md kata list --workspace "$PWD" --json - triage_terms=("fresh agent attachments" "fresh agent exec diff" "fresh agent send" "api run automation" "external editor reveal" "extension lifecycle assets" "browser proxy forwarding websocket" "session repair" "fresh agent paged turns" "terminal viewport scrollback" "codingcli websocket" "fresh agent incident") + triage_terms=("fresh agent attachments" "fresh agent exec diff" "fresh agent send" "api run automation" "external editor reveal" "extension lifecycle assets" "browser proxy forwarding websocket" "session repair" "fresh agent paged turns" "terminal viewport scrollback" "codingcli websocket" "fresh agent incident" "interactive precheck self update") for triage_term in "${triage_terms[@]}"; do kata search --workspace "$PWD" --lexical --limit 20 "$triage_term" --agent gh issue list --repo danshapiro/freshell --state all --limit 500 --search "$triage_term in:title,body" --json number,title,state,url @@ -1727,7 +1852,8 @@ Docker, and GitHub Actions. - [ ] **Step 6: Run full impacted and non-vacuity verification** - Run from the v2 worktree without any server on port 3001: + Run from the v2 worktree without contacting or depending on the live server on + port 3001: ```bash npm run test:status @@ -1748,13 +1874,13 @@ Docker, and GitHub Actions. npm run test:e2e:electron npm run electron:build npm run verify:electron-artifact - npm run test:vitest -- run test/integration/electron/checkout-free-runtime.test.ts --config config/vitest/vitest.electron.config.ts + npm run test:electron:runtime node --import tsx scripts/retirement/verify-node-test-disposition.ts docker build --tag freshell-retire-node-server-v2-cloud --file docker/cloud-run/Dockerfile . docker build --tag freshell-retire-node-server-v2-example --file examples/docker/Dockerfile . docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-cloud -c 'test -x /app/target/release/freshell-server && test -f /app/dist/client/index.html && test -f /app/dist/tools/freshell-mcp/server.js && test ! -e /app/dist/server && test ! -e /app/server && test ! -e /app/node_modules/node-pty' docker run --rm --entrypoint /bin/sh freshell-retire-node-server-v2-example -c 'test -x /app/freshell-server && test -f /app/dist/client/index.html && test ! -e /app/dist/server && test ! -e /app/server && test ! -e /app/node_modules' - ! rg -n "dist/server|server/index\.(ts|js)|tsx watch server|tsconfig\.server|server-node-modules|node-pty|legacy-chromium|npm start" package.json config scripts run-rust-server.sh port/laptop-bootstrap tools electron installers docker examples .github test/e2e-browser test/e2e-electron README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' + ! rg -n "dist/server|server/index\.(ts|js)|tsx watch server|tsconfig\.server|server-node-modules|node-pty|legacy-chromium" package.json config scripts run-rust-server.sh port/laptop-bootstrap tools electron installers docker examples .github test/e2e-browser test/e2e-electron README.md AGENTS.md .env.example docs/development/windows-electron-build.md docs/development/test-sandbox.md --glob '!scripts/retirement/runtime-boundary.ts' --glob '!scripts/verify-electron-artifact.ts' --glob '!scripts/prepare-electron-runtime.ts' --glob '!scripts/verify-container-layout.sh' test ! -d server test ! -d dist/server test ! -f tsconfig.server.json @@ -1764,7 +1890,8 @@ Docker, and GitHub Actions. Expected: all commands PASS; Playwright lists at least 308 tests in at least 86 files and no legacy project; full configured E2E has nonzero executed tests and - zero required skips; optional real-provider T2 tests are reported as + zero unexplained required skips, while the explicitly local-only MCP QA spec + has a positive local receipt; optional real-provider T2 tests are reported as supplemental rather than replacement coverage; Electron artifact works from a checkout-free staged copy with a runnable Rust server and no forbidden path; the disposition ledger has zero unresolved rows; rebuilt final container images contain no legacy source, From af09083899448477c04d40500ef4a20172d824b6 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:42:58 -0700 Subject: [PATCH 04/64] docs: repair Node retirement plan review gaps --- .../plans/2026-08-26-retire-node-server-v2.md | 175 +++++++++++++----- 1 file changed, 128 insertions(+), 47 deletions(-) diff --git a/docs/plans/2026-08-26-retire-node-server-v2.md b/docs/plans/2026-08-26-retire-node-server-v2.md index 3e708b297..3ff612f43 100644 --- a/docs/plans/2026-08-26-retire-node-server-v2.md +++ b/docs/plans/2026-08-26-retire-node-server-v2.md @@ -185,6 +185,8 @@ Docker, and GitHub Actions. - Modify: `scripts/precheck.ts` - Modify: `test/unit/vite-config.test.ts` - Modify: `test/e2e-browser/helpers/session-corpus/session-corpus.test.ts` +- Delete: `test/e2e/update-flow.test.ts` with the retired interactive updater + skip-contract fixtures - Modify: existing coordinator/precheck/tab-registry tests that import the moved owners **Interfaces:** @@ -195,10 +197,19 @@ Docker, and GitHub Actions. returns stable sorted repo-relative evidence. Every tracked executable, package command, service/template, container entrypoint, fixture server, release job, root launcher, and surviving `port/**` bootstrap owner must map to - exactly one manifest row; every row must resolve. Sanctioned Node roles are - explicit entrypoint/module rules, not directory-wide exclusions: backend - listeners, WebSocket servers, Freshell PTY ownership, or imports from - `server/**` still fail when placed under Electron, tools, scripts, or tests. + exactly one manifest row; every row must resolve. Each row declares its role. + Sanctioned Node roles are explicit entrypoint/module rules, not directory-wide + exclusions: Vite/Vitest/Electron-main/CLI/MCP/Claude-sidecar modules plus the + explicitly listed non-backend test infrastructure listeners are allowed. The + listener rows are `scripts/testing/coordinator-endpoint.ts`, + `test/e2e-browser/helpers/echo-ws-fixture.ts`, + `test/e2e-browser/helpers/harness-06/{target-server,update-feed,fake-ai}.ts`, + `test/e2e-browser/fixtures/providers/{fake-codex-app-server.mjs,fake-opencode-server.mjs}`, + `test/e2e-browser/fixtures/fake-opencode.cjs`, the individual + `scripts/proofs/browser-*-probe.ts` files, and `electron/port-check.ts`. + Those rows own only test coordination, probes, or fake targets and no Freshell + PTY/backend state. Backend listeners, WebSocket servers, Freshell PTY + ownership, or imports from `server/**` still fail outside those exact rows. - `getFreshellHomeDir(env)` and `getFreshellConfigDir(env)` preserve the current `FRESHELL_HOME`-then-home behavior without relying on the `NodeJS` global type; the two legacy `server/**` modules are temporary re-exports until Task 10. @@ -217,9 +228,10 @@ Docker, and GitHub Actions. Add `rust-only-server-runtime.test.ts` with a synthetic-tree test proving an invented Node HTTP listener is `unexpectedNodeBackend`, an allowlist test for - Vite/Vitest/Electron-main/CLI/MCP/Claude-sidecar Node roles, and manifest - reconciliation tests for an unlisted tracked owner, a stale row, and duplicate - ownership. The current-tree test requires the known debt entries + Vite/Vitest/Electron-main/CLI/MCP/Claude-sidecar roles and each exact + coordinator/fixture/probe listener row above, and manifest reconciliation tests + for an unlisted tracked owner, a stale row, and duplicate ownership. The + current-tree test requires the known debt entries `server/index.ts`, `package.json:scripts.start`, `config/electron-builder.yml:dist/server`, `test/e2e-browser/playwright.config.ts:legacy-chromium`, the stale legacy @@ -248,7 +260,10 @@ Docker, and GitHub Actions. moving the neutral code without changing its data semantics. Discovery is deliberately broader than the manifest and fails closed on a new root executable, package script, service resource, container command, test server, - workflow launch step, or retained `port/**` bootstrap path. Replace the + workflow launch step, or retained `port/**` bootstrap path. Classify the + explicitly listed coordinator/fixture/probe listeners as non-backend rows; + an unlisted listener or any listener that owns Freshell backend state remains + unexpected. Replace the coordinator import of `server/coding-cli/utils.ts`, the Vite import of `server/get-network-host.ts`, and the client import of `server/tabs-registry/types.ts`. Make `server/freshell-home.ts` and @@ -256,8 +271,11 @@ Docker, and GitHub Actions. neutral owners so the intermediate backend consumes the same contracts. Remove only the interactive update-check block/import from `scripts/precheck.ts`; preserve its serve-branch and port protections and record the removed flow for - Task 11 triage. Remove the two Node provider-reader imports/assertions from the - session-corpus helper test while preserving writer/schema/hash coverage; do not + Task 11 triage. Delete `test/e2e/update-flow.test.ts` and its + `--skip-update-check`/`SKIP_UPDATE_CHECK` fixtures because the interactive + updater no longer exists; do not leave a passing test for a removed behavior. + Remove the two Node provider-reader imports/assertions from the session-corpus + helper test while preserving writer/schema/hash coverage; do not move deleted backend readers into a neutral namespace. Keep a temporary explicit debt list so later tasks can remove entries one by one; manifest rows remain after their @@ -305,7 +323,7 @@ Docker, and GitHub Actions. - [ ] **Step 7: Commit the task** ```bash - git add scripts/retirement test/unit/architecture shared/tab-registry-types.ts shared/freshell-home.ts config/vite/get-network-host.ts scripts/testing/repo-context.ts src/store/tabRegistryTypes.ts server/tabs-registry/types.ts server/freshell-home.ts config/vite/vite.config.ts scripts/testing/test-coordinator.ts scripts/precheck.ts test/unit/vite-config.test.ts test/e2e-browser/helpers/session-corpus + git add scripts/retirement test/unit/architecture shared/tab-registry-types.ts shared/freshell-home.ts config/vite/get-network-host.ts scripts/testing/repo-context.ts src/store/tabRegistryTypes.ts server/tabs-registry/types.ts server/freshell-home.ts config/vite/vite.config.ts scripts/testing/test-coordinator.ts scripts/precheck.ts test/unit/vite-config.test.ts test/e2e/update-flow.test.ts test/e2e-browser/helpers/session-corpus git commit -m "refactor: isolate neutral code from Node server" ``` @@ -357,8 +375,10 @@ Docker, and GitHub Actions. - Unsupported rows/variants are: `run`; `fresh-send`; `attach`; `new-tab` with `agent` other than Rust-supported `opencode`; `split-pane` with any of `agent`, `model`, or `effort`; `wait-for` without a pattern or with - `stable|exit|prompt`; and legacy `capture` `J`/`e` arguments whose semantics - Rust ignores. Help and MCP parameter schemas do not advertise them. Direct + `stable|exit|prompt`. Rust-supported `capture` `J`/`e` arguments remain + accepted and advertised as no-op parameters, matching the current Rust + baseline. Help and MCP parameter schemas do not advertise the unsupported + rows above. Direct Claude/Codex terminals continue through supported `mode` values rather than the rejected `agent` sugar. - Replace the hard-coded-`node` args-only seam with @@ -634,6 +654,10 @@ Docker, and GitHub Actions. - Modify: `port/oracle/harness/external-server.ts` - Create temporarily: `port/oracle/harness/legacy-node-server.ts` from the oracle-only process-owning portion of `test-server.ts`; Task 5 deletes it +- Modify: the closed current set returned by + `rg -l "kind\\s*:\\s*['\"]legacy['\"]" test/e2e-browser/specs | sort`; + convert each remaining literal legacy server selection to the Rust baseline + fixture or delete the obsolete assertion - Modify: the closed current set of specs returned by `rg -l '\be2eServerKind\b' test/e2e-browser/specs | sort`; remove the obsolete fixture parameter and convert any executable legacy conditional to one @@ -661,9 +685,13 @@ Docker, and GitHub Actions. `port/oracle/harness/legacy-node-server.ts` so Task 4 stays green; Task 5 deletes that explicitly while converting oracles to Rust. - `playwright.config.ts` exposes one primary application project named - `chromium` with Rust fixtures. CI-only `firefox`/`webkit` projects inherit the - same Rust fixture contract, and `continuity-smoke` remains a Rust-only - specialized project without `e2eServerKind`; none is a Node/Rust split lane. + `chromium` with Rust fixtures. Its match-all application projects use an exact + `continuity-smoke.spec.ts` exclusion only; all other Rust-only specs formerly + covered by `RUST_ONLY_SPECS` run in the primary project. CI-only + `firefox`/`webkit` projects inherit the same Rust fixture contract and the same + continuity exclusion. `continuity-smoke` remains a separately selected, + Rust-only specialized project without `e2eServerKind`; none is a Node/Rust + split lane. There is no `legacy-chromium`, `rust-chromium`, `MATRIX_SPECS`, or browser-E2E Node `TestServer`. - Selection inspection requires at least 308 tests in at least 86 files (the @@ -681,9 +709,10 @@ Docker, and GitHub Actions. Add `selection-nonvacuity.test.ts` to import local/cloud configs and fixture factories, asserting the primary-project/literal-Rust contract across - chromium/firefox/webkit/continuity projects, positive floors, no browser legacy - helper import (including the visible-first audit runner), no unexplained cloud - skip, and a provenance failure when a fake healthy process reports a non-Rust + chromium/firefox/webkit/continuity projects, the exact continuity-only + exclusion on match-all projects, positive floors, no browser legacy helper + import (including the visible-first audit runner), no unexplained cloud skip, + and a provenance failure when a fake healthy process reports a non-Rust runtime. Require the mcp-qa skip to carry its local-only classification and local test selector. Update browser helper tests to expect only `RustServer` construction. @@ -705,9 +734,12 @@ Docker, and GitHub Actions. - [ ] **Step 3: Add the minimal implementation** Make Rust the only owned constructor, retain the external-target no-stop seam, - and move/rename shared types out of `test-server.ts`. Collapse Playwright to one - `chromium` project; convert conditional Rust branches to unconditional current - baseline assertions and delete legacy-only expectations/spec registrations. + and move/rename shared types out of `test-server.ts`. Collapse the application + lane to a match-all `chromium` project and replace `RUST_ONLY_SPECS` on every + match-all project with an exact `continuity-smoke.spec.ts` exclusion; keep the + separately selected `continuity-smoke` project so no Rust-only spec disappears. + Convert conditional Rust branches to unconditional current-baseline assertions + and delete legacy-only expectations/spec registrations. Build `dist/client` and `target/release/freshell-server` in global setup. Point Electron remote-connect E2E and `perf:audit:visible-first`'s owned sample server at `RustServer`. Move the oracle-only Node process constructor beside the oracle @@ -744,14 +776,17 @@ Docker, and GitHub Actions. Run: ```bash - ! rg -n "legacy-chromium|e2eServerKind|TestServer|test-server\.js|dist/server/index" test/e2e-browser test/e2e-electron --glob '!gate01-baseline.json' + ! rg -n "legacy-chromium|e2eServerKind|TestServer|test-server\.js|dist/server/index|kind[[:space:]]*:[[:space:]]*['\"]legacy['\"]" test/e2e-browser test/e2e-electron --glob '!gate01-baseline.json' npm run test:vitest -- run test/unit/port/oracle/external-handshake-t0.test.ts --config config/vitest/vitest.port.config.ts npm run test:e2e -- --project=chromium test/e2e-browser/specs/auth.spec.ts test/e2e-browser/specs/terminal-lifecycle.spec.ts test/e2e-browser/specs/server-restart-recovery.spec.ts test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts + npm run test:e2e -- --project=chromium ``` - Expected: search returns no executable legacy path; configured E2E reports a - positive count and PASSes, and server-info provenance in every worker identifies - the owned Rust binary on a non-3001 port. + Expected: search returns no executable legacy path; the focused and full + Chromium project runs report positive counts and PASS, and server-info + provenance in every worker identifies the owned Rust binary on a non-3001 port. + The full Chromium run excludes only `continuity-smoke.spec.ts`; its specialized + project remains separately selected and is not silently dropped. - [ ] **Step 7: Commit the task** @@ -863,6 +898,9 @@ Docker, and GitHub Actions. prove a changed expected verdict fails, without importing the deleted Node manifest generator. Rewrite the client fresh-agent WS cases to feed literal normalized Rust-baseline frames instead of importing Node provider adapters. + Delete the `acceptance-contract.test.ts` case that reads `package.json` and + pins the exact focused-lane script string; retain its behavioral contract + constant assertions and verify the real script by running it in Step 4. Add `rust-only-oracle-boundary.test.ts` as an always-running source/exports guard: it rejects a `node` target, warm-proxy module, legacy build command, or active read of `port/oracle/baselines/t2/*.json` even when live-provider gates @@ -878,6 +916,7 @@ Docker, and GitHub Actions. ```bash npm run test:vitest -- run test/unit/client/lib/api.test.ts --config config/vitest/vitest.config.ts npm run test:vitest -- run test/unit/visible-first/acceptance-contract.test.ts --config config/vitest/vitest.config.ts + npm run test:visible-first:contract npm run test:vitest -- run test/unit/port --config config/vitest/vitest.port.config.ts cargo test -p freshell-protocol -p freshell-ws -p freshell-terminal -p freshell-extensions --locked env -u FRESHELL_RUN_REAL_PROVIDER_CONTRACTS npm run test:oracle @@ -920,6 +959,9 @@ Docker, and GitHub Actions. Run the Step 2 commands again. + The `test:visible-first:contract` command is executed as a real lane; no test + reads package.json merely to assert the command's spelling. + Expected: PASS; schema generation has no drift, the Rust crates reject removed messages, client exports are gone, and every active always-running oracle starts/reaps only an owned Rust process on a non-3001 port. Opt-in T2 skips are @@ -996,6 +1038,12 @@ Docker, and GitHub Actions. - Delete: `test/integration/extension-system.test.ts` - Move: retained files from `test/unit/server/claude-sidecar/**` to `test/unit/claude-sidecar/**` - Move: retained coordinator/global-setup tests from `test/unit/server/testing/**` to `test/unit/tooling/testing/**` +- Create: `test/unit/shared/title-utils.test.ts` from the shared + `extractTitleFromMessage` subject in `test/unit/server/title-utils.test.ts` +- Move: `test/unit/server/tabs-registry/types.test.ts` to + `test/unit/shared/tab-registry-types.test.ts` +- Modify: `test/unit/server/title-utils.test.ts` to leave only the + backend-owned JSONL extraction subject for Task 10 disposition - Move: `test/unit/server/deploy-tab-diff-coverage-gate.test.ts` to `test/unit/tooling/deploy-tab-diff-coverage-gate.test.ts` - Move: `test/unit/server/prebuild-guard.test.ts` to `test/unit/tooling/prebuild-guard.test.ts` - Move: `test/unit/server/run-standard-tests.test.ts` to `test/unit/tooling/run-standard-tests.test.ts` @@ -1045,6 +1093,13 @@ Docker, and GitHub Actions. `freshell-server` child before exact-PID teardown. It uses `// @vitest-environment node` because it owns a child process and filesystem fixture. +- Every retained subject formerly under `test/unit/server/**` is re-homed before + this task removes the server Vitest config. The current subject inventory + explicitly splits the shared `extractTitleFromMessage` cases into + `test/unit/shared/title-utils.test.ts` and moves the tab-registry schema test to + `test/unit/shared/tab-registry-types.test.ts`; backend-only JSONL title parsing + remains a Task 10 deletion candidate. Any additional retained subject found by + the inventory must be moved in this task or it blocks config deletion. - [ ] **Step 1: Write the failing behavioral test** @@ -1056,7 +1111,9 @@ Docker, and GitHub Actions. default lane and both artifact-dependent integration trees to be excluded from it. Require the dedicated runtime config/wrapper to select the source smoke and the broad coordinator to execute that phase. Require the closed runtime manifest to reconcile root launchers - and `port/**` bootstrap owners. Add the owned source-runtime integration test + and `port/**` bootstrap owners. Require the subject inventory to report no + retained implementation owner left under `test/unit/server/**`, including the + title-utils split and tab-registry schema move. Add the owned source-runtime integration test described above. Change the Tauri smoke unit path to panic, not print SKIP, when no binary can be resolved. @@ -1078,7 +1135,14 @@ Docker, and GitHub Actions. - [ ] **Step 3: Add the minimal implementation** - Move retained non-server tests before removing exclusions/config. Implement the + Move retained non-server tests before removing exclusions/config. Split + `test/unit/server/title-utils.test.ts` by subject, moving only the + `extractTitleFromMessage` cases to `test/unit/shared/title-utils.test.ts`; keep + backend-owned `extractTitleFromJsonlObject` cases recorded for Task 10 deletion. + Move `test/unit/server/tabs-registry/types.test.ts` to + `test/unit/shared/tab-registry-types.test.ts`. The subject-level inventory must + then show no additional retained owner under `test/unit/server/**` before this + task removes the server config. Implement the Rust phases and source scripts, delete server TypeScript build/typecheck/start scripts/config/global setup, and make cloud Vitest one truthful default-config lane. Exclude artifact-dependent integration trees from default discovery; @@ -1107,7 +1171,7 @@ Docker, and GitHub Actions. npm run build:client npm run build:tools cargo build --release -p freshell-server --locked - npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling test/unit/claude-sidecar test/unit/contracts test/unit/provider-fixtures test/unit/visible-first/cli-command-harness.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts + npm run test:vitest -- run test/unit/tooling/testing test/unit/tooling test/unit/claude-sidecar test/unit/contracts test/unit/provider-fixtures test/unit/shared/title-utils.test.ts test/unit/shared/tab-registry-types.test.ts test/unit/visible-first/cli-command-harness.test.ts test/unit/vite-config.test.ts --config config/vitest/vitest.config.ts npm run test:source-runtime bash scripts/test/cloud-vitest-wrapper.test.sh cargo build -p freshell-server --locked @@ -1309,7 +1373,10 @@ Docker, and GitHub Actions. `electron-runtime/claude-sidecar/**`, and `electron-runtime/mcp/**`. The MCP directory contains `dist/tools/freshell-mcp`, shared compiled client modules, and only the locked - production dependency closure for `@modelcontextprotocol/sdk` and `zod`. + production dependency closure for `@modelcontextprotocol/sdk` and `zod`, plus + a minimal `package.json` whose `name` is `freshell` and whose version matches + the packaged release. The metadata is required because the checkout-free MCP + entry reports its version by walking to package metadata. - The Node binary is sanctioned for the Claude sidecar and standalone MCP client only. Staging contains no `node-pty`, Freshell Node backend entrypoint, `dist/server`, `server-node-modules`, or native-module rebuild output. The MCP @@ -1353,7 +1420,9 @@ Docker, and GitHub Actions. writes non-JSON-RPC stdout, or any owned PID survives. Change the Windows platform check message to require native Rust `.exe` production, not native `node-pty` compilation. Assert the dedicated config selects this integration - test and the default config does not; assert the staging directory is ignored. + test and the default config does not; assert the staging directory is ignored, + the staged MCP package metadata is present, and the checkout-free MCP + initialize response reports the staged package version rather than `0.0.0`. - [ ] **Step 2: Run the test and verify the intended RED** @@ -1373,7 +1442,9 @@ Docker, and GitHub Actions. Refactor the verified Node download to the new staging script, delete header and `node-pty` rebuild/pruned-server-dependency logic, copy the host-native Cargo binary, build/copy `dist/tools`, and stage the two permitted Node consumers with - their locked dependency closures. Preserve the locked archive libraries and + their locked dependency closures. Write the staged MCP `package.json` with the + release's `name: freshell` and version metadata so its initialize response is + stable outside a checkout. Preserve the locked archive libraries and extraction checks. Rewrite electron-builder resources and npm Electron scripts to use the staging directory and invoke the verifier on the unpacked result; package only app-bound resources, with no Electron daemon templates. Add the @@ -1574,8 +1645,8 @@ Docker, and GitHub Actions. - Delete: remaining `test/unit/server/**` - Delete: `test/integration/server/**` - Delete: `test/integration/session-repair.test.ts` -- Move: `test/unit/server/title-utils.test.ts` to - `test/unit/shared/title-utils.test.ts` +- Delete: backend-only remainder of `test/unit/server/title-utils.test.ts` after + the shared `extractTitleFromMessage` subject is re-homed in Task 6 - Modify: `test/unit/architecture/fresh-agent-only-runtime.test.ts` - Delete: `test/helpers/coding-cli/fake-codex-launch-planner.ts` - Delete: `test/fixtures/fresh-agent/claude/thread.ts` @@ -1618,7 +1689,7 @@ Docker, and GitHub Actions. - A test's directory does not decide its fate. Before deleting `test/unit/server/**`, any subject whose implementation owner survives under `shared/**`, `tools/**`, or another retained namespace is re-homed and kept; - `title-utils.test.ts` is the first explicit case. The disposition verifier + the shared `title-utils` subject is the first explicit case. The disposition verifier rejects treating a retained shared subject as obsolete merely because its old test lived under `server/`. - `node-test-disposition.json` is a committed deletion ledger for the complete @@ -1628,9 +1699,15 @@ Docker, and GitHub Actions. old path/title/subject, retained-or-deleted decision, exact surviving test, required lane, selector, and latest receipt. Optional real-provider T2 checks are marked supplemental and cannot satisfy a required replacement. Unknown, - duplicate, stale, or unresolved rows block deletion and the final gate. + duplicate, stale, or unresolved rows block deletion and the final gate. The + ledger also records the earlier Task 1 deletion of + `test/e2e/update-flow.test.ts` as an obsolete interactive-updater subject with + no replacement requirement. - Runtime guard debt shrinks to active documentation-only items left for Task 11; - `unexpectedNodeBackend` stays empty. + `unexpectedNodeBackend` stays empty. Its detector treats the exact + manifest-listed coordinator/fixture/probe listener rows from Task 1 as + sanctioned non-backend infrastructure; only an unlisted backend listener or + any listener that owns Freshell PTY/backend state is unexpected. - [ ] **Step 1: Write the failing behavioral test** @@ -1639,7 +1716,9 @@ Docker, and GitHub Actions. set absent from root direct dependencies, and require zero imports into `server/**`. Add a fixture that proves the allowed CLI/MCP/Claude Node packages and their locked transitive dependencies do not satisfy a Node-backend - detector unless an entrypoint actually listens or owns backend state. Add the + detector unless an unlisted entrypoint listens as a backend or owns backend + state; manifest-listed coordinator/fixture/probe listeners remain explicitly + allowed. Add the disposition verifier with a synthetic mixed test whose second subject is unresolved, a zero-test selector receipt, and a skipped optional T2 receipt; all three must fail required replacement closure. Update @@ -1664,8 +1743,10 @@ Docker, and GitHub Actions. title/subject, bind each retained subject to an exact surviving test/lane and a positive-count receipt, mark obsolete Node-implementation subjects explicitly, and resolve every row; the verifier refuses an unresolved or vacuous row or a - deleted test of retained shared behavior. Re-home `title-utils.test.ts` and any - other ledger-identified retained shared/tool subject before the blanket delete. + deleted test of retained shared behavior. Reconcile the Task 6 subject moves, + including the split `title-utils` test and tab-registry schema test, and verify + every other ledger-identified retained shared/tool subject was re-homed before + the blanket delete. Update the fresh-agent architecture walk to scan only existing retained roots and remove server-only allowances. Then run a retained-fixture import scan and move any provider fixture still @@ -1766,10 +1847,10 @@ Docker, and GitHub Actions. - [ ] **Step 1: Write the failing behavioral test** - Tighten `rust-only-server-runtime.test.ts` to require all three arrays empty and add - active-document assertions for Rust commands, retained standalone clients, and - forbidden Node-backend wording/paths. Add assertions that `docs/index.html` and - `.kata.toml` are unchanged from `origin/main`. + Tighten `rust-only-server-runtime.test.ts` to require all three arrays empty + based on executable/runtime manifest evidence. Do not add tests that only read + prose or configuration text; the final structural/document search and the + `git diff --exit-code` checks remain command-level gates in Steps 4 and 6. - [ ] **Step 2: Run the test and verify the intended RED** @@ -1818,9 +1899,9 @@ Docker, and GitHub Actions. messages/tests represented in active source; the receipt classifies each match and fails if it finds a request sender or advertised supported action. - If and only if contrary implementation evidence establishes a reachable or - safety-critical gap with independent Rust-only product value and all three - owner searches are empty, create one acceptance-sized Kata using priority 1, labels + If and only if contrary implementation evidence identifies an important + Rust-absent capability that is not tracked by any of the three owner searches, + create one acceptance-sized Kata using priority 1, labels `enhancement` and `rust-gap`, metadata `source=retire-node-server-v2`. Derive the idempotency-key slug from the lowercase ASCII capability name, collapse non-alphanumerics to single hyphens, From cc50c34f73e64df0753941d58b63c2c603397698 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:42:27 -0700 Subject: [PATCH 05/64] refactor: isolate neutral code from Node server --- config/vite/get-network-host.ts | 45 ++ config/vite/vite.config.ts | 13 +- scripts/precheck.ts | 39 +- scripts/retirement/runtime-boundary.ts | 424 ++++++++++++++++++ scripts/retirement/runtime-surfaces.json | 109 +++++ scripts/testing/repo-context.ts | 274 +++++++++++ scripts/testing/test-coordinator.ts | 2 +- server/freshell-home.ts | 20 +- server/tabs-registry/types.ts | 107 +---- shared/freshell-home.ts | 17 + shared/tab-registry-types.ts | 91 ++++ src/store/tabRegistryTypes.ts | 4 +- .../helpers/session-corpus/git-layout.ts | 2 +- .../session-corpus/session-corpus.test.ts | 47 +- test/e2e/update-flow.test.ts | 127 ------ .../fresh-agent-only-runtime.test.ts | 67 +-- .../rust-only-server-runtime.test.ts | 243 ++++++++++ test/unit/vite-config.test.ts | 29 +- 18 files changed, 1286 insertions(+), 374 deletions(-) create mode 100644 config/vite/get-network-host.ts create mode 100644 scripts/retirement/runtime-boundary.ts create mode 100644 scripts/retirement/runtime-surfaces.json create mode 100644 scripts/testing/repo-context.ts create mode 100644 shared/freshell-home.ts create mode 100644 shared/tab-registry-types.ts delete mode 100644 test/e2e/update-flow.test.ts create mode 100644 test/unit/architecture/rust-only-server-runtime.test.ts diff --git a/config/vite/get-network-host.ts b/config/vite/get-network-host.ts new file mode 100644 index 000000000..ad1cd55c9 --- /dev/null +++ b/config/vite/get-network-host.ts @@ -0,0 +1,45 @@ +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 +} + +/** + * 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..ddbe4f411 100644 --- a/config/vite/vite.config.ts +++ b/config/vite/vite.config.ts @@ -4,7 +4,9 @@ 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 { isWSL } from '../../server/platform.js' +import { getNetworkHost } from './get-network-host.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -64,7 +66,8 @@ function silenceStartupErrors(proxy: HttpProxy.Server) { } export default defineConfig(({ mode }) => { - const env = loadEnv(mode, projectRoot, '') + // Vite reads .env into `env`; process.env remains the explicit override. + const env = { ...loadEnv(mode, projectRoot, ''), ...process.env } const backendPort = process.env.PORT || env.PORT || '3001' const backendHost = process.env.VITE_BACKEND_HOST || process.env.BACKEND_HOST || env.VITE_BACKEND_HOST || env.BACKEND_HOST || '127.0.0.1' const backendUrl = `http://${backendHost}:${backendPort}` @@ -93,7 +96,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/scripts/precheck.ts b/scripts/precheck.ts index 6553843e1..23848cbbb 100644 --- a/scripts/precheck.ts +++ b/scripts/precheck.ts @@ -4,9 +4,8 @@ * Pre-flight check before starting dev/serve. * * Checks (in order): - * 1. Update availability - prompts user to update if newer version exists - * 2. Missing dependencies - ensures node_modules has all required packages - * 3. Port conflicts - detects if freshell is already running + * 1. Missing dependencies - ensures node_modules has all required packages + * 2. Port conflicts - detects if freshell is already running */ import { readFileSync } from 'fs' @@ -15,23 +14,11 @@ import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' import { createRequire } from 'module' import { createInterface } from 'readline/promises' -import { runUpdateCheck, shouldSkipUpdateCheck } from '../server/updater/index.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const rootDir = resolve(__dirname, '..') const workspaceRequire = createRequire(resolve(rootDir, 'package.json')) -// Load package.json for version -function getPackageVersion(): string { - try { - const pkgPath = resolve(rootDir, 'package.json') - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) - return pkg.version || '0.0.0' - } catch { - return '0.0.0' - } -} - function getCurrentBranch(): string | undefined { try { return execFileSync('git', ['branch', '--show-current'], { @@ -246,25 +233,7 @@ async function main(): Promise { const currentBranch = getCurrentBranch() await confirmServeBranchIfNeeded(currentBranch) - // 1. Check for updates first (before anything else can fail) - if (!shouldSkipUpdateCheck({ branch: currentBranch })) { - const currentVersion = getPackageVersion() - const updateResult = await runUpdateCheck(currentVersion) - - if (updateResult.action === 'updated') { - // Update succeeded - it already ran npm install and build - // Exit with special code to signal caller that update happened - console.log('\n\x1b[32m✓ Update complete!\x1b[0m Restart freshell to use the new version.\n') - process.exit(0) - } - - if (updateResult.action === 'error') { - console.error(`\n\x1b[33m⚠ Update failed: ${updateResult.error}\x1b[0m`) - console.error('Continuing with current version...\n') - } - } - - // 2. Check for missing dependencies + // 1. Check for missing dependencies const missingDeps = checkMissingDependencies() if (missingDeps.length > 0) { console.error('\n\x1b[31m✖ Missing dependencies detected:\x1b[0m\n') @@ -277,7 +246,7 @@ async function main(): Promise { process.exit(1) } - // 3. Check for port conflicts + // 2. Check for port conflicts // Only check Vite port in dev mode (predev), not production (serve:precheck) const isDevMode = process.env.npm_lifecycle_event === 'predev' diff --git a/scripts/retirement/runtime-boundary.ts b/scripts/retirement/runtime-boundary.ts new file mode 100644 index 000000000..352466622 --- /dev/null +++ b/scripts/retirement/runtime-boundary.ts @@ -0,0 +1,424 @@ +import { readFile, readdir, stat } from 'node:fs/promises' +import path from 'node:path' + +/** + * A runtime surface is an executable or resource that must have one owner in + * the retirement manifest. Paths are repository-relative POSIX paths. A + * package-script surface uses `package.json:scripts` and lists its command + * names in `entries` so adding a command is visible as manifest drift. + */ +export type RuntimeSurface = { + id: string + path: string + role: string + listener?: 'non-backend' | 'legacy-backend' + entries?: string[] +} + +export type RuntimeSurfaceManifest = { + version: 1 + surfaces: RuntimeSurface[] +} + +export type RuntimeBoundaryAnalysis = { + manifestDrift: string[] + legacyDebt: string[] + unexpectedNodeBackend: string[] +} + +const MANIFEST_RELATIVE_PATH = 'scripts/retirement/runtime-surfaces.json' +const PACKAGE_SCRIPTS_PATH = 'package.json:scripts' + +const ignoredDirectoryNames = new Set([ + '.git', + '.claude', + '.worktrees', + 'dist', + 'node_modules', + 'target', +]) + +const ignoredPathPrefixes = [ + 'docs/plans/', + 'docs/reports/', + 'docs/evidence/', +] as const + +const sourceExtensions = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']) + +/** + * These files are deliberately allowed to bind a local port. They are test + * coordination, fake targets, or probes; none owns Freshell PTYs or backend + * state. Keep this list exact instead of allowing an entire directory. + */ +export const NON_BACKEND_LISTENER_PATHS = [ + 'scripts/testing/coordinator-endpoint.ts', + 'test/e2e-browser/helpers/echo-ws-fixture.ts', + 'test/e2e-browser/helpers/harness-06/target-server.ts', + 'test/e2e-browser/helpers/harness-06/update-feed.ts', + 'test/e2e-browser/helpers/harness-06/fake-ai.ts', + 'test/e2e-browser/fixtures/providers/fake-codex-app-server.mjs', + 'test/e2e-browser/fixtures/providers/fake-opencode-server.mjs', + 'test/e2e-browser/fixtures/fake-opencode.cjs', + 'scripts/proofs/browser-background-visibility-probe.ts', + 'scripts/proofs/browser-freeze-lifecycle-probe.ts', + 'scripts/proofs/browser-process-suspend-probe.ts', + 'electron/port-check.ts', +] as const + +const nonBackendListenerPaths = new Set(NON_BACKEND_LISTENER_PATHS) + +const legacyDebtChecks: Array<{ + relativePath: string + marker: string + matches: (contents: string) => boolean +}> = [ + { + relativePath: 'server/index.ts', + marker: 'server/index.ts', + matches: () => true, + }, + { + relativePath: 'package.json', + marker: 'package.json:scripts.start', + matches: (contents) => { + try { + const pkg = JSON.parse(contents) as { scripts?: Record } + return typeof pkg.scripts?.start === 'string' && /dist\/server|server\/index\.(?:ts|js)/.test(pkg.scripts.start) + } catch { + return false + } + }, + }, + { + relativePath: 'config/electron-builder.yml', + marker: 'config/electron-builder.yml:dist/server', + matches: (contents) => contents.includes('dist/server'), + }, + { + relativePath: 'test/e2e-browser/playwright.config.ts', + marker: 'test/e2e-browser/playwright.config.ts:legacy-chromium', + matches: (contents) => contents.includes('legacy-chromium'), + }, + { + relativePath: 'run-rust-server.sh', + marker: 'run-rust-server.sh:legacy-comment', + matches: (contents) => /^\s*#.*\bLegacy server:/im.test(contents), + }, + { + relativePath: 'port/laptop-bootstrap/2-bootstrap-wsl.sh', + marker: 'port/laptop-bootstrap/2-bootstrap-wsl.sh:inherited-build-path', + matches: (contents) => /npm run build/.test(contents), + }, +] + +function normalizeRelativePath(relativePath: string): string { + return relativePath.split(path.sep).join('/').replace(/^\.\//, '') +} + +function validateManifestPath(relativePath: string, id: string): string { + const normalized = normalizeRelativePath(relativePath) + if ( + path.posix.isAbsolute(normalized) + || /^[A-Za-z]:\//.test(normalized) + || normalized === '..' + || normalized.startsWith('../') + ) { + throw new Error(`Runtime surface manifest row ${id} must use a repository-relative path.`) + } + return normalized +} + +function isIgnoredRelativePath(relativePath: string): boolean { + const normalized = normalizeRelativePath(relativePath) + return ignoredPathPrefixes.some((prefix) => normalized.startsWith(prefix)) +} + +function isSourcePath(relativePath: string): boolean { + return sourceExtensions.has(path.posix.extname(relativePath)) +} + +function isExecutableMode(mode: number): boolean { + return (mode & 0o111) !== 0 +} + +function isServiceResourcePath(relativePath: string): boolean { + return relativePath.startsWith('installers/') + && /\.(?:service|plist|xml)(?:\.template)?$/.test(relativePath) +} + +function isContainerResourcePath(relativePath: string): boolean { + const basename = path.posix.basename(relativePath) + return /^Dockerfile(?:\..*)?$/.test(basename) + || (relativePath.startsWith('docker/') && /\.(?:yml|yaml)$/.test(relativePath)) +} + +function isReleaseJobPath(relativePath: string): boolean { + return relativePath.startsWith('.github/workflows/') && /\.ya?ml$/.test(relativePath) +} + +function isFixtureServerPath(relativePath: string): boolean { + if (!relativePath.startsWith('test/')) return false + const basename = path.posix.basename(relativePath) + if (!/(?:^|[-_.])server(?:[-_.]|$)/i.test(basename)) return false + if (!/\.(?:cjs|js|mjs|ts|tsx)$/.test(relativePath)) return false + if (/\.(?:test|spec)\.(?:cjs|js|mjs|ts|tsx)$/.test(basename)) return false + return relativePath.startsWith('test/fixtures/') + || relativePath.startsWith('test/e2e-browser/helpers/') +} + +/** Read and validate the manifest without allowing malformed rows to vanish. */ +export async function loadRuntimeSurfaceManifest(root: string): Promise { + const manifestPath = path.join(root, ...MANIFEST_RELATIVE_PATH.split('/')) + const raw = JSON.parse(await readFile(manifestPath, 'utf8')) as Partial + if (raw.version !== 1 || !Array.isArray(raw.surfaces)) { + throw new Error(`Runtime surface manifest must have version 1 and a surfaces array: ${manifestPath}`) + } + + const surfaces: RuntimeSurface[] = [] + for (const [index, candidate] of raw.surfaces.entries()) { + if (!candidate || typeof candidate !== 'object') { + throw new Error(`Runtime surface manifest row ${index} is not an object.`) + } + const row = candidate as Partial + if (typeof row.id !== 'string' || !row.id || typeof row.path !== 'string' || !row.path || typeof row.role !== 'string' || !row.role) { + throw new Error(`Runtime surface manifest row ${index} requires id, path, and role.`) + } + if (row.listener !== undefined && row.listener !== 'non-backend' && row.listener !== 'legacy-backend') { + throw new Error(`Runtime surface manifest row ${row.id} has an invalid listener role.`) + } + if (row.entries !== undefined && (!Array.isArray(row.entries) || row.entries.some((entry) => typeof entry !== 'string'))) { + throw new Error(`Runtime surface manifest row ${row.id} has invalid entries.`) + } + surfaces.push({ + id: row.id, + path: validateManifestPath(row.path, row.id), + role: row.role, + ...(row.listener ? { listener: row.listener } : {}), + ...(row.entries ? { entries: [...row.entries].sort() } : {}), + }) + } + + return { version: 1, surfaces } +} + +async function walkFiles(root: string, current = root): Promise { + const entries = await readdir(current, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + if (entry.isSymbolicLink() && entry.name === 'node_modules') continue + if (entry.isDirectory() && ignoredDirectoryNames.has(entry.name)) continue + + const absolutePath = path.join(current, entry.name) + const relativePath = normalizeRelativePath(path.relative(root, absolutePath)) + if (isIgnoredRelativePath(relativePath)) continue + + if (entry.isDirectory()) { + files.push(...await walkFiles(root, absolutePath)) + continue + } + if (entry.isFile()) files.push(relativePath) + } + + return files.sort() +} + +/** + * Discover launch/resource owners conservatively from executable bits and + * known launch categories. The closed manifest then supplies the ownership + * decision; discovery must never silently accept a new executable. + */ +async function discoverOwners(root: string, allFiles: string[]): Promise { + const owners = new Set() + + for (const relativePath of allFiles) { + const absolutePath = path.join(root, ...relativePath.split('/')) + const fileStat = await stat(absolutePath) + const rootLauncher = !relativePath.includes('/') && relativePath.endsWith('.sh') + const portBootstrap = relativePath.startsWith('port/') + && /\.(?:sh|cmd|ps1)$/.test(relativePath) + if ( + isExecutableMode(fileStat.mode) + || rootLauncher + || portBootstrap + || isServiceResourcePath(relativePath) + || isContainerResourcePath(relativePath) + || isReleaseJobPath(relativePath) + || isFixtureServerPath(relativePath) + ) { + owners.add(relativePath) + } + if (nonBackendListenerPaths.has(relativePath)) owners.add(relativePath) + } + + const packagePath = path.join(root, 'package.json') + try { + const packageContents = await readFile(packagePath, 'utf8') + const packageJson = JSON.parse(packageContents) as { scripts?: unknown } + if (packageJson.scripts && typeof packageJson.scripts === 'object') { + owners.add(PACKAGE_SCRIPTS_PATH) + } + } catch { + // A synthetic tree may intentionally omit package.json. + } + + return [...owners].sort() +} + +function rowPath(row: RuntimeSurface): string { + return normalizeRelativePath(row.path) +} + +async function pathExists(root: string, relativePath: string): Promise { + try { + await stat(path.join(root, ...relativePath.split('/'))) + return true + } catch { + return false + } +} + +async function packageScriptNames(root: string): Promise { + try { + const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')) as { scripts?: unknown } + if (!packageJson.scripts || typeof packageJson.scripts !== 'object') return [] + return Object.keys(packageJson.scripts as Record).sort() + } catch { + return [] + } +} + +async function reconcileManifest(root: string, manifest: RuntimeSurfaceManifest, discoveredOwners: string[]): Promise { + const drift: string[] = [] + const rowsByPath = new Map() + + for (const row of manifest.surfaces) { + const normalizedPath = rowPath(row) + const rows = rowsByPath.get(normalizedPath) ?? [] + rows.push(row) + rowsByPath.set(normalizedPath, rows) + } + + for (const [relativePath, rows] of rowsByPath) { + if (rows.length > 1) { + const ids = rows.map((row) => row.id).sort().join(', ') + drift.push(`duplicate ownership: ${relativePath} (${ids})`) + } + } + + for (const row of manifest.surfaces) { + const relativePath = rowPath(row) + if (relativePath === PACKAGE_SCRIPTS_PATH) { + const actualNames = await packageScriptNames(root) + const expectedNames = row.entries ?? [] + const expected = new Set(expectedNames) + for (const name of actualNames) { + if (!expected.has(name)) drift.push(`unlisted owner: package.json:scripts.${name}`) + } + for (const name of expectedNames) { + if (!actualNames.includes(name)) drift.push(`stale manifest row: package.json:scripts.${name}`) + } + if (!await pathExists(root, 'package.json')) { + drift.push(`stale manifest row: ${row.id} -> ${relativePath}`) + } + continue + } + + if (!await pathExists(root, relativePath)) { + drift.push(`stale manifest row: ${row.id} -> ${relativePath}`) + } + } + + for (const owner of discoveredOwners) { + const rows = rowsByPath.get(owner) ?? [] + if (rows.length === 0) drift.push(`unlisted owner: ${owner}`) + } + + return drift +} + +function hasListenerCapability(contents: string): boolean { + // Require an actual networking/listener API rather than treating every + // object with a `server` property as a backend. Comments are intentionally + // retained: a newly documented launch path should still be reviewed. + return /(?:\b(?:http|https|net|tls)\.createServer\s*\(|\bcreateServer\s*\(|\bnew\s+WebSocketServer\b|\.listen\s*\(|\bserver\.listen\s*\()/.test(contents) +} + +function isCapabilityScanPath(relativePath: string): boolean { + if (isIgnoredRelativePath(relativePath) || !isSourcePath(relativePath)) return false + if (relativePath.startsWith('test/')) return nonBackendListenerPaths.has(relativePath) + return [ + 'config/', + 'crates/', + 'electron/', + 'port/', + 'scripts/', + 'server/', + 'shared/', + 'src/', + 'tools/', + ].some((prefix) => relativePath.startsWith(prefix)) +} + +async function detectUnexpectedNodeBackend( + root: string, + allFiles: string[], + manifest: RuntimeSurfaceManifest, +): Promise { + const unexpected: string[] = [] + const rowsByPath = new Map(manifest.surfaces.map((row) => [rowPath(row), row])) + + for (const relativePath of allFiles) { + if (!isCapabilityScanPath(relativePath)) continue + const contents = await readFile(path.join(root, ...relativePath.split('/')), 'utf8') + if (!hasListenerCapability(contents)) continue + + const row = rowsByPath.get(relativePath) + // The allowlist is intentionally closed. A manifest row cannot broaden + // it by relabeling an arbitrary listener as non-backend. + if (nonBackendListenerPaths.has(relativePath)) continue + if (row?.listener === 'legacy-backend') continue + unexpected.push(relativePath) + } + + return unexpected.sort() +} + +async function detectLegacyDebt(root: string): Promise { + const debt: string[] = [] + for (const check of legacyDebtChecks) { + const absolutePath = path.join(root, ...check.relativePath.split('/')) + try { + const contents = await readFile(absolutePath, 'utf8') + if (check.matches(contents)) debt.push(check.marker) + } catch { + // A later retirement task may remove the legacy path entirely. + } + } + return debt.sort() +} + +/** + * Reconcile the checked-in runtime surface inventory and return deterministic + * evidence for the three retirement gates. This function is read-only and + * never contacts a server, starts a process, or reads environment secrets. + */ +export async function analyzeRuntimeBoundary(root: string): Promise { + const normalizedRoot = path.resolve(root) + const manifest = await loadRuntimeSurfaceManifest(normalizedRoot) + const allFiles = await walkFiles(normalizedRoot) + const discoveredOwners = await discoverOwners(normalizedRoot, allFiles) + + const [manifestDrift, legacyDebt, unexpectedNodeBackend] = await Promise.all([ + reconcileManifest(normalizedRoot, manifest, discoveredOwners), + detectLegacyDebt(normalizedRoot), + detectUnexpectedNodeBackend(normalizedRoot, allFiles, manifest), + ]) + + return { + manifestDrift: [...new Set(manifestDrift)].sort(), + legacyDebt: [...new Set(legacyDebt)].sort(), + unexpectedNodeBackend: [...new Set(unexpectedNodeBackend)].sort(), + } +} diff --git a/scripts/retirement/runtime-surfaces.json b/scripts/retirement/runtime-surfaces.json new file mode 100644 index 000000000..3826a4820 --- /dev/null +++ b/scripts/retirement/runtime-surfaces.json @@ -0,0 +1,109 @@ +{ + "version": 1, + "surfaces": [ + { + "id": "package-scripts", + "path": "package.json:scripts", + "role": "package-commands", + "entries": [ + "predev", "dev", "dev:client", "dev:server", "typecheck", "typecheck:client", "typecheck:server", + "prebuild", "build", "build:client", "build:server", "build:electron", "build:wizard", "build:launch-chooser", + "dev:wizard", "dev:launch-chooser", "electron:dev", "electron:build", "electron:build:win", "prepare:bundled-node", + "start", "serve:precheck", "serve", "test", "test:balanced", "test:sequential", "test:aggressive", + "test:client:standard", "test:server:standard", "test:client:aggressive", "test:server:aggressive", "test:electron", + "verify", "check", "test:watch", "test:ui", "test:server", "test:coverage", "test:unit", "test:integration", + "test:client", "test:real:coding-cli-contracts", "test:visible-first:contract", "test:all", "test:status", "test:vitest", + "test:codex-real-provider-smoke", "test:opencode-serve-smoke", "smoke:browser-use", "smoke:continuity", "prepare:icons", + "lint", "lint:fix", "test:e2e", "test:e2e:local", "test:e2e:cloud", "test:e2e:cloud:build", "test:cloud", + "test:cloud:build", "test:e2e:chromium", "test:e2e:headed", "test:e2e:update-snapshots", "test:e2e:debug", + "test:e2e:helpers", "test:e2e:a11y-gate", "test:e2e:a11y-gate:deny", "test:e2e:electron", "perf:audit:visible-first", + "visible-first:contract:check", "perf:audit:compare", "perf:audit:gate", "contract:generate", "test:port", "test:oracle", + "test:oracle:t2", "test:sandbox" + ] + }, + + { "id": "root-run-rust-server", "path": "run-rust-server.sh", "role": "root-launcher" }, + { "id": "docker-cloud-entrypoint", "path": "docker/cloud-run/entrypoint.sh", "role": "container-entrypoint" }, + { "id": "docker-sandbox-entrypoint", "path": "docker/sandbox/entrypoint.sh", "role": "container-entrypoint" }, + { "id": "scripts-base-gate", "path": "scripts/base-gate.sh", "role": "test-coordinator" }, + { "id": "scripts-build-stamp-check", "path": "scripts/build-stamp-check.sh", "role": "build-tool" }, + { "id": "scripts-deploy-tab-diff", "path": "scripts/deploy-tab-diff.sh", "role": "release-tool" }, + { "id": "scripts-e2e-cloud", "path": "scripts/e2e-cloud.sh", "role": "test-runner" }, + { "id": "scripts-launch-rust", "path": "scripts/launch-rust.sh", "role": "rust-launcher" }, + { "id": "scripts-launch", "path": "scripts/launch.sh", "role": "rust-launcher" }, + { "id": "scripts-sandbox-build", "path": "scripts/sandbox-build.sh", "role": "test-sandbox" }, + { "id": "scripts-sandbox-selftest", "path": "scripts/sandbox-selftest.sh", "role": "test-sandbox" }, + { "id": "scripts-sandbox-test", "path": "scripts/sandbox-test.sh", "role": "test-sandbox" }, + { "id": "scripts-cloud-build-test", "path": "scripts/test/cloud-build.test.sh", "role": "test-coordinator" }, + { "id": "scripts-cloud-exec-id-test", "path": "scripts/test/cloud-exec-id-parse.test.sh", "role": "test-coordinator" }, + { "id": "scripts-cloud-vitest-entrypoint-test", "path": "scripts/test/cloud-vitest-entrypoint.test.sh", "role": "test-coordinator" }, + { "id": "scripts-cloud-vitest-integration-test", "path": "scripts/test/cloud-vitest-integration.test.sh", "role": "test-coordinator" }, + { "id": "scripts-cloud-vitest-wrapper-test", "path": "scripts/test/cloud-vitest-wrapper.test.sh", "role": "test-coordinator" }, + { "id": "scripts-verify-remote-access", "path": "scripts/verify-remote-access.sh", "role": "diagnostic-tool" }, + { "id": "scripts-vitest-cloud", "path": "scripts/vitest-cloud.sh", "role": "test-runner" }, + + { "id": "container-cloud-run-dockerfile", "path": "docker/cloud-run/Dockerfile", "role": "container-image" }, + { "id": "container-cloud-run-build", "path": "docker/cloud-run/cloudbuild.yaml", "role": "container-release-job" }, + { "id": "container-sandbox-dockerfile", "path": "docker/sandbox/Dockerfile", "role": "container-image" }, + { "id": "container-example-dockerfile", "path": "examples/docker/Dockerfile", "role": "legacy-container-image" }, + + { "id": "service-launchd-template", "path": "installers/launchd/com.freshell.server.plist.template", "role": "legacy-service-template" }, + { "id": "service-systemd-rust", "path": "installers/systemd/freshell-rust.service", "role": "rust-service" }, + { "id": "service-systemd-node-template", "path": "installers/systemd/freshell.service.template", "role": "legacy-service-template" }, + { "id": "service-windows-template", "path": "installers/windows/freshell-task.xml.template", "role": "legacy-service-template" }, + + { "id": "fixture-fake-amplifier", "path": "test/e2e-browser/fixtures/providers/fake-amplifier.mjs", "role": "provider-fixture" }, + { "id": "fixture-fake-claude-sdk", "path": "test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs", "role": "provider-fixture" }, + { "id": "fixture-fake-claude", "path": "test/e2e-browser/fixtures/providers/fake-claude.mjs", "role": "provider-fixture" }, + { "id": "fixture-fake-codex", "path": "test/e2e-browser/fixtures/providers/fake-codex-app-server.mjs", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "fixture-fake-gemini", "path": "test/e2e-browser/fixtures/providers/fake-gemini.mjs", "role": "provider-fixture" }, + { "id": "fixture-fake-kimi", "path": "test/e2e-browser/fixtures/providers/fake-kimi.mjs", "role": "provider-fixture" }, + { "id": "fixture-fake-opencode", "path": "test/e2e-browser/fixtures/providers/fake-opencode-server.mjs", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "fixture-fake-opencode-cjs", "path": "test/e2e-browser/fixtures/fake-opencode.cjs", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "fixture-gate01-run-slice", "path": "test/e2e-browser/gate01-run-slice.sh", "role": "test-runner" }, + + { "id": "listener-coordinator", "path": "scripts/testing/coordinator-endpoint.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "listener-echo-fixture", "path": "test/e2e-browser/helpers/echo-ws-fixture.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "listener-target-server", "path": "test/e2e-browser/helpers/harness-06/target-server.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "listener-update-feed", "path": "test/e2e-browser/helpers/harness-06/update-feed.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "listener-fake-ai", "path": "test/e2e-browser/helpers/harness-06/fake-ai.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "listener-browser-visibility-probe", "path": "scripts/proofs/browser-background-visibility-probe.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "listener-browser-freeze-probe", "path": "scripts/proofs/browser-freeze-lifecycle-probe.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "listener-browser-suspend-probe", "path": "scripts/proofs/browser-process-suspend-probe.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "listener-electron-port-check", "path": "electron/port-check.ts", "role": "non-backend-listener", "listener": "non-backend" }, + { "id": "fixture-rust-browser-server", "path": "test/e2e-browser/helpers/rust-server.ts", "role": "rust-test-server" }, + + { "id": "legacy-server-index", "path": "server/index.ts", "role": "legacy-node-backend", "listener": "legacy-backend" }, + { "id": "legacy-server-remote-proxy", "path": "server/coding-cli/codex-app-server/remote-proxy.ts", "role": "legacy-node-backend", "listener": "legacy-backend" }, + { "id": "legacy-server-extension-manager", "path": "server/extension-manager.ts", "role": "legacy-node-backend", "listener": "legacy-backend" }, + { "id": "legacy-server-local-port", "path": "server/local-port.ts", "role": "legacy-node-backend", "listener": "legacy-backend" }, + { "id": "legacy-server-network-manager", "path": "server/network-manager.ts", "role": "legacy-node-backend", "listener": "legacy-backend" }, + { "id": "legacy-server-port-forward", "path": "server/port-forward.ts", "role": "legacy-node-backend", "listener": "legacy-backend" }, + { "id": "legacy-server-ws-handler", "path": "server/ws-handler.ts", "role": "legacy-node-backend", "listener": "legacy-backend" }, + { "id": "legacy-port-opencode-proxy", "path": "port/oracle/harness/opencode-warm-proxy.ts", "role": "legacy-port-tool", "listener": "legacy-backend" }, + { "id": "legacy-port-rest-sweep", "path": "port/oracle/rest-parity/sweep.mjs", "role": "legacy-port-tool", "listener": "legacy-backend" }, + { "id": "legacy-test-coding-cli", "path": "test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs", "role": "legacy-provider-fixture", "listener": "legacy-backend" }, + { "id": "legacy-test-server-helper", "path": "test/e2e-browser/helpers/test-server.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + { "id": "legacy-test-agent-cli", "path": "test/e2e/agent-cli-flow.test.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + { "id": "legacy-test-agent-cli-screenshot", "path": "test/e2e/agent-cli-screenshot-smoke.test.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + { "id": "legacy-test-extension-system", "path": "test/integration/extension-system.test.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + { "id": "legacy-test-codex-remote", "path": "test/integration/real/codex-remote-fork-contract.test.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + + { "id": "port-laptop-bootstrap", "path": "port/laptop-bootstrap/2-bootstrap-wsl.sh", "role": "port-bootstrap" }, + { "id": "port-vm-bridge-bootstrap", "path": "port/vm-bridge/agent-console-wsl.sh", "role": "port-bootstrap" }, + { "id": "port-laptop-bootstrap-cmd", "path": "port/laptop-bootstrap/1-install-wsl.cmd", "role": "port-bootstrap" }, + { "id": "port-vm-bridge-powershell", "path": "port/vm-bridge/agent-console-vm.ps1", "role": "port-bootstrap" }, + { "id": "port-oracle-indexer-seed", "path": "port/oracle/indexer/seed.sh", "role": "port-bootstrap" }, + { "id": "test-browser-use-coalesce", "path": "test/browser_use/tool_coalesce.py", "role": "test-tool" }, + + { "id": "workflow-electron-build", "path": ".github/workflows/electron-build.yml", "role": "release-job" }, + { "id": "workflow-electron-release", "path": ".github/workflows/electron-release.yml", "role": "release-job" }, + { "id": "workflow-port-contract", "path": ".github/workflows/port-contract.yml", "role": "release-job" }, + { "id": "workflow-rust-clippy", "path": ".github/workflows/rust-clippy.yml", "role": "release-job" }, + { "id": "workflow-typecheck-client", "path": ".github/workflows/typecheck-client.yml", "role": "release-job" }, + { "id": "workflow-docs-pages", "path": ".github/workflows/docs-pages-deploy.yml", "role": "release-job" }, + { "id": "electron-builder-config", "path": "config/electron-builder.yml", "role": "packaging-config" }, + { "id": "vite-main-config", "path": "config/vite/vite.config.ts", "role": "vite" }, + { "id": "vitest-main-config", "path": "config/vitest/vitest.config.ts", "role": "vitest" } + ] +} diff --git a/scripts/testing/repo-context.ts b/scripts/testing/repo-context.ts new file mode 100644 index 000000000..51bc167dc --- /dev/null +++ b/scripts/testing/repo-context.ts @@ -0,0 +1,274 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +/** The small environment surface needed by coordinator/repository discovery. */ +export type RepositoryEnvironment = Readonly> + +const repoRootCache = new Map() +const checkoutRootCache = new Map() +const commonDirCache = new Map() +const execFileAsync = promisify(execFile) + +/** Clear repository discovery caches between isolated test fixtures. */ +export function clearRepoRootCache(): void { + repoRootCache.clear() + checkoutRootCache.clear() + commonDirCache.clear() +} + +/** Resolve a path to its containing repository, collapsing Git worktrees. */ +export async function resolveGitRepoRoot(cwd: string): Promise { + if (!cwd) return cwd + + const normalized = normalizeGitPathInput(cwd) + if (!normalized) return cwd + + const cached = repoRootCache.get(normalized) + if (cached !== undefined) return cached + + try { + const result = await walkForGitRoot(normalized, 'repo') + repoRootCache.set(normalized, result) + return result + } catch { + repoRootCache.set(normalized, normalized) + return normalized + } +} + +/** Resolve a path to the checkout root without collapsing a Git worktree. */ +export async function resolveGitCheckoutRoot(cwd: string): Promise { + if (!cwd) return cwd + + const normalized = normalizeGitPathInput(cwd) + if (!normalized) return cwd + + const cached = checkoutRootCache.get(normalized) + if (cached !== undefined) return cached + + try { + const result = await walkForGitRoot(normalized, 'checkout') + checkoutRootCache.set(normalized, result) + return result + } catch { + checkoutRootCache.set(normalized, normalized) + return normalized + } +} + +/** Resolve the shared `.git` directory for a checkout or worktree. */ +export async function resolveGitCommonDir(cwd: string): Promise { + if (!cwd) return undefined + + const normalized = normalizeGitPathInput(cwd) + if (!normalized) return undefined + + if (commonDirCache.has(normalized)) return commonDirCache.get(normalized) + + try { + const result = await walkForGitCommonDir(normalized) + commonDirCache.set(normalized, result) + return result + } catch { + commonDirCache.set(normalized, undefined) + return undefined + } +} + +/** Resolve branch and dirty state for a checkout, returning an empty object on Git errors. */ +export async function resolveGitBranchAndDirty(cwd: string): Promise<{ branch?: string; isDirty?: boolean }> { + const normalized = normalizeGitPathInput(cwd) + if (!normalized) return {} + + const checkoutRoot = await resolveGitCheckoutRoot(normalized) + + try { + const [branch, status] = await Promise.all([ + resolveGitBranch(checkoutRoot), + execFileAsync('git', ['-C', checkoutRoot, 'status', '--porcelain']), + ]) + + if (!branch && !status.stdout.trim()) return {} + + return { + ...(branch ? { branch } : {}), + isDirty: status.stdout.trim().length > 0, + } + } catch { + return {} + } +} + +/** Return a stable integer epoch-millisecond mtime for shared read models. */ +export function statMtimeMs(stat: { mtimeMs: number; mtime: Date }): number { + return Math.floor(stat.mtimeMs || stat.mtime.getTime()) +} + +/** Resolve the original invocation directory without depending on a global NodeJS type. */ +export function resolveInvocationCwd(envVars: RepositoryEnvironment = process.env): string | undefined { + const candidate = envVars.INIT_CWD || envVars.PWD + if (candidate) return candidate + try { + return process.cwd() + } catch { + return undefined + } +} + +function normalizeGitPathInput(cwd: string): string | undefined { + // Relative paths describe the caller's directory, not the coordinator's + // process cwd, and therefore cannot be made reliable here. + if (cwd.startsWith('~')) { + return path.resolve(os.homedir(), cwd.slice(cwd.startsWith('~/') ? 2 : 1)) + } + if (path.isAbsolute(cwd)) return path.resolve(cwd) + return undefined +} + +async function resolveGitBranch(checkoutRoot: string): Promise { + try { + const symbolic = await execFileAsync('git', ['-C', checkoutRoot, 'symbolic-ref', '--short', 'HEAD']) + const branch = symbolic.stdout.trim() + if (branch) return branch + } catch { + // Detached heads or older Git layouts can fail symbolic-ref. + } + + try { + const revParse = await execFileAsync('git', ['-C', checkoutRoot, 'rev-parse', '--abbrev-ref', 'HEAD']) + const branch = revParse.stdout.trim() + return branch || undefined + } catch { + return undefined + } +} + +async function walkForGitRoot(startDir: string, mode: 'repo' | 'checkout'): Promise { + let current = startDir + + // eslint-disable-next-line no-constant-condition + while (true) { + const gitPath = path.join(current, '.git') + + try { + const gitStat = await fsp.lstat(gitPath) + + if (gitStat.isDirectory()) { + if (!await isGitDirectory(gitPath)) return startDir + return current + } + + if (gitStat.isFile()) { + if (mode === 'checkout') return current + const content = await fsp.readFile(gitPath, 'utf8') + const match = content.match(/^gitdir:\s*(.+)/m) + if (match) { + const gitdir = path.resolve(path.dirname(gitPath), match[1].trim()) + return resolveFromGitFile(current, gitdir) + } + return current + } + } catch { + // No .git entry at this level; continue walking upwards. + } + + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + + return startDir +} + +async function resolveFromGitFile(dotGitDir: string, gitdir: string): Promise { + if (gitdir.includes('/.git/modules/') || gitdir.includes('\\.git\\modules\\')) { + return dotGitDir + } + + if (gitdir.includes('/.git/worktrees/') || gitdir.includes('\\.git\\worktrees\\')) { + return resolveWorktreeRoot(dotGitDir, gitdir) + } + + return dotGitDir +} + +async function resolveWorktreeRoot(dotGitDir: string, gitdir: string): Promise { + try { + const commondirContent = await fsp.readFile(path.join(gitdir, 'commondir'), 'utf8') + const commonDir = path.resolve(gitdir, commondirContent.trim()) + return path.dirname(commonDir) + } catch { + // Fall through to the path heuristic for old/incomplete worktree metadata. + } + + const parts = gitdir.split(path.sep) + const worktreesIndex = parts.lastIndexOf('worktrees') + if (worktreesIndex >= 2 && parts[worktreesIndex - 1] === '.git') { + const gitDirParent = parts.slice(0, worktreesIndex - 1) + return gitDirParent.join(path.sep) || path.sep + } + + return dotGitDir +} + +async function walkForGitCommonDir(startDir: string): Promise { + let current = startDir + + // eslint-disable-next-line no-constant-condition + while (true) { + const gitPath = path.join(current, '.git') + + try { + const gitStat = await fsp.lstat(gitPath) + if (gitStat.isDirectory()) { + if (!await isGitDirectory(gitPath)) return undefined + return gitPath + } + + if (gitStat.isFile()) { + const content = await fsp.readFile(gitPath, 'utf8') + const match = content.match(/^gitdir:\s*(.+)/m) + if (!match) return undefined + const gitdir = path.resolve(path.dirname(gitPath), match[1].trim()) + return resolveCommonDirFromGitFile(gitdir) + } + } catch { + // No .git entry at this level; continue walking upwards. + } + + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + + return undefined +} + +async function isGitDirectory(gitPath: string): Promise { + try { + const headStat = await fsp.stat(path.join(gitPath, 'HEAD')) + return headStat.isFile() + } catch { + return false + } +} + +async function resolveCommonDirFromGitFile(gitdir: string): Promise { + try { + const commondirContent = await fsp.readFile(path.join(gitdir, 'commondir'), 'utf8') + return path.resolve(gitdir, commondirContent.trim()) + } catch { + // Not all gitdir layouts use a commondir file. + } + + const worktreesToken = `${path.sep}.git${path.sep}worktrees${path.sep}` + const worktreesIndex = gitdir.lastIndexOf(worktreesToken) + if (worktreesIndex >= 0) { + return gitdir.slice(0, worktreesIndex + `${path.sep}.git`.length) + } + + return gitdir +} diff --git a/scripts/testing/test-coordinator.ts b/scripts/testing/test-coordinator.ts index 4b4c43a40..702ef6c7f 100644 --- a/scripts/testing/test-coordinator.ts +++ b/scripts/testing/test-coordinator.ts @@ -11,7 +11,7 @@ import { resolveGitCommonDir, resolveGitRepoRoot, resolveInvocationCwd, -} from '../../server/coding-cli/utils.js' +} from './repo-context.js' import { classifyCommand, COMMAND_KEYS, diff --git a/server/freshell-home.ts b/server/freshell-home.ts index 280abe157..24f98abb1 100644 --- a/server/freshell-home.ts +++ b/server/freshell-home.ts @@ -1,12 +1,8 @@ -import os from 'os' -import path from 'path' - -export function getFreshellHomeDir(env: NodeJS.ProcessEnv = process.env): string { - const override = env.FRESHELL_HOME?.trim() - if (override) return path.resolve(override) - return os.homedir() -} - -export function getFreshellConfigDir(env: NodeJS.ProcessEnv = process.env): string { - return path.join(getFreshellHomeDir(env), '.freshell') -} +// Temporary compatibility boundary. Legacy Node-server modules continue to +// import this path until the server tree is removed; the neutral owner is the +// shared module so clients and the Rust migration use one contract. +export { + getFreshellConfigDir, + getFreshellHomeDir, +} from '../shared/freshell-home.js' +export type { FreshellEnvironment } from '../shared/freshell-home.js' diff --git a/server/tabs-registry/types.ts b/server/tabs-registry/types.ts index 122e8caa4..f9a7d46db 100644 --- a/server/tabs-registry/types.ts +++ b/server/tabs-registry/types.ts @@ -1,90 +1,17 @@ -import { z } from 'zod' -import { migrateLegacyFreshAgentContent } from '../../shared/fresh-agent.js' - -export const RegistryTabStatusSchema = z.enum(['open', 'closed']) -export type RegistryTabStatus = z.infer - -export const RegistryPaneKindSchema = z.enum([ - 'terminal', - 'browser', - 'editor', - 'picker', - 'claude-chat', - 'fresh-agent', - 'extension', -]) -export type RegistryPaneKind = z.infer - -const LEGACY_AGENT_CHAT_PANE_KIND = 'agent-chat' - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value) -} - -function stripUndefinedValues(value: Record): Record { - return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)) -} - -function normalizeRegistryPaneSnapshotInput(value: unknown): unknown { - if ( - !isRecord(value) - || (value.kind !== LEGACY_AGENT_CHAT_PANE_KIND && value.kind !== 'fresh-agent') - ) { - return value - } - const payload = isRecord(value.payload) ? value.payload : {} - const migrated = migrateLegacyFreshAgentContent({ - kind: value.kind, - ...payload, - }) as Record - if (migrated.kind !== 'fresh-agent') return value - const { kind: _kind, ...migratedPayload } = migrated - return { - ...value, - kind: 'fresh-agent', - payload: stripUndefinedValues(migratedPayload), - } -} - -export const RegistryPaneSnapshotSchema = z.preprocess(normalizeRegistryPaneSnapshotInput, z.object({ - paneId: z.string().min(1), - kind: RegistryPaneKindSchema, - title: z.string().optional(), - payload: z.record(z.string(), z.unknown()), -})) -export type RegistryPaneSnapshot = z.infer - -export const TabRegistryRecordBaseSchema = z.object({ - tabKey: z.string().min(1), - tabId: z.string().min(1), - serverInstanceId: z.string().min(1), - deviceId: z.string().min(1), - deviceLabel: z.string().min(1), - clientInstanceId: z.string().min(1).optional(), - tabName: z.string().min(1), - status: RegistryTabStatusSchema, - revision: z.number().int().nonnegative(), - createdAt: z.number().int().nonnegative(), - updatedAt: z.number().int().nonnegative(), - closedAt: z.number().int().nonnegative().optional(), - paneCount: z.number().int().nonnegative(), - titleSetByUser: z.boolean(), - panes: z.array(RegistryPaneSnapshotSchema), -}) - -export const TabRegistryRecordSchema = TabRegistryRecordBaseSchema.superRefine((value, ctx) => { - if (value.status === 'closed' && value.closedAt == null) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'closedAt is required when status is closed', - path: ['closedAt'], - }) - } -}) - -export type RegistryTabRecord = z.infer - -export function normalizeRegistryTabRecord(value: unknown): RegistryTabRecord | undefined { - const parsed = TabRegistryRecordSchema.safeParse(value) - return parsed.success ? parsed.data : undefined -} +// Temporary compatibility boundary for the Node server. The shared module +// owns the schema so client persistence and the retiring backend use one type. +export { + RegistryTabStatusSchema, + RegistryPaneKindSchema, + RegistryPaneSnapshotSchema, + TabRegistryRecordBaseSchema, + TabRegistryRecordSchema, + normalizeRegistryTabRecord, +} from '../../shared/tab-registry-types.js' + +export type { + RegistryTabStatus, + RegistryPaneKind, + RegistryPaneSnapshot, + RegistryTabRecord, +} from '../../shared/tab-registry-types.js' diff --git a/shared/freshell-home.ts b/shared/freshell-home.ts new file mode 100644 index 000000000..bd3567ab9 --- /dev/null +++ b/shared/freshell-home.ts @@ -0,0 +1,17 @@ +import os from 'node:os' +import path from 'node:path' + +/** Environment shape needed by the filesystem-neutral configuration helpers. */ +export type FreshellEnvironment = Readonly> + +/** Resolve Freshell's home directory, honoring the explicit test/deployment override. */ +export function getFreshellHomeDir(env: FreshellEnvironment = process.env): string { + const override = env.FRESHELL_HOME?.trim() + if (override) return path.resolve(override) + return os.homedir() +} + +/** Resolve the directory containing Freshell's persisted configuration. */ +export function getFreshellConfigDir(env: FreshellEnvironment = process.env): string { + return path.join(getFreshellHomeDir(env), '.freshell') +} diff --git a/shared/tab-registry-types.ts b/shared/tab-registry-types.ts new file mode 100644 index 000000000..1e049261a --- /dev/null +++ b/shared/tab-registry-types.ts @@ -0,0 +1,91 @@ +import { z } from 'zod' + +import { migrateLegacyFreshAgentContent } from './fresh-agent.js' + +export const RegistryTabStatusSchema = z.enum(['open', 'closed']) +export type RegistryTabStatus = z.infer + +export const RegistryPaneKindSchema = z.enum([ + 'terminal', + 'browser', + 'editor', + 'picker', + 'claude-chat', + 'fresh-agent', + 'extension', +]) +export type RegistryPaneKind = z.infer + +const LEGACY_AGENT_CHAT_PANE_KIND = 'agent-chat' + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function stripUndefinedValues(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)) +} + +function normalizeRegistryPaneSnapshotInput(value: unknown): unknown { + if ( + !isRecord(value) + || (value.kind !== LEGACY_AGENT_CHAT_PANE_KIND && value.kind !== 'fresh-agent') + ) { + return value + } + const payload = isRecord(value.payload) ? value.payload : {} + const migrated = migrateLegacyFreshAgentContent({ + kind: value.kind, + ...payload, + }) as Record + if (migrated.kind !== 'fresh-agent') return value + const { kind: _kind, ...migratedPayload } = migrated + return { + ...value, + kind: 'fresh-agent', + payload: stripUndefinedValues(migratedPayload), + } +} + +export const RegistryPaneSnapshotSchema = z.preprocess(normalizeRegistryPaneSnapshotInput, z.object({ + paneId: z.string().min(1), + kind: RegistryPaneKindSchema, + title: z.string().optional(), + payload: z.record(z.string(), z.unknown()), +})) +export type RegistryPaneSnapshot = z.infer + +export const TabRegistryRecordBaseSchema = z.object({ + tabKey: z.string().min(1), + tabId: z.string().min(1), + serverInstanceId: z.string().min(1), + deviceId: z.string().min(1), + deviceLabel: z.string().min(1), + clientInstanceId: z.string().min(1).optional(), + tabName: z.string().min(1), + status: RegistryTabStatusSchema, + revision: z.number().int().nonnegative(), + createdAt: z.number().int().nonnegative(), + updatedAt: z.number().int().nonnegative(), + closedAt: z.number().int().nonnegative().optional(), + paneCount: z.number().int().nonnegative(), + titleSetByUser: z.boolean(), + panes: z.array(RegistryPaneSnapshotSchema), +}) + +export const TabRegistryRecordSchema = TabRegistryRecordBaseSchema.superRefine((value, ctx) => { + if (value.status === 'closed' && value.closedAt == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'closedAt is required when status is closed', + path: ['closedAt'], + }) + } +}) + +export type RegistryTabRecord = z.infer + +export function normalizeRegistryTabRecord(value: unknown): RegistryTabRecord | undefined { + const parsed = TabRegistryRecordSchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} diff --git a/src/store/tabRegistryTypes.ts b/src/store/tabRegistryTypes.ts index 1553e0e1e..b752fe8c7 100644 --- a/src/store/tabRegistryTypes.ts +++ b/src/store/tabRegistryTypes.ts @@ -4,11 +4,11 @@ export { RegistryPaneSnapshotSchema, TabRegistryRecordSchema, normalizeRegistryTabRecord, -} from '../../server/tabs-registry/types' +} from '../../shared/tab-registry-types.js' export type { RegistryTabStatus, RegistryPaneKind, RegistryPaneSnapshot, RegistryTabRecord, -} from '../../server/tabs-registry/types' +} from '../../shared/tab-registry-types.js' diff --git a/test/e2e-browser/helpers/session-corpus/git-layout.ts b/test/e2e-browser/helpers/session-corpus/git-layout.ts index 134ea3c95..a4feae569 100644 --- a/test/e2e-browser/helpers/session-corpus/git-layout.ts +++ b/test/e2e-browser/helpers/session-corpus/git-layout.ts @@ -2,7 +2,7 @@ * HARNESS-04 — on-disk git fixtures (hand-written, no git binary required). * * Shapes mirror `test/unit/server/coding-cli/resolve-git-root.test.ts` and were - * validated against the production resolvers in `server/coding-cli/utils.ts` + * validated against the shared repository-context resolvers * (load-bearing L1): * - a VALID `.git` directory = directory containing a `HEAD` file * (`isGitDirectory`); nested repos resolve to the innermost valid root. diff --git a/test/e2e-browser/helpers/session-corpus/session-corpus.test.ts b/test/e2e-browser/helpers/session-corpus/session-corpus.test.ts index 2409bec8a..e28fe3ad9 100644 --- a/test/e2e-browser/helpers/session-corpus/session-corpus.test.ts +++ b/test/e2e-browser/helpers/session-corpus/session-corpus.test.ts @@ -13,18 +13,13 @@ import { claudeProjectSlug, writeClaudeSession } from './claude.js' import { codexDatePath, writeCodexSession } from './codex.js' import { writeOpencodeCorpus, type OpencodeSessionSpec } from './opencode.js' import { writeAmplifierSession } from './amplifier.js' -import { parseAmplifierMetadata } from '../../../../server/coding-cli/providers/amplifier.js' import { createNestedGitRepos, createWorktreePair } from './git-layout.js' import { buildSessionCorpus } from './index.js' import { clearRepoRootCache, resolveGitCheckoutRoot, resolveGitRepoRoot, -} from '../../../../server/coding-cli/utils.js' -import { - runOpencodeListingQuery, - THREE_VIEWS_MARKER_SQL_PATTERN, -} from '../../../../server/coding-cli/providers/opencode-listing-query.js' +} from '../../../../scripts/testing/repo-context.js' import type { CorpusContext } from './types.js' /** @@ -387,7 +382,7 @@ describe('session-corpus opencode writer', () => { } } - it('creates the DB under XDG data home; production listing query sees only root non-archived rows', async () => { + it('creates the DB under XDG data home and records visibility expectations', async () => { const home = await mkHome() const ctx = mkCtx(home) const specs = [ @@ -402,21 +397,9 @@ describe('session-corpus opencode writer', () => { expect(ctx.files).toHaveLength(1) expect(ctx.files[0].path).toBe('.local/share/opencode/opencode.db') - // THE production listing query (opencode-listing-query.ts) is the reader - // under test here: archived and child rows must not come back. - const dbPath = path.join(home, '.local', 'share', 'opencode', 'opencode.db') - const { rows } = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) - const ids = rows.map((r) => r.sessionId).sort() - expect(ids).toEqual(['h04corpus-oc-delta', 'h04corpus-oc-echo']) - - const delta = rows.find((r) => r.sessionId === 'h04corpus-oc-delta')! - expect(delta).toMatchObject({ - cwd: specs[0].directory, - title: 'h04corpus-testtoken delta', - createdAt: specs[0].timeCreated, - lastActivityAt: specs[0].timeUpdated, - projectPath: specs[0].projectWorktree, - }) + // The writer contract records the exact DB location and provider rows; + // Rust owns production ingestion, so this helper test does not import the + // deleted Node OpenCode reader. // expectations const byRole = (role: string) => exps.find((e) => e.role === role)! @@ -461,16 +444,16 @@ describe('session-corpus amplifier writer', () => { '.amplifier/projects/epsilon-project/sessions/h04corpus-testtoken-amp-epsilon/transcript.jsonl', ]) - // the production parser is the reader under test - const parsed = parseAmplifierMetadata(metaRaw) - expect(parsed).toMatchObject({ - sessionId: 'h04corpus-testtoken-amp-epsilon', - cwd, - createdAt: Math.floor(created), // fractional floored - lastActivityAt: updated, - title: 'h04corpus-testtoken epsilon', - titleSource: 'provider-generated', - summary: 'h04corpus-testtoken epsilon summary text', + // Rust owns production ingestion; this helper test checks the writer's + // metadata bytes without importing the deleted Node provider reader. + const metadata = JSON.parse(metaRaw) as Record + expect(metadata).toMatchObject({ + session_id: 'h04corpus-testtoken-amp-epsilon', + working_dir: cwd, + created, + description_updated_at: new Date(updated).toISOString(), + name: 'h04corpus-testtoken epsilon', + description: 'h04corpus-testtoken epsilon summary text', }) // mtimes pinned to the seeded activity instant (recency fold must not diff --git a/test/e2e/update-flow.test.ts b/test/e2e/update-flow.test.ts deleted file mode 100644 index c00d6d27a..000000000 --- a/test/e2e/update-flow.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// @vitest-environment node -import { describe, it, expect } from 'vitest' -import { spawn } from 'child_process' -import { createRequire } from 'module' -import net from 'net' -import path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const REPO_ROOT = path.resolve(__dirname, '../..') -const PRECHECK_SCRIPT = path.resolve(REPO_ROOT, 'scripts/precheck.ts') -const require = createRequire(import.meta.url) -const TSX_CLI = require.resolve('tsx/cli') -const PROCESS_TIMEOUT_MS = 30_000 - -type PrecheckResult = { - code: number | null - signal: NodeJS.Signals | null - stdout: string - stderr: string -} - -async function getFreePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer() - server.once('error', reject) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (typeof address !== 'object' || !address) { - server.close(() => reject(new Error('Failed to allocate a free port'))) - return - } - - const { port } = address - server.close((err) => { - if (err) { - reject(err) - return - } - resolve(port) - }) - }) - }) -} - -async function runPrecheck( - args: string[] = [], - env: NodeJS.ProcessEnv = {}, -): Promise { - const [serverPort, vitePort] = await Promise.all([getFreePort(), getFreePort()]) - - return await new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - [TSX_CLI, PRECHECK_SCRIPT, ...args], - { - cwd: REPO_ROOT, - env: { - ...process.env, - PORT: String(serverPort), - VITE_PORT: String(vitePort), - npm_lifecycle_event: 'preserve', - ...env, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ) - - let stdout = '' - let stderr = '' - - child.stdout?.on('data', (chunk: Buffer | string) => { - stdout += chunk.toString() - }) - child.stderr?.on('data', (chunk: Buffer | string) => { - stderr += chunk.toString() - }) - - const timeout = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`precheck timed out after ${PROCESS_TIMEOUT_MS}ms`)) - }, PROCESS_TIMEOUT_MS) - - child.once('error', (error) => { - clearTimeout(timeout) - reject(error) - }) - - child.once('close', (code, signal) => { - clearTimeout(timeout) - resolve({ code, signal, stdout, stderr }) - }) - }) -} - -describe('update flow precheck', () => { - it('skips update checking when --skip-update-check is provided', async () => { - const result = await runPrecheck(['--skip-update-check']) - - expect(result.signal).toBeNull() - expect(result.code).toBe(0) - expect(result.stdout).not.toContain('new Freshell') - expect(result.stdout).not.toContain('Update complete!') - expect(result.stderr).toBe('') - }) - - it('skips update checking when SKIP_UPDATE_CHECK=true', async () => { - const result = await runPrecheck([], { SKIP_UPDATE_CHECK: 'true' }) - - expect(result.signal).toBeNull() - expect(result.code).toBe(0) - expect(result.stdout).not.toContain('new Freshell') - expect(result.stdout).not.toContain('Update complete!') - expect(result.stderr).toBe('') - }) - - it('skips update checking during the predev lifecycle while still succeeding the preflight', async () => { - const result = await runPrecheck([], { npm_lifecycle_event: 'predev' }) - - expect(result.signal).toBeNull() - expect(result.code).toBe(0) - expect(result.stdout).not.toContain('new Freshell') - expect(result.stdout).not.toContain('Update complete!') - expect(result.stderr).toBe('') - }) -}) diff --git a/test/unit/architecture/fresh-agent-only-runtime.test.ts b/test/unit/architecture/fresh-agent-only-runtime.test.ts index 625f54400..f504e0961 100644 --- a/test/unit/architecture/fresh-agent-only-runtime.test.ts +++ b/test/unit/architecture/fresh-agent-only-runtime.test.ts @@ -5,7 +5,9 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' const repoRoot = process.cwd() -const productionRuntimeRoots = ['src', 'server', 'shared'] as const +// The browser architecture scan follows retained runtime roots only. The +// legacy Node backend is intentionally not an allowed production root. +const productionRuntimeRoots = ['src', 'shared'] as const const runtimeExtensions = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx']) @@ -62,46 +64,14 @@ const legacyMigrationBoundaryAllowances: LegacyBoundaryAllowance[] = [ ], }, { - file: 'server/config-store.ts', - reason: 'config input migration and patch rejection normalize old agentChat settings without exposing them live', - patterns: [ - /^\s*const existingAgentChat = isRecord\(migrated\.agentChat\) \? \{ \.\.\.migrated\.agentChat \} : \{\}$/, - /^\s*const existingProviders = isRecord\(existingAgentChat\.providers\) \? \{ \.\.\.existingAgentChat\.providers \} : \{\}$/, - /^\s*existingAgentChat\.providers = existingProviders$/, - /^\s*migrated\.agentChat = existingAgentChat$/, - /^\s*delete \(migratedSettings as Record\)\.agentChat$/, - /^\s*&& Object\.prototype\.hasOwnProperty\.call\(patch, 'agentChat'\)$/, - /^\s*const error = new Error\('agentChat settings have been migrated; use freshAgent'\)$/, - /^\s*'Rejected legacy agentChat settings patch',$/, - ], - }, - { - file: 'server/settings-router.ts', - reason: 'settings route rejects legacy agentChat input at the boundary', - patterns: [ - /^\s*if \(Object\.prototype\.hasOwnProperty\.call\(req\.body \|\| \{\}, 'agentChat'\)\) \{$/, - /^\s*res\.status\(400\)\.json\(\{ error: 'agentChat settings have been migrated; use freshAgent' \}\)$/, - ], - }, - { - file: 'server/tabs-registry/types.ts', - reason: 'explicit legacy tab-registry kind constant is used only to normalize old records', + file: 'shared/tab-registry-types.ts', + reason: 'tab-registry input migration recognizes old pane kind before normalizing it to fresh-agent', patterns: [ /^const LEGACY_AGENT_CHAT_PANE_KIND = 'agent-chat'$/, ], }, ] -const sdkInternalFiles = new Set([ - 'server/sdk-bridge.ts', - 'server/sdk-bridge-types.ts', - 'server/fresh-agent/sdk-events.ts', -]) - -const sdkInternalPrefixes = [ - 'server/fresh-agent/adapters/', -] - function toRepoRelativePath(filePath: string): string { return path.relative(repoRoot, filePath).split(path.sep).join('/') } @@ -164,10 +134,6 @@ function legacyAllowanceFor(file: string, line: string): LegacyBoundaryAllowance )) } -function isSdkInternalPath(file: string): boolean { - return sdkInternalFiles.has(file) || sdkInternalPrefixes.some((prefix) => file.startsWith(prefix)) -} - function formatWindowSnippet(lines: SourceLine[]): string { return lines .map(({ line }) => line.trim()) @@ -244,10 +210,6 @@ function findLegacyRuntimeReferences(file: string, contents: string): Finding[] } function findSdkPublicReferences(file: string, contents: string): Finding[] { - if (isSdkInternalPath(file)) { - return [] - } - return contents .split(/\r?\n/) .flatMap((line, index): Finding[] => { @@ -278,6 +240,12 @@ function formatFindings(findings: Finding[]): string { } describe('fresh-agent-only runtime architecture', () => { + it('scans only existing retained production runtime roots', () => { + for (const root of productionRuntimeRoots) { + expect(fs.existsSync(path.join(repoRoot, root))).toBe(true) + } + }) + describe('legacy reference detection', () => { it('detects direct and obviously split legacy references', () => { const snippets = [ @@ -299,8 +267,8 @@ describe('fresh-agent-only runtime architecture', () => { " return isRecord(candidate.agentChat)\n ? candidate.agentChat as LegacyFreshAgentSettingsInput", )).toEqual([]) expect(findLegacyRuntimeReferences( - 'server/settings-router.ts', - " res.status(400).json({ error: 'agentChat settings have been migrated; use freshAgent' })", + 'shared/tab-registry-types.ts', + "const LEGACY_AGENT_CHAT_PANE_KIND = 'agent-chat'", )).toEqual([]) }) @@ -348,12 +316,11 @@ describe('fresh-agent-only runtime architecture', () => { it('does not let migration-boundary lines trigger multiline findings', () => { expect(findLegacyRuntimeReferences( - 'server/config-store.ts', + 'shared/settings.ts', [ - ' const existingAgentChat = isRecord(migrated.agentChat) ? { ...migrated.agentChat } : {}', - ' const existingProviders = isRecord(existingAgentChat.providers) ? { ...existingAgentChat.providers } : {}', - ' existingAgentChat.providers = existingProviders', - ' migrated.agentChat = existingAgentChat', + ' return isRecord(candidate.agentChat)', + ' ? candidate.agentChat as LegacyFreshAgentSettingsInput', + " const next = omitKeys(raw, ['theme', 'uiScale', 'notifications', 'agentChat'])", ].join('\n'), )).toEqual([]) }) diff --git a/test/unit/architecture/rust-only-server-runtime.test.ts b/test/unit/architecture/rust-only-server-runtime.test.ts new file mode 100644 index 000000000..7be982704 --- /dev/null +++ b/test/unit/architecture/rust-only-server-runtime.test.ts @@ -0,0 +1,243 @@ +// @vitest-environment node +import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { analyzeRuntimeBoundary } from '../../../scripts/retirement/runtime-boundary.js' + +type RuntimeSurface = { + id: string + path: string + role: string + listener?: 'non-backend' | 'legacy-backend' +} + +const ALLOWED_LISTENER_PATHS = [ + 'scripts/testing/coordinator-endpoint.ts', + 'test/e2e-browser/helpers/echo-ws-fixture.ts', + 'test/e2e-browser/helpers/harness-06/target-server.ts', + 'test/e2e-browser/helpers/harness-06/update-feed.ts', + 'test/e2e-browser/helpers/harness-06/fake-ai.ts', + 'test/e2e-browser/fixtures/providers/fake-codex-app-server.mjs', + 'test/e2e-browser/fixtures/providers/fake-opencode-server.mjs', + 'test/e2e-browser/fixtures/fake-opencode.cjs', + 'scripts/proofs/browser-background-visibility-probe.ts', + 'scripts/proofs/browser-freeze-lifecycle-probe.ts', + 'scripts/proofs/browser-process-suspend-probe.ts', + 'electron/port-check.ts', +] as const + +const tempRoots: string[] = [] + +async function createSyntheticRoot( + surfaces: RuntimeSurface[], + files: Record = {}, +): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'freshell-runtime-boundary-')) + tempRoots.push(root) + await mkdir(path.join(root, 'scripts', 'retirement'), { recursive: true }) + await writeFile( + path.join(root, 'scripts', 'retirement', 'runtime-surfaces.json'), + `${JSON.stringify({ version: 1, surfaces }, null, 2)}\n`, + ) + await Promise.all(Object.entries(files).map(async ([relativePath, contents]) => { + const filePath = path.join(root, relativePath) + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, contents) + if (relativePath.endsWith('.sh') || relativePath.includes('unlisted') || relativePath.includes('new-owner')) { + await chmod(filePath, 0o755) + } + })) + return root +} + +async function removeSyntheticRoots(): Promise { + while (tempRoots.length > 0) { + await rm(tempRoots.pop()!, { recursive: true, force: true }) + } +} + +afterEach(removeSyntheticRoots) + +describe('runtime boundary analyzer', () => { + it('rejects an invented Node HTTP listener as an unexpected backend', async () => { + const root = await createSyntheticRoot( + [{ id: 'known-script', path: 'scripts/known.ts', role: 'test-tool' }], + { + 'scripts/known.ts': 'export const known = true\n', + 'scripts/invented-listener.ts': [ + "import http from 'node:http'", + "http.createServer((_req, res) => res.end('nope')).listen(0)", + ].join('\n'), + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.unexpectedNodeBackend).toContain('scripts/invented-listener.ts') + }) + + it('allows sanctioned Node roles and the exact non-backend listener rows', async () => { + const roleFiles: Record = { + 'config/vite/vite.config.ts': "export default { server: { host: '127.0.0.1' } }\n", + 'config/vitest/vitest.config.ts': "export default { test: { environment: 'node' } }\n", + 'electron/main.ts': "app.whenReady().then(() => {})\n", + 'tools/client.ts': "await fetch('http://127.0.0.1:3001/api/health')\n", + 'tools/mcp.ts': "new StdioServerTransport()\n", + 'crates/freshell-claude-sidecar/index.mjs': "await query({ prompt: 'safe fixture' })\n", + } + const surfaces: RuntimeSurface[] = Object.keys(roleFiles).map((surfacePath) => ({ + id: surfacePath, + path: surfacePath, + role: surfacePath.includes('vite') + ? 'vite' + : surfacePath.includes('vitest') + ? 'vitest' + : surfacePath.startsWith('electron/') + ? 'electron-main' + : surfacePath.includes('mcp') + ? 'mcp-client' + : surfacePath.includes('claude') + ? 'claude-sidecar' + : 'cli-client', + })) + + for (const surfacePath of ALLOWED_LISTENER_PATHS) { + roleFiles[surfacePath] = [ + "import http from 'node:http'", + "http.createServer((_req, res) => res.end('fixture')).listen(0)", + ].join('\n') + surfaces.push({ + id: surfacePath, + path: surfacePath, + role: 'non-backend-listener', + listener: 'non-backend', + }) + } + + const root = await createSyntheticRoot(surfaces, roleFiles) + const result = await analyzeRuntimeBoundary(root) + + expect(result.manifestDrift).toEqual([]) + expect(result.unexpectedNodeBackend).toEqual([]) + }) + + it('reconciles unlisted owners, stale rows, and duplicate ownership', async () => { + const root = await createSyntheticRoot( + [ + { id: 'known', path: 'scripts/known.ts', role: 'test-tool' }, + { id: 'stale', path: 'scripts/missing.ts', role: 'test-tool' }, + { id: 'duplicate-a', path: 'scripts/known.ts', role: 'test-tool' }, + { id: 'duplicate-b', path: 'scripts/known.ts', role: 'test-tool' }, + ], + { + 'scripts/known.ts': 'export const known = true\n', + 'scripts/unlisted.ts': 'export const unlisted = true\n', + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.manifestDrift).toEqual(expect.arrayContaining([ + 'duplicate ownership: scripts/known.ts (duplicate-a, duplicate-b, known)', + 'stale manifest row: stale -> scripts/missing.ts', + 'unlisted owner: scripts/unlisted.ts', + ])) + }) + + it('ignores historical plans but inventories root, scripts, and port owners', async () => { + const root = await createSyntheticRoot( + [], + { + 'docs/plans/historical.ts': "require('node:http').createServer().listen(0)\n", + 'scripts/new-owner.ts': 'export const owner = true\n', + 'scripts/new-listener.ts': "require('node:http').createServer().listen(0)\n", + 'port/new-bootstrap.sh': '#!/usr/bin/env bash\necho owner\n', + 'root-launcher.sh': '#!/usr/bin/env bash\necho owner\n', + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.manifestDrift).toEqual(expect.arrayContaining([ + 'unlisted owner: port/new-bootstrap.sh', + 'unlisted owner: root-launcher.sh', + 'unlisted owner: scripts/new-owner.ts', + ])) + expect(result.manifestDrift).not.toContain('unlisted owner: docs/plans/historical.ts') + expect(result.unexpectedNodeBackend).not.toContain('docs/plans/historical.ts') + expect(result.unexpectedNodeBackend).toContain('scripts/new-listener.ts') + }) + + it('inventories service, container, workflow, and fixture-server resources', async () => { + const root = await createSyntheticRoot( + [], + { + 'installers/systemd/new.service': '[Service]\nExecStart=/opt/example\n', + 'docker/new/Dockerfile': 'FROM scratch\n', + 'docker/new/cloudbuild.yaml': 'steps: []\n', + '.github/workflows/new.yml': 'name: new\n', + 'test/fixtures/new-server.ts': 'export const fixture = true\n', + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.manifestDrift).toEqual(expect.arrayContaining([ + 'unlisted owner: .github/workflows/new.yml', + 'unlisted owner: docker/new/Dockerfile', + 'unlisted owner: docker/new/cloudbuild.yaml', + 'unlisted owner: installers/systemd/new.service', + 'unlisted owner: test/fixtures/new-server.ts', + ])) + }) + + it('does not let a fake tools or electron listener bypass capability detection', async () => { + const root = await createSyntheticRoot( + [], + { + 'tools/fake-server.ts': "import http from 'node:http'; http.createServer().listen(0)\n", + 'electron/fake-server.ts': "import http from 'node:http'; http.createServer().listen(0)\n", + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.unexpectedNodeBackend).toEqual([ + 'electron/fake-server.ts', + 'tools/fake-server.ts', + ]) + }) + + it('does not allow a manifest row to expand the non-backend listener allowlist', async () => { + const root = await createSyntheticRoot( + [{ id: 'misclassified', path: 'scripts/misclassified.ts', role: 'non-backend-listener', listener: 'non-backend' }], + { + 'scripts/misclassified.ts': "require('node:http').createServer().listen(0)\n", + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.unexpectedNodeBackend).toEqual(['scripts/misclassified.ts']) + }) +}) + +describe('runtime boundary inventory for the current checkout', () => { + it('keeps current legacy debt explicit while proving the manifest is reconciled', async () => { + const result = await analyzeRuntimeBoundary(process.cwd()) + + expect(result.manifestDrift).toEqual([]) + expect(result.unexpectedNodeBackend).toEqual([]) + expect(result.legacyDebt).toEqual(expect.arrayContaining([ + 'server/index.ts', + 'package.json:scripts.start', + 'config/electron-builder.yml:dist/server', + 'test/e2e-browser/playwright.config.ts:legacy-chromium', + 'run-rust-server.sh:legacy-comment', + 'port/laptop-bootstrap/2-bootstrap-wsl.sh:inherited-build-path', + ])) + }) +}) diff --git a/test/unit/vite-config.test.ts b/test/unit/vite-config.test.ts index c822f9404..5c78bc3f7 100644 --- a/test/unit/vite-config.test.ts +++ b/test/unit/vite-config.test.ts @@ -4,19 +4,13 @@ import { loadEnv } from 'vite' import { readFileSync } from 'node:fs' vi.mock('node:fs') -// Mock dotenv to prevent .env file loading in tests. getNetworkHost() -// calls dotenv.config() at runtime, which would load any .env file from -// the test runner's working directory — making tests non-hermetic. -vi.mock('dotenv', () => ({ - default: { config: vi.fn() }, - config: vi.fn(), -})) // Mock platform module — WSL detection is now centralized in platform.ts vi.mock('../../server/platform.js', () => ({ isWSL: vi.fn(() => false), })) import { isWSL } from '../../server/platform.js' +import { getNetworkHost } from '../../config/vite/get-network-host.js' const TEST_TIMEOUT_MS = 20_000 @@ -42,22 +36,19 @@ describe('getNetworkHost', () => { it('returns 127.0.0.1 when config file does not exist', async () => { vi.mocked(readFileSync).mockImplementation(() => { throw new Error('ENOENT') }) - const { getNetworkHost } = await import('../../server/get-network-host.js') - expect(getNetworkHost()).toBe('127.0.0.1') + expect(getNetworkHost({ env: process.env, configDir: '/tmp/freshell-test-config', isWsl: false })).toBe('127.0.0.1') }) it('returns host from config when configured', async () => { vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ settings: { network: { host: '0.0.0.0', configured: true } }, })) - const { getNetworkHost } = await import('../../server/get-network-host.js') - expect(getNetworkHost()).toBe('0.0.0.0') + expect(getNetworkHost({ env: process.env, configDir: '/tmp/freshell-test-config', isWsl: false })).toBe('0.0.0.0') }) it('returns 127.0.0.1 when config has no network settings', async () => { vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ settings: {} })) - const { getNetworkHost } = await import('../../server/get-network-host.js') - expect(getNetworkHost()).toBe('127.0.0.1') + expect(getNetworkHost({ env: process.env, configDir: '/tmp/freshell-test-config', isWsl: false })).toBe('127.0.0.1') }) it('honors HOST env override when unconfigured', async () => { @@ -65,8 +56,7 @@ describe('getNetworkHost', () => { vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ settings: { network: { host: '127.0.0.1', configured: false } }, })) - const { getNetworkHost } = await import('../../server/get-network-host.js') - expect(getNetworkHost()).toBe('0.0.0.0') + expect(getNetworkHost({ env: process.env, configDir: '/tmp/freshell-test-config', isWsl: false })).toBe('0.0.0.0') }) it('ignores HOST env when configured', async () => { @@ -74,21 +64,18 @@ describe('getNetworkHost', () => { vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ settings: { network: { host: '127.0.0.1', configured: true } }, })) - const { getNetworkHost } = await import('../../server/get-network-host.js') - expect(getNetworkHost()).toBe('127.0.0.1') + expect(getNetworkHost({ env: process.env, configDir: '/tmp/freshell-test-config', isWsl: false })).toBe('127.0.0.1') }) it('uses HOST env when no config file exists', async () => { process.env.HOST = '0.0.0.0' vi.mocked(readFileSync).mockImplementation(() => { throw new Error('ENOENT') }) - const { getNetworkHost } = await import('../../server/get-network-host.js') - expect(getNetworkHost()).toBe('0.0.0.0') + expect(getNetworkHost({ env: process.env, configDir: '/tmp/freshell-test-config', isWsl: false })).toBe('0.0.0.0') }) it('always returns 0.0.0.0 on WSL regardless of config', async () => { vi.mocked(isWSL).mockReturnValue(true) - const { getNetworkHost } = await import('../../server/get-network-host.js') - expect(getNetworkHost()).toBe('0.0.0.0') + expect(getNetworkHost({ env: process.env, configDir: '/tmp/freshell-test-config', isWsl: isWSL() })).toBe('0.0.0.0') }) }) From 554d987e3711d5ffbbab8bfd4f69fc5ff9bcc120 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:52:40 -0700 Subject: [PATCH 06/64] fix: scan test runtime listeners at boundary --- scripts/retirement/runtime-boundary.ts | 10 ++++- scripts/retirement/runtime-surfaces.json | 4 ++ .../rust-only-server-runtime.test.ts | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/scripts/retirement/runtime-boundary.ts b/scripts/retirement/runtime-boundary.ts index 352466622..5c36bb916 100644 --- a/scripts/retirement/runtime-boundary.ts +++ b/scripts/retirement/runtime-boundary.ts @@ -138,6 +138,10 @@ function isSourcePath(relativePath: string): boolean { return sourceExtensions.has(path.posix.extname(relativePath)) } +function isTestFilePath(relativePath: string): boolean { + return /\.(?:test|spec)\.(?:cjs|js|jsx|mjs|ts|tsx)$/.test(relativePath) +} + function isExecutableMode(mode: number): boolean { return (mode & 0o111) !== 0 } @@ -347,7 +351,10 @@ function hasListenerCapability(contents: string): boolean { function isCapabilityScanPath(relativePath: string): boolean { if (isIgnoredRelativePath(relativePath) || !isSourcePath(relativePath)) return false - if (relativePath.startsWith('test/')) return nonBackendListenerPaths.has(relativePath) + // Test cases may create throwaway listeners for assertions. Runtime helpers + // and fixtures are the executable test surfaces, so scan every non-test + // source file below test/ while excluding test/spec implementations. + if (relativePath.startsWith('test/') && isTestFilePath(relativePath)) return false return [ 'config/', 'crates/', @@ -357,6 +364,7 @@ function isCapabilityScanPath(relativePath: string): boolean { 'server/', 'shared/', 'src/', + 'test/', 'tools/', ].some((prefix) => relativePath.startsWith(prefix)) } diff --git a/scripts/retirement/runtime-surfaces.json b/scripts/retirement/runtime-surfaces.json index 3826a4820..de9731831 100644 --- a/scripts/retirement/runtime-surfaces.json +++ b/scripts/retirement/runtime-surfaces.json @@ -88,6 +88,10 @@ { "id": "legacy-test-agent-cli-screenshot", "path": "test/e2e/agent-cli-screenshot-smoke.test.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, { "id": "legacy-test-extension-system", "path": "test/integration/extension-system.test.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, { "id": "legacy-test-codex-remote", "path": "test/integration/real/codex-remote-fork-contract.test.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + { "id": "legacy-test-coding-cli-session-harness", "path": "test/helpers/coding-cli/real-session-contract-harness.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + { "id": "legacy-test-visible-first-protocol-harness", "path": "test/helpers/visible-first/protocol-harness.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + { "id": "legacy-test-visible-first-read-model-harness", "path": "test/helpers/visible-first/read-model-route-harness.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, + { "id": "legacy-test-remote-proxy-forward-child", "path": "test/unit/server/coding-cli/codex-app-server/remote-proxy-large-forward-child.ts", "role": "legacy-test-backend", "listener": "legacy-backend" }, { "id": "port-laptop-bootstrap", "path": "port/laptop-bootstrap/2-bootstrap-wsl.sh", "role": "port-bootstrap" }, { "id": "port-vm-bridge-bootstrap", "path": "port/vm-bridge/agent-console-wsl.sh", "role": "port-bootstrap" }, diff --git a/test/unit/architecture/rust-only-server-runtime.test.ts b/test/unit/architecture/rust-only-server-runtime.test.ts index 7be982704..669b7d625 100644 --- a/test/unit/architecture/rust-only-server-runtime.test.ts +++ b/test/unit/architecture/rust-only-server-runtime.test.ts @@ -79,6 +79,45 @@ describe('runtime boundary analyzer', () => { expect(result.unexpectedNodeBackend).toContain('scripts/invented-listener.ts') }) + it('rejects an unlisted Node listener in an e2e helper regardless of its filename', async () => { + const root = await createSyntheticRoot( + [], + { + 'test/e2e-browser/helpers/rogue-listener.ts': [ + "import http from 'node:http'", + "http.createServer((_req, res) => res.end('nope')).listen(0)", + ].join('\n'), + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.unexpectedNodeBackend).toEqual([ + 'test/e2e-browser/helpers/rogue-listener.ts', + ]) + }) + + it('preserves explicitly recorded legacy test listeners without allowing adjacent helpers', async () => { + const root = await createSyntheticRoot( + [{ + id: 'legacy-helper', + path: 'test/e2e-browser/helpers/legacy-listener.ts', + role: 'legacy-test-backend', + listener: 'legacy-backend', + }], + { + 'test/e2e-browser/helpers/legacy-listener.ts': "require('node:http').createServer().listen(0)\n", + 'test/e2e-browser/helpers/rogue-listener.ts': "require('node:http').createServer().listen(0)\n", + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.unexpectedNodeBackend).toEqual([ + 'test/e2e-browser/helpers/rogue-listener.ts', + ]) + }) + it('allows sanctioned Node roles and the exact non-backend listener rows', async () => { const roleFiles: Record = { 'config/vite/vite.config.ts': "export default { server: { host: '127.0.0.1' } }\n", From ab9e20dbb69655edabc382248709c5e41c77478d Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:04:57 -0700 Subject: [PATCH 07/64] fix: close runtime listener inventory --- scripts/retirement/runtime-boundary.ts | 39 +++++++-- scripts/retirement/runtime-surfaces.json | 55 +++++++++++++ .../rust-only-server-runtime.test.ts | 82 ++++++++++++++++++- 3 files changed, 166 insertions(+), 10 deletions(-) diff --git a/scripts/retirement/runtime-boundary.ts b/scripts/retirement/runtime-boundary.ts index 5c36bb916..c77a495fd 100644 --- a/scripts/retirement/runtime-boundary.ts +++ b/scripts/retirement/runtime-boundary.ts @@ -11,7 +11,7 @@ export type RuntimeSurface = { id: string path: string role: string - listener?: 'non-backend' | 'legacy-backend' + listener?: 'non-backend' | 'legacy-backend' | 'assertion-only' entries?: string[] } @@ -171,6 +171,10 @@ function isFixtureServerPath(relativePath: string): boolean { || relativePath.startsWith('test/e2e-browser/helpers/') } +function isExampleExtensionServerPath(relativePath: string): boolean { + return /^examples\/extensions\/[^/]+\/server\.(?:cjs|js|mjs|ts|tsx)$/.test(relativePath) +} + /** Read and validate the manifest without allowing malformed rows to vanish. */ export async function loadRuntimeSurfaceManifest(root: string): Promise { const manifestPath = path.join(root, ...MANIFEST_RELATIVE_PATH.split('/')) @@ -188,15 +192,23 @@ export async function loadRuntimeSurfaceManifest(root: string): Promise typeof entry !== 'string'))) { throw new Error(`Runtime surface manifest row ${row.id} has invalid entries.`) } + const normalizedPath = validateManifestPath(row.path, row.id) + if (row.listener === 'assertion-only' && ( + row.role !== 'test-listener-assertion' + || !normalizedPath.startsWith('test/') + || !isTestFilePath(normalizedPath) + )) { + throw new Error(`Runtime surface manifest assertion-only row ${row.id} must classify a test implementation.`) + } surfaces.push({ id: row.id, - path: validateManifestPath(row.path, row.id), + path: normalizedPath, role: row.role, ...(row.listener ? { listener: row.listener } : {}), ...(row.entries ? { entries: [...row.entries].sort() } : {}), @@ -250,6 +262,7 @@ async function discoverOwners(root: string, allFiles: string[]): Promise relativePath.startsWith(prefix)) } +function isReviewedAssertionOnlyListener(row: RuntimeSurface | undefined, relativePath: string): boolean { + return row?.listener === 'assertion-only' + && row.role === 'test-listener-assertion' + && relativePath.startsWith('test/') + && isTestFilePath(relativePath) +} + async function detectUnexpectedNodeBackend( root: string, allFiles: string[], @@ -387,14 +404,18 @@ async function detectUnexpectedNodeBackend( // it by relabeling an arbitrary listener as non-backend. if (nonBackendListenerPaths.has(relativePath)) continue if (row?.listener === 'legacy-backend') continue + if (isReviewedAssertionOnlyListener(row, relativePath)) continue unexpected.push(relativePath) } return unexpected.sort() } -async function detectLegacyDebt(root: string): Promise { +async function detectLegacyDebt(root: string, manifest: RuntimeSurfaceManifest): Promise { const debt: string[] = [] + for (const row of manifest.surfaces) { + if (row.listener === 'legacy-backend') debt.push(rowPath(row)) + } for (const check of legacyDebtChecks) { const absolutePath = path.join(root, ...check.relativePath.split('/')) try { @@ -420,7 +441,7 @@ export async function analyzeRuntimeBoundary(root: string): Promise { ]) }) + it('rejects an unlisted Node listener in a test implementation', async () => { + const root = await createSyntheticRoot( + [], + { + 'test/e2e-browser/helpers/rogue-listener.test.ts': [ + "import http from 'node:http'", + "http.createServer((_req, res) => res.end('nope')).listen(0)", + ].join('\n'), + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.unexpectedNodeBackend).toEqual([ + 'test/e2e-browser/helpers/rogue-listener.test.ts', + ]) + }) + + it('rejects an unlisted Node listener in a supported extension example', async () => { + const root = await createSyntheticRoot( + [], + { + 'examples/extensions/rogue/server.js': [ + "import http from 'node:http'", + "http.createServer((_req, res) => res.end('nope')).listen(0)", + ].join('\n'), + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.unexpectedNodeBackend).toEqual([ + 'examples/extensions/rogue/server.js', + ]) + expect(result.manifestDrift).toContain( + 'unlisted owner: examples/extensions/rogue/server.js', + ) + }) + it('preserves explicitly recorded legacy test listeners without allowing adjacent helpers', async () => { const root = await createSyntheticRoot( [{ @@ -118,6 +157,45 @@ describe('runtime boundary analyzer', () => { ]) }) + it('requires an exact assertion-only row for a test implementation listener', async () => { + const root = await createSyntheticRoot( + [{ + id: 'assertion-listener', + path: 'test/unit/assertion-listener.test.ts', + role: 'test-listener-assertion', + listener: 'assertion-only', + }], + { + 'test/unit/assertion-listener.test.ts': "require('node:http').createServer().listen(0)\n", + 'test/unit/rogue-listener.test.ts': "require('node:http').createServer().listen(0)\n", + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect(result.unexpectedNodeBackend).toEqual([ + 'test/unit/rogue-listener.test.ts', + ]) + }) + + it('does not allow assertion-only listener rows outside test implementations', async () => { + const root = await createSyntheticRoot( + [{ + id: 'misclassified', + path: 'scripts/misclassified.ts', + role: 'test-listener-assertion', + listener: 'assertion-only', + }], + { + 'scripts/misclassified.ts': "require('node:http').createServer().listen(0)\n", + }, + ) + + await expect(analyzeRuntimeBoundary(root)).rejects.toThrow( + 'Runtime surface manifest assertion-only row misclassified must classify a test implementation.', + ) + }) + it('allows sanctioned Node roles and the exact non-backend listener rows', async () => { const roleFiles: Record = { 'config/vite/vite.config.ts': "export default { server: { host: '127.0.0.1' } }\n", @@ -277,6 +355,8 @@ describe('runtime boundary inventory for the current checkout', () => { 'test/e2e-browser/playwright.config.ts:legacy-chromium', 'run-rust-server.sh:legacy-comment', 'port/laptop-bootstrap/2-bootstrap-wsl.sh:inherited-build-path', + 'examples/extensions/live-counter/server.js', + 'examples/extensions/status-dashboard/server.js', ])) }) }) From 3b3bf8a523aa709d58fdce1286582f943b583598 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:15:06 -0700 Subject: [PATCH 08/64] fix: close package runtime boundary --- scripts/retirement/runtime-boundary.ts | 63 ++++++++++++++----- scripts/retirement/runtime-surfaces.json | 1 + .../rust-only-server-runtime.test.ts | 33 +++++++++- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/scripts/retirement/runtime-boundary.ts b/scripts/retirement/runtime-boundary.ts index c77a495fd..916d27282 100644 --- a/scripts/retirement/runtime-boundary.ts +++ b/scripts/retirement/runtime-boundary.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { readFile, readdir, stat } from 'node:fs/promises' import path from 'node:path' @@ -5,7 +6,8 @@ import path from 'node:path' * A runtime surface is an executable or resource that must have one owner in * the retirement manifest. Paths are repository-relative POSIX paths. A * package-script surface uses `package.json:scripts` and lists its command - * names in `entries` so adding a command is visible as manifest drift. + * names in `entries` plus a hash of their commands so additions and command + * changes are both visible as manifest drift. */ export type RuntimeSurface = { id: string @@ -13,6 +15,7 @@ export type RuntimeSurface = { role: string listener?: 'non-backend' | 'legacy-backend' | 'assertion-only' entries?: string[] + commandEvidence?: string } export type RuntimeSurfaceManifest = { @@ -199,6 +202,20 @@ export async function loadRuntimeSurfaceManifest(root: string): Promise { } } +async function packageScriptCommandEvidence(root: string): Promise { + try { + const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')) as { scripts?: unknown } + if (!packageJson.scripts || typeof packageJson.scripts !== 'object' || Array.isArray(packageJson.scripts)) return undefined + + const scripts = Object.entries(packageJson.scripts as Record) + if (scripts.some(([, command]) => typeof command !== 'string')) return undefined + + const canonicalCommands = scripts + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([name, command]) => `${JSON.stringify(name)}:${JSON.stringify(command)}`) + .join('\n') + return `sha256:${createHash('sha256').update(canonicalCommands).digest('hex')}` + } catch { + return undefined + } +} + async function reconcileManifest(root: string, manifest: RuntimeSurfaceManifest, discoveredOwners: string[]): Promise { const drift: string[] = [] const rowsByPath = new Map() @@ -338,6 +374,13 @@ async function reconcileManifest(root: string, manifest: RuntimeSurfaceManifest, } if (!await pathExists(root, 'package.json')) { drift.push(`stale manifest row: ${row.id} -> ${relativePath}`) + } else { + const actualEvidence = await packageScriptCommandEvidence(root) + if (actualEvidence === undefined) { + drift.push(`invalid package script commands: ${row.id}`) + } else if (row.commandEvidence !== actualEvidence) { + drift.push(`changed package script command evidence: ${row.id}`) + } } continue } @@ -363,20 +406,10 @@ function hasListenerCapability(contents: string): boolean { } function isCapabilityScanPath(relativePath: string): boolean { - if (isIgnoredRelativePath(relativePath) || !isSourcePath(relativePath)) return false - return [ - 'config/', - 'crates/', - 'electron/', - 'examples/', - 'port/', - 'scripts/', - 'server/', - 'shared/', - 'src/', - 'test/', - 'tools/', - ].some((prefix) => relativePath.startsWith(prefix)) + // Source need not be executable to be launched by a manifest-listed package + // command. Scan every non-ignored Node source file so a root launcher target + // (or a target in a future source directory) cannot evade the boundary. + return !isIgnoredRelativePath(relativePath) && isSourcePath(relativePath) } function isReviewedAssertionOnlyListener(row: RuntimeSurface | undefined, relativePath: string): boolean { diff --git a/scripts/retirement/runtime-surfaces.json b/scripts/retirement/runtime-surfaces.json index c2a04fb27..993cfc1f5 100644 --- a/scripts/retirement/runtime-surfaces.json +++ b/scripts/retirement/runtime-surfaces.json @@ -5,6 +5,7 @@ "id": "package-scripts", "path": "package.json:scripts", "role": "package-commands", + "commandEvidence": "sha256:291a52d5899220d929705e5ad3fee905230c29c75cee098758d4be3461bdfffa", "entries": [ "predev", "dev", "dev:client", "dev:server", "typecheck", "typecheck:client", "typecheck:server", "prebuild", "build", "build:client", "build:server", "build:electron", "build:wizard", "build:launch-chooser", diff --git a/test/unit/architecture/rust-only-server-runtime.test.ts b/test/unit/architecture/rust-only-server-runtime.test.ts index d2b76df8f..17551a189 100644 --- a/test/unit/architecture/rust-only-server-runtime.test.ts +++ b/test/unit/architecture/rust-only-server-runtime.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -12,6 +12,8 @@ type RuntimeSurface = { path: string role: string listener?: 'non-backend' | 'legacy-backend' | 'assertion-only' + entries?: string[] + commandEvidence?: string } const ALLOWED_LISTENER_PATHS = [ @@ -79,6 +81,35 @@ describe('runtime boundary analyzer', () => { expect(result.unexpectedNodeBackend).toContain('scripts/invented-listener.ts') }) + it('rejects a manifest-listed package script that launches a non-executable root Node listener', async () => { + const root = await createSyntheticRoot( + [{ + id: 'package-scripts', + path: 'package.json:scripts', + role: 'package-commands', + entries: ['serve'], + commandEvidence: 'sha256:7c34a7bec5d78bec828822651670524d60a6a53f9cd1c5faec468dd201706f07', + }], + { + 'package.json': JSON.stringify({ + scripts: { serve: 'node runtime-backend.mjs' }, + }), + 'runtime-backend.mjs': [ + "import http from 'node:http'", + "http.createServer((_req, res) => res.end('nope')).listen(0)", + ].join('\n'), + }, + ) + + const result = await analyzeRuntimeBoundary(root) + + expect((await stat(path.join(root, 'runtime-backend.mjs')).mode & 0o111)).toBe(0) + expect(result.manifestDrift).toEqual([ + 'changed package script command evidence: package-scripts', + ]) + expect(result.unexpectedNodeBackend).toEqual(['runtime-backend.mjs']) + }) + it('rejects an unlisted Node listener in an e2e helper regardless of its filename', async () => { const root = await createSyntheticRoot( [], From c8fa06566f87d2d34ec844b48ea6824425bde215 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:30:21 -0700 Subject: [PATCH 09/64] refactor: separate Node clients from legacy server --- crates/freshell-platform/src/cli_launch.rs | 2 +- .../src/cli_launch_goldens.rs | 2 +- crates/freshell-platform/src/mcp_inject.rs | 103 ++- .../freshell-platform/src/mcp_inject_tests.rs | 10 +- package.json | 4 +- server/agent-api/router.ts | 4 +- .../codex-app-server/restore-decision.ts | 4 +- server/mcp/config-writer.ts | 10 +- test/e2e-browser/helpers/mcp-stdio-client.ts | 23 +- .../e2e-browser/specs/mcp-bridge-rust.spec.ts | 6 +- .../specs/mcp-qa-smoke-rust.spec.ts | 10 +- test/e2e/agent-cli-flow.test.ts | 803 ------------------ test/e2e/agent-cli-screenshot-smoke.test.ts | 314 ------- .../tools/rust-action-capability-matrix.json | 6 + test/unit/cli/args.test.ts | 2 +- test/unit/cli/commands.test.ts | 4 +- test/unit/cli/config.test.ts | 2 +- test/unit/cli/http.test.ts | 2 +- test/unit/cli/keys.test.ts | 2 +- test/unit/cli/send-keys-args.test.ts | 2 +- test/unit/cli/targets.test.ts | 2 +- .../{server => }/mcp/freshell-tool.test.ts | 33 +- .../unit/{server => }/mcp/http-client.test.ts | 35 +- test/unit/{server => }/mcp/server.test.ts | 15 +- .../server/mcp/config-writer-paths.test.ts | 2 +- test/unit/server/mcp/config-writer.test.ts | 14 +- {server/cli => tools/freshell-cli}/args.ts | 0 .../freshell-cli}/commands/sendKeys.ts | 2 +- {server/cli => tools/freshell-cli}/http.ts | 4 +- {server/cli => tools/freshell-cli}/index.ts | 8 +- {server/cli => tools/freshell-cli}/output.ts | 0 .../freshell-cli}/send-keys-args.ts | 0 {server/cli => tools/freshell-cli}/targets.ts | 0 .../freshell-mcp}/freshell-tool.ts | 22 +- .../mcp => tools/freshell-mcp}/http-client.ts | 0 {server/mcp => tools/freshell-mcp}/server.ts | 2 +- .../action-capabilities.ts | 49 ++ .../codex-restore-contract.ts | 3 + tools/node-client-runtime/config.ts | 27 + tools/node-client-runtime/keys.ts | 12 + tsconfig.tools.json | 16 + 41 files changed, 312 insertions(+), 1249 deletions(-) delete mode 100644 test/e2e/agent-cli-flow.test.ts delete mode 100644 test/e2e/agent-cli-screenshot-smoke.test.ts create mode 100644 test/fixtures/tools/rust-action-capability-matrix.json rename test/unit/{server => }/mcp/freshell-tool.test.ts (97%) rename test/unit/{server => }/mcp/http-client.test.ts (85%) rename test/unit/{server => }/mcp/server.test.ts (95%) rename {server/cli => tools/freshell-cli}/args.ts (100%) rename {server/cli => tools/freshell-cli}/commands/sendKeys.ts (84%) rename {server/cli => tools/freshell-cli}/http.ts (92%) rename {server/cli => tools/freshell-cli}/index.ts (99%) rename {server/cli => tools/freshell-cli}/output.ts (100%) rename {server/cli => tools/freshell-cli}/send-keys-args.ts (100%) rename {server/cli => tools/freshell-cli}/targets.ts (100%) rename {server/mcp => tools/freshell-mcp}/freshell-tool.ts (97%) rename {server/mcp => tools/freshell-mcp}/http-client.ts (100%) rename {server/mcp => tools/freshell-mcp}/server.ts (94%) create mode 100644 tools/node-client-runtime/action-capabilities.ts create mode 100644 tools/node-client-runtime/codex-restore-contract.ts create mode 100644 tools/node-client-runtime/config.ts create mode 100644 tools/node-client-runtime/keys.ts create mode 100644 tsconfig.tools.json 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..109c42b37 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); diff --git a/crates/freshell-platform/src/mcp_inject.rs b/crates/freshell-platform/src/mcp_inject.rs index a04955842..d292de769 100644 --- a/crates/freshell-platform/src/mcp_inject.rs +++ b/crates/freshell-platform/src/mcp_inject.rs @@ -1,4 +1,4 @@ -//! MCP config injection — the IO port of `server/mcp/config-writer.ts` +//! MCP config injection for the retained standalone MCP client //! (`port/machine/specs/cli-argv-fidelity.md` §3.2). //! //! Per-mode injection (`generateMcpInjection`, `cw:252-423`): @@ -20,10 +20,10 @@ //! server of its own, so this port adopts **option (a)**: resolve the SAME //! Node-repo layout — repo root found by walking up from the process cwd //! looking for a `package.json` with `"name": "freshell"` (the reference walks -//! from `server/mcp/`; both resolve the same root when the server runs from +//! from the standalone tools tree; both resolve the same root when the server runs from //! the repo checkout, which is the deployment under test) — and inject the //! reference-identical `node --import /node_modules/tsx/dist/loader.mjs -//! /server/mcp/server.ts` (dev) or `/dist/server/mcp/server.js` +//! /tools/freshell-mcp/server.ts` (dev) or `/dist/tools/freshell-mcp/server.js` //! (production build present + `NODE_ENV=production`). When `tsx` cannot be //! resolved the reference-exact error is raised (`cw:72-79`). The golden tests //! inject [`McpRuntime::server_command_args`] as a seam, so they remain valid @@ -68,6 +68,15 @@ pub enum McpServerArg { Path(String), } +/// An MCP command is a complete executable plus its arguments. Keeping the +/// executable tagged alongside arguments prevents platform conversion from +/// silently leaving a path-valued command on the wrong side of WSL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpServerCommand { + pub command: McpServerArg, + pub args: Vec, +} + /// The environment seam for the config writer: tmp dir (`os.tmpdir()`), WSL /// detection (`cw:45-51`), `wslpath -w` conversion (`cw:57-70`), and the MCP /// server command args (U1 seam — `cw:89-107`). @@ -84,6 +93,15 @@ pub trait McpRuntime { /// minus the `needsWinPaths` mapping, which [`build_mcp_server_command_args`] /// applies. fn server_command_args(&self) -> Result, McpInjectError>; + + /// Complete server command. The default preserves the existing seam for + /// test runtimes while production overrides it with the explicit command. + fn server_command(&self) -> Result { + Ok(McpServerCommand { + command: McpServerArg::Literal("node".to_string()), + args: self.server_command_args()?, + }) + } } /// The live runtime (see the module-level U1 decision). @@ -108,15 +126,36 @@ impl McpRuntime for RealMcpRuntime { } fn server_command_args(&self) -> Result, McpInjectError> { + Ok(self.server_command()?.args) + } + + fn server_command(&self) -> Result { + let node = std::env::var("FRESHELL_MCP_NODE").ok(); + let entry = std::env::var("FRESHELL_MCP_ENTRY").ok(); + match (node, entry) { + (Some(command), Some(entry)) if !command.is_empty() && !entry.is_empty() => { + return Ok(McpServerCommand { + command: McpServerArg::Path(command), + args: vec![McpServerArg::Path(entry)], + }); + } + (Some(_), None) | (None, Some(_)) | (Some(_), Some(_)) => { + return Err(McpInjectError::new( + "FRESHELL_MCP_NODE and FRESHELL_MCP_ENTRY must be configured together.", + )); + } + (None, None) => {} + } let repo_root = find_repo_root(); - let built = repo_root.join("dist/server/mcp/server.js"); + let built = repo_root.join("dist/tools/freshell-mcp/server.js"); let node_env_production = std::env::var("NODE_ENV") .map(|v| v == "production") .unwrap_or(false); if node_env_production && built.is_file() { - return Ok(vec![McpServerArg::Path( - built.to_string_lossy().into_owned(), - )]); + return Ok(McpServerCommand { + command: McpServerArg::Literal("node".to_string()), + args: vec![McpServerArg::Path(built.to_string_lossy().into_owned())], + }); } // `require.resolve('tsx')` resolves the package export "." → // `./dist/loader.mjs` (rev 2 pin vs node_modules/tsx/package.json). @@ -126,16 +165,19 @@ impl McpRuntime for RealMcpRuntime { "Unable to resolve MCP dependency \"tsx\". Ensure project dependencies are installed.", )); } - Ok(vec![ - McpServerArg::Literal("--import".to_string()), - McpServerArg::Path(tsx.to_string_lossy().into_owned()), - McpServerArg::Path( + Ok(McpServerCommand { + command: McpServerArg::Literal("node".to_string()), + args: vec![ + McpServerArg::Literal("--import".to_string()), + McpServerArg::Path(tsx.to_string_lossy().into_owned()), + McpServerArg::Path( repo_root - .join("server/mcp/server.ts") + .join("tools/freshell-mcp/server.ts") .to_string_lossy() .into_owned(), - ), - ]) + ), + ], + }) } } @@ -222,6 +264,23 @@ pub fn build_mcp_server_command_args( .collect()) } +/// Render a complete MCP command for a provider target. This is the canonical +/// path used by every injection renderer; the args-only helper remains for +/// compatibility with existing callers and focused goldens. +pub fn build_mcp_server_command( + rt: &dyn McpRuntime, + target: ProviderTarget, +) -> Result<(String, Vec), McpInjectError> { + let needs_win_paths = target == ProviderTarget::Windows && rt.is_wsl_environment(); + let command = rt.server_command()?; + let convert = |arg: McpServerArg| match arg { + McpServerArg::Literal(value) => value, + McpServerArg::Path(value) if needs_win_paths => rt.convert_to_windows_path(&value), + McpServerArg::Path(value) => value, + }; + Ok((convert(command.command), command.args.into_iter().map(convert).collect())) +} + /// `tomlEscape` (`cw:142-144`): wrap in `"` with `\` → `\\` and `"` → `\"`. pub fn toml_escape(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) @@ -285,11 +344,11 @@ fn write_mcp_config_file( if let Some(dir) = file_path.parent() { std::fs::create_dir_all(dir).map_err(|e| McpInjectError::new(e.to_string()))?; } - let server_args = build_mcp_server_command_args(rt, target)?; + let (server_command, server_args) = build_mcp_server_command(rt, target)?; let config = serde_json::json!({ "mcpServers": { "freshell": { - "command": "node", + "command": server_command, "args": server_args, } } @@ -477,12 +536,12 @@ fn opencode_inject( }; if !user_managed { - let server_args = build_mcp_server_command_args(rt, target)?; + let (server_command, server_args) = build_mcp_server_command(rt, target)?; let obj = existing_config.as_object_mut().expect("validated object"); if !obj.get("mcp").map(|m| m.is_object()).unwrap_or(false) { obj.insert("mcp".to_string(), serde_json::json!({})); } - let mut command = vec![serde_json::Value::String("node".to_string())]; + let mut command = vec![serde_json::Value::String(server_command)]; command.extend(server_args.into_iter().map(serde_json::Value::String)); obj.get_mut("mcp") .and_then(|m| m.as_object_mut()) @@ -548,9 +607,13 @@ 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: { + let mut args = codex_inline_toml_args(&server_args); + args[1] = format!("mcp_servers.freshell.command={}", toml_escape(&server_command)); + args + }, env: BTreeMap::new(), }) } diff --git a/crates/freshell-platform/src/mcp_inject_tests.rs b/crates/freshell-platform/src/mcp_inject_tests.rs index 6c3780d67..b43e4d5f0 100644 --- a/crates/freshell-platform/src/mcp_inject_tests.rs +++ b/crates/freshell-platform/src/mcp_inject_tests.rs @@ -60,7 +60,7 @@ fn mcp_unix_args() -> Vec { vec![ McpServerArg::Literal("--import".to_string()), McpServerArg::Path("/repo/node_modules/tsx/dist/loader.mjs".to_string()), - McpServerArg::Path("/repo/server/mcp/server.ts".to_string()), + McpServerArg::Path("/repo/tools/freshell-mcp/server.ts".to_string()), ] } @@ -87,7 +87,7 @@ fn claude_writes_tmp_json_0600_pretty_two_space() { ); assert!(inj.env.is_empty()); let written = std::fs::read_to_string(&expected_path).unwrap(); - let expected_json = "{\n \"mcpServers\": {\n \"freshell\": {\n \"command\": \"node\",\n \"args\": [\n \"--import\",\n \"/repo/node_modules/tsx/dist/loader.mjs\",\n \"/repo/server/mcp/server.ts\"\n ]\n }\n }\n}"; + let expected_json = "{\n \"mcpServers\": {\n \"freshell\": {\n \"command\": \"node\",\n \"args\": [\n \"--import\",\n \"/repo/node_modules/tsx/dist/loader.mjs\",\n \"/repo/tools/freshell-mcp/server.ts\"\n ]\n }\n }\n}"; assert_eq!(written, expected_json); #[cfg(unix)] { @@ -145,7 +145,7 @@ fn g_x4_codex_windows_target_on_wsl_unc_toml() { assert_eq!(inj.args[2], "-c"); assert_eq!( inj.args[3], - "mcp_servers.freshell.args=[\"--import\", \"\\\\\\\\wsl.localhost\\\\Ubuntu\\\\repo\\\\node_modules\\\\tsx\\\\dist\\\\loader.mjs\", \"\\\\\\\\wsl.localhost\\\\Ubuntu\\\\repo\\\\server\\\\mcp\\\\server.ts\"]" + "mcp_servers.freshell.args=[\"--import\", \"\\\\\\\\wsl.localhost\\\\Ubuntu\\\\repo\\\\node_modules\\\\tsx\\\\dist\\\\loader.mjs\", \"\\\\\\\\wsl.localhost\\\\Ubuntu\\\\repo\\\\tools\\\\freshell-mcp\\\\server.ts\"]" ); } @@ -157,7 +157,7 @@ fn codex_unix_target_on_wsl_keeps_host_paths() { let inj = generate_mcp_injection(&rt, "codex", "term1", None, ProviderTarget::Unix).unwrap(); assert_eq!( inj.args[3], - "mcp_servers.freshell.args=[\"--import\", \"/repo/node_modules/tsx/dist/loader.mjs\", \"/repo/server/mcp/server.ts\"]" + "mcp_servers.freshell.args=[\"--import\", \"/repo/node_modules/tsx/dist/loader.mjs\", \"/repo/tools/freshell-mcp/server.ts\"]" ); } @@ -258,7 +258,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/package.json b/package.json index 926bf6b24..c7c99cfa1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.7.5", "type": "module", "bin": { - "freshell": "dist/server/cli/index.js" + "freshell": "dist/tools/freshell-cli/index.js" }, "main": "dist/electron/electron/entry.js", "engines": { @@ -18,10 +18,12 @@ "typecheck": "npm run typecheck:client && npm run typecheck:server", "typecheck:client": "tsc -p tsconfig.json --noEmit", "typecheck:server": "tsc -p tsconfig.server.json --noEmit", + "typecheck:tools": "tsc -p tsconfig.tools.json --noEmit", "prebuild": "tsx scripts/prebuild-guard.ts", "build": "npm run typecheck:client && npm run build:client && npm run build:server", "build:client": "vite build --config config/vite/vite.config.ts", "build:server": "tsc -p tsconfig.server.json && node scripts/bake-server-build-id.mjs", + "build:tools": "tsc -p tsconfig.tools.json", "build:electron": "node -e \"const fs=require('fs');fs.rmSync('dist/electron',{recursive:true,force:true});fs.rmSync('node_modules/.cache/tsconfig.electron.tsbuildinfo',{force:true});fs.rmSync('node_modules/.cache/tsconfig.electron-preload.tsbuildinfo',{force:true})\" && tsc -p tsconfig.electron.json && tsc -p tsconfig.electron-preload.json", "build:wizard": "vite build --config config/vite/vite.wizard.config.ts", "build:launch-chooser": "vite build --config config/vite/vite.launch-chooser.config.ts", diff --git a/server/agent-api/router.ts b/server/agent-api/router.ts index ce9fa4514..4b84ee08b 100644 --- a/server/agent-api/router.ts +++ b/server/agent-api/router.ts @@ -12,7 +12,7 @@ import { getCodexSessionBindingReason, normalizeCodexSandboxSetting, } from '../coding-cli/codex-launch-config.js' -import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../../tools/node-client-runtime/codex-restore-contract.js' import { makeSessionKey } from '../coding-cli/types.js' import { terminalIdFromCreateError, UnknownTerminalModeError, type ProviderSettings, type TerminalInputResult } from '../terminal-registry.js' import { buildSessionIdentityMismatchDetails, terminalMatchesExpectedSession } from '../terminal-session-identity.js' @@ -25,7 +25,7 @@ import { resolveScreenshotOutputPath } from './screenshot-path.js' import { sanitizeSessionRef } from '../../shared/session-contract.js' import type { LayoutStore } from './layout-store.js' import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '../../shared/fresh-agent.js' -import { translateKeys } from '../cli/keys.js' +import { translateKeys } from '../../tools/node-client-runtime/keys.js' import type { FreshAgentSessionLocator, FreshAgentThreadLocator, diff --git a/server/coding-cli/codex-app-server/restore-decision.ts b/server/coding-cli/codex-app-server/restore-decision.ts index 937585309..78a2c0d7c 100644 --- a/server/coding-cli/codex-app-server/restore-decision.ts +++ b/server/coding-cli/codex-app-server/restore-decision.ts @@ -1,4 +1,6 @@ import type { SessionRef } from '../../../shared/session-contract.js' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../../../tools/node-client-runtime/codex-restore-contract.js' +export { INVALID_RAW_CODEX_RESUME_MESSAGE } import type { CodexCandidateIdentity, CodexDurabilityRef } from '../../../shared/codex-durability.js' type MaybePromise = T | Promise @@ -24,8 +26,6 @@ export type CodexCreateRestorePlan = export type CodexCreateRestoreDecision = | CodexCreateRestorePlan -export const INVALID_RAW_CODEX_RESUME_MESSAGE = - 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.' export const MISSING_CODEX_SESSION_REF_MESSAGE = 'Restore requires a canonical session reference.' diff --git a/server/mcp/config-writer.ts b/server/mcp/config-writer.ts index 797ec3663..f06f00c27 100644 --- a/server/mcp/config-writer.ts +++ b/server/mcp/config-writer.ts @@ -80,8 +80,8 @@ function resolveDependencyPath(specifier: string): string { /** * Build the MCP server command + args for the given environment. - * In production with a built server: node /dist/server/mcp/server.js - * In development: node --import /node_modules/tsx/dist/esm/index.mjs /server/mcp/server.ts + * In production with built tools: node /dist/tools/freshell-mcp/server.js + * In development: node --import /node_modules/tsx/dist/esm/index.mjs /tools/freshell-mcp/server.ts * * When platform is 'windows' and running on WSL, paths are converted to * Windows UNC format so Windows-native agent processes can resolve them. @@ -95,14 +95,14 @@ export function buildMcpServerCommandArgs(platform?: 'unix' | 'windows'): string return needsWinPaths ? convertToWindowsPath(resolved) : resolved } - const builtServerPath = resolve(repoRoot, 'dist/server/mcp/server.js') + const builtServerPath = resolve(repoRoot, 'dist/tools/freshell-mcp/server.js') if (process.env.NODE_ENV === 'production' && fs.existsSync(builtServerPath)) { - return [resolveRepoPath('dist/server/mcp/server.js')] + return [resolveRepoPath('dist/tools/freshell-mcp/server.js')] } return [ '--import', resolveDependencyForPlatform('tsx'), - resolveRepoPath('server/mcp/server.ts'), + resolveRepoPath('tools/freshell-mcp/server.ts'), ] } diff --git a/test/e2e-browser/helpers/mcp-stdio-client.ts b/test/e2e-browser/helpers/mcp-stdio-client.ts index 7b563a705..fa08a43a8 100644 --- a/test/e2e-browser/helpers/mcp-stdio-client.ts +++ b/test/e2e-browser/helpers/mcp-stdio-client.ts @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url' * over stdin/stdout, no `Content-Length` framing (unlike LSP). Deliberately * NOT built on the SDK's own `Client`/`StdioClientTransport` -- this hand-rolls * the raw wire contract so a regression in the SERVER's framing (e.g. an - * accidental `console.log` corrupting the stdio channel, which `server/mcp/server.ts`'s + * accidental `console.log` corrupting the stdio channel, which the MCP entrypoint's * own doc comment calls out as the one hard rule) is caught even if the SDK's * client-side transport were ever to compensate for malformed output. */ @@ -33,15 +33,14 @@ function findRepoRootFrom(startDir: string): string { /** Absolute path to this worktree's repo root (ported pattern from `rust-server.ts`). */ export const REPO_ROOT = findRepoRootFrom(__dirname) -/** Absolute path of the built (frozen-source) MCP stdio binary. */ +/** Absolute path of the built standalone MCP stdio binary. */ export function mcpServerBinPath(root: string = REPO_ROOT): string { - return path.join(root, 'dist', 'server', 'mcp', 'server.js') + return path.join(root, 'dist', 'tools', 'freshell-mcp', 'server.js') } /** - * Ensure `dist/server/mcp/server.js` (built from the FROZEN `server/mcp/` - * source -- consumed here, never edited) exists and is current, by running - * `npm run build:server` (`tsc -p tsconfig.server.json`). That project has + * Ensure `dist/tools/freshell-mcp/server.js` exists and is current by running + * `npm run build:tools` (`tsc -p tsconfig.tools.json`). That project has * `incremental: true` with a committed `tsBuildInfo` cache * (`node_modules/.cache/tsconfig.server.tsbuildinfo`), so it is safe and fast * to run UNCONDITIONALLY on every call rather than hand-rolling mtime-staleness @@ -53,7 +52,7 @@ export function mcpServerBinPath(root: string = REPO_ROOT): string { export function ensureMcpServerBuilt(root: string = REPO_ROOT): { path: string; buildMs: number } { const bin = mcpServerBinPath(root) const start = Date.now() - const result = spawnSync('npm', ['run', 'build:server'], { + const result = spawnSync('npm', ['run', 'build:tools'], { cwd: root, stdio: 'pipe', encoding: 'utf8', @@ -61,13 +60,13 @@ export function ensureMcpServerBuilt(root: string = REPO_ROOT): { path: string; const buildMs = Date.now() - start if (result.status !== 0) { throw new Error( - `npm run build:server failed (exit ${result.status ?? 'signal ' + result.signal}); ` + - `cannot boot the MCP bridge fixture without a current dist/server/mcp/server.js.\n` + + `npm run build:tools failed (exit ${result.status ?? 'signal ' + result.signal}); ` + + `cannot boot the MCP bridge fixture without a current dist/tools/freshell-mcp/server.js.\n` + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ) } if (!fs.existsSync(bin)) { - throw new Error(`npm run build:server completed but ${bin} is still missing.`) + throw new Error(`npm run build:tools completed but ${bin} is still missing.`) } return { path: bin, buildMs } } @@ -156,7 +155,7 @@ export class McpStdioClient { message = JSON.parse(line) } catch (error) { // A non-JSON line on stdout is itself a protocol violation -- the - // "no console.log" rule in `server/mcp/server.ts` exists precisely to + // The MCP entrypoint's "no console.log" rule exists precisely to // prevent this. Surface it loudly instead of silently dropping it. throw new Error( `MCP server wrote a non-JSON line to stdout (stdio channel corruption): ${JSON.stringify(line)}\n` + @@ -217,7 +216,7 @@ export class McpStdioClient { /** * Call the single `freshell` tool with `{action, params}` and unwrap the - * `content[0].text` payload. `server/mcp/server.ts`'s tool handler always + * `content[0].text` payload. The MCP tool handler always * `JSON.stringify()`s the raw action result -- even when that result is * itself a plain string (e.g. `capture-pane`'s scrollback text) -- so * `JSON.parse`ing it back here recovers the original value in both cases. diff --git a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts index b1da7ec8b..3fc54b2f1 100644 --- a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts +++ b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts @@ -11,8 +11,8 @@ import { McpStdioClient, ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp- * (`docs/plans/2026-07-18-agent-api-mcp-parity-spec.md` \u00a76 "QA-Lever Design", * \u00a78.3 "One MCP smoke"). * - * Proves the legacy Node MCP stdio binary (`server/mcp/` -- FROZEN, consumed - * here ONLY as the already-BUILT `dist/server/mcp/server.js`, never edited) + * Proves the retained standalone MCP stdio binary under `tools/freshell-mcp/` + * through its built `dist/tools/freshell-mcp/server.js` entrypoint. * drives an OWNED, ephemeral Rust `freshell-server` end-to-end over its REAL * stdio JSON-RPC wire protocol, with ZERO Rust-side MCP code. This is the * "zero-Rust-MCP" QA lever the spec's \u00a76.2 describes: the moment the Rust @@ -48,7 +48,7 @@ test.describe('MCP bridge -- Rust QA lever pin (Slice 2)', () => { test('unmodified legacy MCP stdio binary drives an ephemeral Rust server end-to-end', async () => { const { path: mcpBinPath, buildMs } = ensureMcpServerBuilt(REPO_ROOT) // eslint-disable-next-line no-console - console.error(`[mcp-bridge-rust] npm run build:server completed in ${buildMs}ms (dist/server/mcp/server.js)`) + console.error(`[mcp-bridge-rust] npm run build:tools completed in ${buildMs}ms (dist/tools/freshell-mcp/server.js)`) const server = new RustServer({ verbose: false }) const info = await server.start() diff --git a/test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts b/test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts index 294548dfa..36f9d4ef8 100644 --- a/test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts +++ b/test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts @@ -11,8 +11,8 @@ import { McpStdioClient, ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp- * MCP QA SMOKE -- the full-mode-matrix payoff of the QA lever (Slice 2 of * `docs/plans/2026-07-18-agent-api-mcp-parity-spec.md` \u00a76/\u00a78.3, which * `mcp-bridge-rust.spec.ts` pins for `mode:"shell"` only). This spec drives - * the SAME unmodified legacy Node MCP stdio binary (`dist/server/mcp/server.js`, - * built from the FROZEN `server/mcp/` -- never edited) against ONE owned, + * the retained standalone Node MCP stdio binary (`dist/tools/freshell-mcp/server.js`) + * against one owned, * ephemeral Rust `freshell-server`, but exercises EVERY pane mode the Rust * Slice-1/3a/3b REST surface now supports: shell, amplifier (fresh + resume), * opencode (fresh), codex (fresh + resume-via-sessionRef), browser, editor, @@ -68,7 +68,7 @@ test.describe('MCP QA smoke -- Rust full mode-matrix (QA-lever payoff)', () => { test('the unmodified legacy MCP stdio binary drives an ephemeral Rust server across every pane mode', async () => { const { path: mcpBinPath, buildMs } = ensureMcpServerBuilt(REPO_ROOT) // eslint-disable-next-line no-console - console.error(`[mcp-qa-smoke-rust] npm run build:server completed in ${buildMs}ms (dist/server/mcp/server.js)`) + console.error(`[mcp-qa-smoke-rust] npm run build:tools completed in ${buildMs}ms (dist/tools/freshell-mcp/server.js)`) const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-mcp-qa-smoke-')) const binDir = path.join(sharedRoot, 'bin') @@ -119,7 +119,7 @@ test.describe('MCP QA smoke -- Rust full mode-matrix (QA-lever payoff)', () => { // ----------------------------------------------------------------- // 1. SHELL (control) -- already pinned end-to-end by // `mcp-bridge-rust.spec.ts`; one quick assertion here just proves - // this suite's OWN server/mcp wiring is sound before moving to the + // this suite's standalone MCP wiring is sound before moving to the // modes that suite doesn't cover. // ----------------------------------------------------------------- const shellTab = await mcp.callFreshellAction('new-tab', { mode: 'shell', cwd: sharedRoot }) @@ -246,7 +246,7 @@ test.describe('MCP QA smoke -- Rust full mode-matrix (QA-lever payoff)', () => { // ----------------------------------------------------------------- // 4. CODEX -- fresh launch, THEN resume via the `sessionRef` param // directly (NOT the raw `resume` string -- `rejectRawCodexResume` - // in `server/mcp/freshell-tool.ts` rejects a raw codex + // in the standalone MCP tool rejects a raw codex // `resume`/`resumeSessionId` with no matching `sessionRef` outright, // mirrored by the Rust `requested_resume_session_id_for_mode`). // The current dist MCP binary's `new-tab` DOES accept an explicit diff --git a/test/e2e/agent-cli-flow.test.ts b/test/e2e/agent-cli-flow.test.ts deleted file mode 100644 index b5a2830ec..000000000 --- a/test/e2e/agent-cli-flow.test.ts +++ /dev/null @@ -1,803 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { spawn } from 'child_process' -import path from 'path' -import { createRequire } from 'module' -import fs from 'node:fs/promises' -import express from 'express' -import http from 'http' -import { createAgentApiRouter } from '../../server/agent-api/router' -import { LayoutStore } from '../../server/agent-api/layout-store' -import { FakeCodexLaunchPlanner } from '../helpers/coding-cli/fake-codex-launch-planner.js' -import WebSocket from 'ws' -import { WsHandler } from '../../server/ws-handler.js' -import { TerminalRegistry } from '../../server/terminal-registry.js' -import { WS_PROTOCOL_VERSION } from '../../shared/ws-protocol.js' - -function startTestServer( - layoutStoreOverrides: Partial> = {}, - options: { wsHandler?: any; registry?: any; codexActivityTracker?: any } = {}, -) { - const app = express() - app.use(express.json()) - const codexLaunchPlanner = new FakeCodexLaunchPlanner() - app.use('/api', createAgentApiRouter({ - layoutStore: { - listTabs: () => ([{ id: 'tab_1', title: 'Alpha', activePaneId: 'pane_1' }]), - listPanes: () => ([{ id: 'pane_1', index: 0, kind: 'terminal', terminalId: 'term_1' }]), - getActiveTabId: () => 'tab_1', - resolvePaneToTerminal: () => 'term_1', - ...layoutStoreOverrides, - }, - registry: { - create: () => ({ terminalId: 'term_1' }), - get: () => ({ - mode: 'codex', - status: 'running', - buffer: { - snapshot: () => '$ ', - }, - }), - ...options.registry, - }, - wsHandler: options.wsHandler, - codexActivityTracker: options.codexActivityTracker, - codexLaunchPlanner, - })) - - const server = http.createServer(app) - return new Promise<{ url: string; close: () => Promise }>((resolve) => { - server.listen(0, () => { - const { port } = server.address() as { port: number } - resolve({ - url: `http://localhost:${port}`, - close: () => new Promise((done) => server.close(() => done())), - }) - }) - }) -} - -function resolveCliPaths() { - const require = createRequire(import.meta.url) - const tsxRoot = path.dirname(require.resolve('tsx/package.json')) - return { - tsxPath: path.join(tsxRoot, 'dist', 'cli.mjs'), - cliPath: path.resolve(__dirname, '../../server/cli/index.ts'), - } -} - -async function runCli(url: string, args: string[]) { - const result = await runCliResult(url, args) - if (result.code !== 0) throw new Error(`cli exited ${result.code}: ${result.stderr}`) - return { stdout: result.stdout, stderr: result.stderr } -} - -async function runCliResult(url: string, args: string[]) { - const { tsxPath, cliPath } = resolveCliPaths() - const proc = spawn(process.execPath, [tsxPath, cliPath, ...args], { - env: { ...process.env, FRESHELL_URL: url, FRESHELL_TOKEN: 'test-token' }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - - return await new Promise<{ code: number; stdout: string; stderr: string }>((resolve, reject) => { - let stdout = '' - let stderr = '' - proc.stdout.on('data', (chunk) => { stdout += chunk.toString() }) - proc.stderr.on('data', (chunk) => { stderr += chunk.toString() }) - proc.on('error', reject) - proc.on('close', (code) => { - resolve({ code: code ?? 0, stdout, stderr }) - }) - }) -} - -async function startTestServerWithRealLayoutStore() { - const layoutStore = new LayoutStore() - const app = express() - app.use(express.json()) - const codexLaunchPlanner = new FakeCodexLaunchPlanner() - - let terminalCount = 0 - app.use('/api', createAgentApiRouter({ - layoutStore, - registry: { - create: () => ({ terminalId: `term_${++terminalCount}` }), - get: () => undefined, - input: () => {}, - }, - codexLaunchPlanner, - })) - - const server = http.createServer(app) - return await new Promise<{ url: string; layoutStore: LayoutStore; close: () => Promise }>((resolve) => { - server.listen(0, () => { - const { port } = server.address() as { port: number } - resolve({ - url: `http://localhost:${port}`, - layoutStore, - close: () => new Promise((done) => server.close(() => done())), - }) - }) - }) -} - -async function runCliJson(url: string, args: string[]) { - const output = await runCli(url, args) - return JSON.parse(output.stdout) as T -} - -async function waitForExpect(assertions: () => void, timeoutMs = 2000, intervalMs = 25) { - const deadline = Date.now() + timeoutMs - let lastError: unknown - - while (Date.now() < deadline) { - try { - assertions() - return - } catch (error) { - lastError = error - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)) - } - - throw lastError ?? new Error('Timed out waiting for expectations to pass') -} - -function findPaneContent(node: any, paneId: string): any | undefined { - if (!node) return undefined - if (node.type === 'leaf') return node.id === paneId ? node.content : undefined - if (node.type === 'split') { - return findPaneContent(node.children?.[0], paneId) ?? findPaneContent(node.children?.[1], paneId) - } - return undefined -} - -describe('cli e2e flow', () => { - // ejh6: the WS door rejects a raw legacy `resumeSessionId` carry on - // terminal.create with INVALID_MESSAGE + the frozen text. This reuses the - // existing WsHandler + http.createServer infrastructure (not a new harness). - it('rejects a raw legacy WS terminal.create with INVALID_MESSAGE + frozen text', async () => { - const previousAuthToken = process.env.AUTH_TOKEN - process.env.AUTH_TOKEN = 'test-token' - const layoutStore = new LayoutStore() - const app = express() - app.use(express.json()) - const codexLaunchPlanner = new FakeCodexLaunchPlanner() - let terminalCount = 0 - app.use('/api', createAgentApiRouter({ - layoutStore, - registry: { create: () => ({ terminalId: `term_${++terminalCount}` }), get: () => undefined, input: () => {} }, - codexLaunchPlanner, - })) - const server = http.createServer(app) - const registry = new TerminalRegistry() - const handler = new WsHandler(server, registry, { codexLaunchPlanner } as never) - await new Promise((resolve) => server.listen(0, () => resolve())) - const { port } = server.address() as { port: number } - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) - try { - await new Promise((resolve, reject) => { - ws.on('open', () => { - ws.send(JSON.stringify({ type: 'hello', token: 'test-token', protocolVersion: WS_PROTOCOL_VERSION })) - }) - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) - if (msg.type === 'ready') resolve() - }) - ws.on('error', reject) - }) - const requestId = 'raw-legacy-ws-1' - const errorPromise = new Promise((resolve) => { - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) - if (msg.type === 'error' && msg.requestId === requestId) resolve(msg) - }) - }) - ws.send(JSON.stringify({ - type: 'terminal.create', requestId, mode: 'claude', shell: 'system', - resumeSessionId: 'legacy-ws-id', - })) - const error = await errorPromise - expect(error).toMatchObject({ - type: 'error', code: 'INVALID_MESSAGE', - message: 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.', - requestId, - }) - } finally { - ws.close() - handler.close?.() - if (previousAuthToken === undefined) { - delete process.env.AUTH_TOKEN - } else { - process.env.AUTH_TOKEN = previousAuthToken - } - await new Promise((done) => server.close(() => done())) - } - }) - - it('runs list-tabs end-to-end', async () => { - const { url, close } = await startTestServer() - try { - const output = await runCli(url, ['list-tabs', '--json']) - - expect(output.stdout).toContain('tabs') - } finally { - await close() - } - }) - - it('uses active tab id when display has no target', async () => { - const { url, close } = await startTestServer({ - listTabs: () => ([ - { id: 'tab_1', title: 'Alpha', activePaneId: 'pane_1' }, - { id: 'tab_2', title: 'Beta', activePaneId: 'pane_2' }, - ]), - listPanes: (tabId?: string) => { - if (tabId === 'tab_2') return [{ id: 'pane_2', index: 0, kind: 'terminal', terminalId: 'term_2' }] - return [{ id: 'pane_1', index: 0, kind: 'terminal', terminalId: 'term_1' }] - }, - getActiveTabId: () => 'tab_2', - }) - try { - const output = await runCli(url, ['display', '-p', '#I']) - - expect(output.stdout.trim()).toBe('tab_2') - } finally { - await close() - } - }) - - it('uses the first pane in the active tab when pane commands omit a target and tabs omit activePaneId', async () => { - const renamePane = vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })) - const { url, close } = await startTestServer({ - listTabs: () => ([ - { id: 'tab_1', title: 'Alpha' }, - ]), - listPanes: () => ([ - { id: 'pane_1', index: 0, kind: 'terminal', terminalId: 'term_1', title: 'Shell' }, - ]), - renamePane, - getPaneSnapshot: () => ({ - tabId: 'tab_1', - paneId: 'pane_1', - paneContent: { kind: 'terminal', mode: 'shell', terminalId: 'term_1' }, - }), - }) - try { - const output = await runCli(url, ['rename-pane', 'Renamed shell']) - const parsed = JSON.parse(output.stdout) as { status: string } - - expect(parsed.status).toBe('ok') - expect(renamePane).toHaveBeenCalledWith('pane_1', 'Renamed shell') - } finally { - await close() - } - }) - - it('rejects ambiguous pane title targets', async () => { - const { url, close } = await startTestServer({ - listTabs: () => ([ - { id: 'tab_1', title: 'Alpha', activePaneId: 'pane_1' }, - { id: 'tab_2', title: 'Beta', activePaneId: 'pane_2' }, - ]), - listPanes: (tabId?: string) => { - if (tabId === 'tab_2') { - return [{ id: 'pane_2', index: 0, kind: 'terminal', terminalId: 'term_2', title: 'Shell' }] - } - return [{ id: 'pane_1', index: 0, kind: 'terminal', terminalId: 'term_1', title: 'Shell' }] - }, - }) - try { - const output = await runCliResult(url, ['select-pane', '-t', 'Shell']) - - expect(output.code).toBe(1) - expect(output.stderr).toContain('pane target is ambiguous') - } finally { - await close() - } - }) - - it('prints tab-vs-pane guidance for new-window alias', async () => { - const { url, close } = await startTestServer({ - createTab: () => ({ tabId: 'tab_new', paneId: 'pane_new' }), - attachPaneContent: () => {}, - }) - try { - const output = await runCli(url, ['new-window', '--name', 'Alias Test']) - - expect(output.stderr).toContain('new-window maps to new-tab') - expect(output.stderr).toContain('Use split-pane') - expect(output.stdout).toContain('"tabId": "tab_new"') - } finally { - await close() - } - }) - - it('normalizes resize-pane single-axis values to complementary split percentages', async () => { - const resizePaneCalls: Array<{ tabId?: string; splitId: string; sizes: [number, number] }> = [] - const { url, close } = await startTestServer({ - resolveTarget: () => ({ tabId: 'tab_1', paneId: 'pane_1' }), - findSplitForPane: () => ({ tabId: 'tab_1', splitId: 'split_1' }), - getSplitSizes: (_tabId: string | undefined, splitId: string) => ( - splitId === 'split_1' ? [72, 28] as [number, number] : undefined - ), - resizePane: (tabId: string | undefined, splitId: string, sizes: [number, number]) => { - resizePaneCalls.push({ tabId, splitId, sizes }) - return { tabId: tabId || 'tab_1' } - }, - }) - try { - const output = await runCli(url, ['resize-pane', '-t', 'pane_1', '--y', '33']) - const parsed = JSON.parse(output.stdout) as { status: string } - expect(parsed.status).toBe('ok') - expect(resizePaneCalls).toHaveLength(1) - expect(resizePaneCalls[0]).toEqual({ - tabId: 'tab_1', - splitId: 'split_1', - sizes: [67, 33], - }) - } finally { - await close() - } - }) - - it('runs screenshot-view end-to-end with required name', async () => { - const tinyPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6r7gkAAAAASUVORK5CYII=' - const { url, close } = await startTestServer({}, { - wsHandler: { - requestUiScreenshot: async () => ({ - ok: true, - mimeType: 'image/png', - imageBase64: tinyPngBase64, - width: 1, - height: 1, - changedFocus: false, - restoredFocus: false, - }), - }, - }) - - let screenshotPath: string | undefined - try { - const output = await runCli(url, ['screenshot-view', '--name', 'cli-e2e-shot', '--overwrite']) - const parsed = JSON.parse(output.stdout) as { status: string; data: { path: string; scope: string } } - expect(parsed.status).toBe('ok') - expect(parsed.data.scope).toBe('view') - expect(parsed.data.path.endsWith('cli-e2e-shot.png')).toBe(true) - screenshotPath = parsed.data.path - - const stat = await fs.stat(screenshotPath) - expect(stat.isFile()).toBe(true) - expect(stat.size).toBeGreaterThan(0) - } finally { - if (screenshotPath) { - await fs.unlink(screenshotPath).catch(() => undefined) - } - await close() - } - }) - - it('keeps wait-for --prompt blocked across busy polling turns until the codex tracker clears', async () => { - let checks = 0 - const unblockAfterChecks = 3 - - const { url, close } = await startTestServer({}, { - codexActivityTracker: { - isPromptBlocked: () => { - checks += 1 - return checks < unblockAfterChecks - }, - }, - }) - - try { - const output = await runCli(url, ['wait-for', '-t', 'pane_1', '--prompt', '--timeout', '1.2']) - const parsed = JSON.parse(output.stdout) as { status: string; data: { matched: boolean; reason?: string } } - - expect(parsed.status).toBe('ok') - expect(parsed.data).toEqual({ matched: true, reason: 'prompt' }) - expect(checks).toBeGreaterThanOrEqual(unblockAfterChecks) - } finally { - await close() - } - }) - - it('renames the active tab when only a new name is provided', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const first = await runCliJson<{ data: { tabId: string } }>(server.url, ['new-tab', '-n', 'Backlog']) - const second = await runCliJson<{ data: { tabId: string } }>(server.url, ['new-tab', '-n', 'Active']) - - const renamed = await runCli(server.url, ['rename-tab', 'Release prep']) - - expect(renamed.stderr).toContain('active tab used') - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(snapshot.activeTabId).toBe(second.data.tabId) - expect(snapshot.tabs.find((tab: any) => tab.id === second.data.tabId)?.title).toBe('Release prep') - expect(snapshot.tabs.find((tab: any) => tab.id === first.data.tabId)?.title).toBe('Backlog') - }) - } finally { - await server.close() - } - }) - - it('renames a non-active tab when a target id is provided', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const first = await runCliJson<{ data: { tabId: string } }>(server.url, ['new-tab', '-n', 'Backlog']) - const second = await runCliJson<{ data: { tabId: string } }>(server.url, ['new-tab', '-n', 'Active']) - - await runCli(server.url, ['rename-tab', first.data.tabId, 'Release', 'board']) - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(snapshot.activeTabId).toBe(second.data.tabId) - expect(snapshot.tabs.find((tab: any) => tab.id === first.data.tabId)?.title).toBe('Release board') - expect(snapshot.tabs.find((tab: any) => tab.id === second.data.tabId)?.title).toBe('Active') - }) - } finally { - await server.close() - } - }) - - it('renames the tab and panes in a create split rename flow', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const created = await runCliJson<{ data: { tabId: string; paneId: string } }>(server.url, [ - 'new-tab', - '-n', - 'Workspace', - '--codex', - '--cwd', - process.cwd(), - ]) - const tabId = created.data.tabId - const firstPaneId = created.data.paneId - - const split = await runCliJson<{ data: { paneId: string } }>(server.url, [ - 'split-pane', - '-t', - firstPaneId, - '--editor', - '/tmp/example.txt', - ]) - const secondPaneId = split.data.paneId - - await runCli(server.url, ['rename-tab', '-t', tabId, '-n', 'Issue 166 work']) - await runCli(server.url, ['rename-pane', '-t', firstPaneId, '-n', 'Codex']) - await runCli(server.url, ['rename-pane', secondPaneId, 'Editor']) - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(snapshot.tabs.find((tab: any) => tab.id === tabId)?.title).toBe('Issue 166 work') - expect(snapshot.paneTitles[tabId][firstPaneId]).toBe('Codex') - expect(snapshot.paneTitles[tabId][secondPaneId]).toBe('Editor') - }) - } finally { - await server.close() - } - }) - - it('renames the active pane when only a new name is provided', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const created = await runCliJson<{ data: { tabId: string; paneId: string } }>(server.url, [ - 'new-tab', - '-n', - 'Workspace', - '--shell', - 'system', - ]) - - const renamed = await runCli(server.url, ['rename-pane', 'Main shell']) - - expect(renamed.stderr).toContain('active tab used') - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(snapshot.paneTitles[created.data.tabId][created.data.paneId]).toBe('Main shell') - expect(snapshot.tabs.find((tab: any) => tab.id === created.data.tabId)?.title).toBe('Main shell') - }) - } finally { - await server.close() - } - }) - - it('passes canonical Codex session refs through new-tab, split-pane, and respawn-pane', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const created = await runCliJson<{ data: { tabId: string; paneId: string } }>(server.url, [ - 'new-tab', - '--mode', - 'codex', - '--session-ref', - 'codex:thread-cli-new', - ]) - const tabId = created.data.tabId - const firstPaneId = created.data.paneId - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(findPaneContent(snapshot.layouts[tabId], firstPaneId)).toEqual(expect.objectContaining({ - mode: 'codex', - sessionRef: { provider: 'codex', sessionId: 'thread-cli-new' }, - })) - }) - - const split = await runCliJson<{ data: { paneId: string } }>(server.url, [ - 'split-pane', - '-t', - firstPaneId, - '--mode', - 'codex', - '--session-ref=codex:thread-cli-split', - ]) - - await runCliJson<{ data: { terminalId: string } }>(server.url, [ - 'respawn-pane', - '-t', - firstPaneId, - '--mode', - 'codex', - '--session-ref', - 'codex:thread-cli-respawn', - ]) - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(findPaneContent(snapshot.layouts[tabId], firstPaneId)).toEqual(expect.objectContaining({ - mode: 'codex', - sessionRef: { provider: 'codex', sessionId: 'thread-cli-respawn' }, - })) - expect(findPaneContent(snapshot.layouts[tabId], split.data.paneId)).toEqual(expect.objectContaining({ - mode: 'codex', - sessionRef: { provider: 'codex', sessionId: 'thread-cli-split' }, - })) - }) - } finally { - await server.close() - } - }) - - it('promotes --resume into a canonical sessionRef for non-codex modes', async () => { - // The CLI is the last legacy-only carrier (the 2026-08-16 duplicate-tab - // incident): it must convert `--resume ` into the canonical - // `sessionRef {provider: mode, sessionId}` before it hits the wire — - // exactly like the MCP freshell tool — instead of sending the legacy - // `resumeSessionId` field. - const server = await startTestServerWithRealLayoutStore() - try { - const created = await runCliJson<{ data: { tabId: string; paneId: string } }>(server.url, [ - 'new-tab', - '--mode', - 'claude', - '--resume', - '11111111-1111-4111-8111-111111111111', - ]) - const tabId = created.data.tabId - const firstPaneId = created.data.paneId - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(findPaneContent(snapshot.layouts[tabId], firstPaneId)).toEqual(expect.objectContaining({ - mode: 'claude', - sessionRef: { provider: 'claude', sessionId: '11111111-1111-4111-8111-111111111111' }, - })) - }) - - const split = await runCliJson<{ data: { paneId: string } }>(server.url, [ - 'split-pane', - '-t', - firstPaneId, - '--mode', - 'claude', - '--resume', - '22222222-2222-4222-8222-222222222222', - ]) - - await runCliJson<{ data: { terminalId: string } }>(server.url, [ - 'respawn-pane', - '-t', - firstPaneId, - '--mode', - 'claude', - '--resume', - '33333333-3333-4333-8333-333333333333', - ]) - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(findPaneContent(snapshot.layouts[tabId], firstPaneId)).toEqual(expect.objectContaining({ - mode: 'claude', - sessionRef: { provider: 'claude', sessionId: '33333333-3333-4333-8333-333333333333' }, - })) - expect(findPaneContent(snapshot.layouts[tabId], split.data.paneId)).toEqual(expect.objectContaining({ - mode: 'claude', - sessionRef: { provider: 'claude', sessionId: '22222222-2222-4222-8222-222222222222' }, - })) - }) - } finally { - await server.close() - } - }) - - it('rejects raw Codex resume ids in new-tab, split-pane, and respawn-pane', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const created = await runCliJson<{ data: { paneId: string } }>(server.url, [ - 'new-tab', - '--mode', - 'codex', - ]) - - const commands = [ - ['new-tab', '--mode', 'codex', '--resume', 'thread-raw-new'], - ['split-pane', '-t', created.data.paneId, '--mode', 'codex', '--resume', 'thread-raw-split'], - ['respawn-pane', '-t', created.data.paneId, '--mode', 'codex', '--resume', 'thread-raw-respawn'], - ] - - for (const args of commands) { - const output = await runCliResult(server.url, args) - expect(output.code).toBe(1) - expect(output.stderr).toContain('Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.') - } - } finally { - await server.close() - } - }) - - it('lists and resolves derived pane titles without an explicit rename', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const created = await runCliJson<{ data: { tabId: string; paneId: string } }>(server.url, [ - 'new-tab', - '-n', - 'Workspace', - '--codex', - '--cwd', - process.cwd(), - ]) - const tabId = created.data.tabId - const firstPaneId = created.data.paneId - - const split = await runCliJson<{ data: { paneId: string } }>(server.url, [ - 'split-pane', - '-t', - firstPaneId, - '--editor', - '/tmp/example.txt', - ]) - - const listed = await runCliJson<{ data: { panes: Array<{ id: string; title?: string }> } }>(server.url, [ - 'list-panes', - '--json', - ]) - expect(listed.data.panes).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: firstPaneId, title: 'Codex CLI' }), - expect.objectContaining({ id: split.data.paneId, title: 'example.txt' }), - ])) - - const listedText = await runCli(server.url, ['list-panes']) - const listedRows = listedText.stdout.split('\n').filter(Boolean).map((line) => line.split('\t')) - expect(listedRows).toEqual(expect.arrayContaining([ - [firstPaneId, '0', 'terminal', 'term_1'], - [split.data.paneId, '1', 'editor', ''], - ])) - expect(listedRows.every((row) => row.length === 4)).toBe(true) - - const listedWithTitles = await runCli(server.url, ['list-panes', '--titles']) - expect(listedWithTitles.stdout).toContain('Codex CLI') - expect(listedWithTitles.stdout).toContain('example.txt') - - await runCli(server.url, ['select-pane', '-t', 'example.txt']) - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(snapshot.activePane[tabId]).toBe(split.data.paneId) - }) - } finally { - await server.close() - } - }) - - it('keeps title-based pane targeting aligned after swap-pane', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const created = await runCliJson<{ data: { tabId: string; paneId: string } }>(server.url, [ - 'new-tab', - '-n', - 'Workspace', - '--codex', - '--cwd', - process.cwd(), - ]) - const tabId = created.data.tabId - const firstPaneId = created.data.paneId - - const split = await runCliJson<{ data: { paneId: string } }>(server.url, [ - 'split-pane', - '-t', - firstPaneId, - '--editor', - '/tmp/example.txt', - ]) - const secondPaneId = split.data.paneId - - await runCli(server.url, ['rename-pane', '-t', firstPaneId, '-n', 'Codex']) - await runCli(server.url, ['rename-pane', '-t', secondPaneId, '-n', 'Editor']) - await runCli(server.url, ['swap-pane', '-t', firstPaneId, '-s', secondPaneId]) - - const listed = await runCliJson<{ data: { panes: Array<{ id: string; title?: string }> } }>(server.url, [ - 'list-panes', - '--json', - ]) - expect(listed.data.panes).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: firstPaneId, title: 'Editor' }), - expect.objectContaining({ id: secondPaneId, title: 'Codex' }), - ])) - - await runCli(server.url, ['select-pane', '-t', 'Editor']) - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(snapshot.activePane[tabId]).toBe(firstPaneId) - }) - } finally { - await server.close() - } - }) - - it('lists pane titles publicly and resolves pane targets by title', async () => { - const server = await startTestServerWithRealLayoutStore() - try { - const created = await runCliJson<{ data: { tabId: string; paneId: string } }>(server.url, [ - 'new-tab', - '-n', - 'Workspace', - '--codex', - '--cwd', - process.cwd(), - ]) - const tabId = created.data.tabId - const firstPaneId = created.data.paneId - - const split = await runCliJson<{ data: { paneId: string } }>(server.url, [ - 'split-pane', - '-t', - firstPaneId, - '--editor', - '/tmp/example.txt', - ]) - const secondPaneId = split.data.paneId - - await runCli(server.url, ['rename-pane', '-t', secondPaneId, '-n', 'Editor notes']) - - const listed = await runCliJson<{ data: { panes: Array<{ id: string; title?: string }> } }>(server.url, [ - 'list-panes', - '--json', - ]) - expect(listed.data.panes).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: secondPaneId, title: 'Editor notes' }), - ])) - - const listedText = await runCli(server.url, ['list-panes']) - const listedRows = listedText.stdout.split('\n').filter(Boolean).map((line) => line.split('\t')) - expect(listedRows).toEqual(expect.arrayContaining([ - [firstPaneId, '0', 'terminal', 'term_1'], - [secondPaneId, '1', 'editor', ''], - ])) - expect(listedRows.every((row) => row.length === 4)).toBe(true) - - const listedWithTitles = await runCli(server.url, ['list-panes', '--titles']) - expect(listedWithTitles.stdout).toContain('Editor notes') - - await runCli(server.url, ['select-pane', '-t', 'Editor notes']) - - await waitForExpect(() => { - const snapshot = (server.layoutStore as any).snapshot - expect(snapshot.activePane[tabId]).toBe(secondPaneId) - }) - } finally { - await server.close() - } - }) -}) diff --git a/test/e2e/agent-cli-screenshot-smoke.test.ts b/test/e2e/agent-cli-screenshot-smoke.test.ts deleted file mode 100644 index 851af8edb..000000000 --- a/test/e2e/agent-cli-screenshot-smoke.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { spawn } from 'child_process' -import path from 'path' -import os from 'os' -import fs from 'node:fs/promises' -import express from 'express' -import http from 'http' -import { createRequire } from 'module' -import { LayoutStore } from '../../server/agent-api/layout-store' -import { createAgentApiRouter } from '../../server/agent-api/router' - -type CliRun = { - code: number | null - stdout: string - stderr: string -} - -type TerminalRecord = { - terminalId: string - status: 'running' | 'exited' - exitCode?: number - buffer: { snapshot: () => string } - _bufferText: string - _pendingInput: string -} - -const tinyPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6r7gkAAAAASUVORK5CYII=' - -function createFakeRegistry() { - let seq = 0 - const records = new Map() - - const create = vi.fn((_opts?: unknown) => { - const terminalId = `term_${++seq}` - const record: TerminalRecord = { - terminalId, - status: 'running', - buffer: { snapshot: () => `${record._bufferText}${record._pendingInput}` }, - _bufferText: '$ ', - _pendingInput: '', - } - records.set(terminalId, record) - return { terminalId } - }) - - const input = vi.fn((terminalId: string, data: unknown) => { - const record = records.get(terminalId) - if (!record || record.status !== 'running') return { status: 'not_running' } - - const text = String(data ?? '') - for (const ch of text) { - if (ch === '\r' || ch === '\n') { - const line = record._pendingInput - record._bufferText += line + '\n' - if (line.startsWith('echo ')) { - record._bufferText += line.slice(5) + '\n' - } - record._pendingInput = '' - record._bufferText += '$ ' - continue - } - record._pendingInput += ch - } - return { status: 'written' } - }) - - const get = (terminalId: string) => records.get(terminalId) - const list = () => [...records.values()] - - return { create, input, get, list } -} - -function findPaneContent(node: any, paneId: string): any | undefined { - if (!node) return undefined - if (node.type === 'leaf') { - return node.id === paneId ? node.content : undefined - } - return findPaneContent(node.children?.[0], paneId) ?? findPaneContent(node.children?.[1], paneId) -} - -function parseJsonOutput(text: string): T { - return JSON.parse(text.trim()) as T -} - -function resolveCliPaths() { - const require = createRequire(import.meta.url) - const tsxRoot = path.dirname(require.resolve('tsx/package.json')) - const tsxPath = path.join(tsxRoot, 'dist', 'cli.mjs') - const cliPath = path.resolve(__dirname, '../../server/cli/index.ts') - return { tsxPath, cliPath } -} - -async function runCli(url: string, args: string[]): Promise { - const { tsxPath, cliPath } = resolveCliPaths() - const proc = spawn(process.execPath, [tsxPath, cliPath, ...args], { - env: { ...process.env, FRESHELL_URL: url, FRESHELL_TOKEN: 'test-token' }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - - return await new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - proc.stdout.on('data', (chunk) => { stdout += chunk.toString() }) - proc.stderr.on('data', (chunk) => { stderr += chunk.toString() }) - proc.on('error', reject) - proc.on('close', (code) => resolve({ code, stdout, stderr })) - }) -} - -async function runCliJson(url: string, args: string[]): Promise { - const run = await runCli(url, args) - if (run.code !== 0) { - throw new Error(`cli ${args.join(' ')} exited ${run.code}: ${run.stderr}`) - } - return parseJsonOutput(run.stdout) -} - -async function startTestServer() { - const app = express() - app.use(express.json()) - - const layoutStore = new LayoutStore() - const registry = createFakeRegistry() - const requestUiScreenshot = vi.fn(async (_payload: unknown) => ({ - ok: true, - mimeType: 'image/png', - imageBase64: tinyPngBase64, - width: 1, - height: 1, - changedFocus: false, - restoredFocus: false, - })) - const wsHandler = { - broadcastUiCommand: vi.fn(), - requestUiScreenshot, - } - - app.use('/api', createAgentApiRouter({ layoutStore, registry, wsHandler })) - - const server = http.createServer(app) - await new Promise((resolve) => { - server.listen(0, () => resolve()) - }) - const { port } = server.address() as { port: number } - - return { - url: `http://localhost:${port}`, - layoutStore, - wsHandler, - close: () => new Promise((resolve) => server.close(() => resolve())), - } -} - -describe('agent cli screenshot smoke', () => { - it('covers terminal/editor/browser panes with pane+tab+view screenshots in one flow', async () => { - const server = await startTestServer() - const smokeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-agent-cli-smoke-')) - const editorPath = path.join(smokeDir, 'editor-canary.txt') - const editorCanary = `EDITOR_CANARY_${Date.now()}` - const termCanary = `TERM_CANARY_${Date.now()}` - const browserCanary = `bsmk${Date.now()}` - const browserUrl = `https://${browserCanary}.example.com/` - const createdPaths = new Set() - - try { - await fs.writeFile(editorPath, `${editorCanary}\nline 2\n`) - createdPaths.add(editorPath) - - const createdTab = await runCliJson<{ status: string; data: { tabId: string; paneId: string } }>(server.url, [ - 'new-tab', - '-n', - 'Smoke Pane Types', - ]) - expect(createdTab.status).toBe('ok') - - const tabId = createdTab.data.tabId - const terminalPaneId = createdTab.data.paneId - - const editorSplit = await runCliJson<{ status: string; data: { paneId: string } }>(server.url, [ - 'split-pane', - '-t', - terminalPaneId, - '--editor', - editorPath, - ]) - expect(editorSplit.status).toBe('ok') - const editorPaneId = editorSplit.data.paneId - server.layoutStore.attachPaneContent(tabId, editorPaneId, { - kind: 'editor', - filePath: editorPath, - language: 'plaintext', - readOnly: false, - content: `${editorCanary}\nline 2\n`, - viewMode: 'source', - }) - - const browserSplit = await runCliJson<{ status: string; data: { paneId: string } }>(server.url, [ - 'split-pane', - '-t', - terminalPaneId, - '--browser', - browserUrl, - ]) - expect(browserSplit.status).toBe('ok') - const browserPaneId = browserSplit.data.paneId - - const panes = await runCliJson<{ status: string; data: { panes: Array<{ kind: string }> } }>(server.url, [ - 'list-panes', - '-t', - tabId, - '--json', - ]) - expect(panes.data.panes.map((pane) => pane.kind).sort()).toEqual(['browser', 'editor', 'terminal']) - - const literalSend = await runCliJson<{ status: string }>(server.url, [ - 'send-keys', - '-t', - terminalPaneId, - '-l', - `echo ${termCanary}`, - ]) - expect(literalSend.status).toBe('ok') - - const enterSend = await runCliJson<{ status: string }>(server.url, [ - 'send-keys', - terminalPaneId, - 'ENTER', - ]) - expect(enterSend.status).toBe('ok') - const captured = await runCli(server.url, ['capture-pane', '-t', terminalPaneId, '-S', '-20']) - expect(captured.code).toBe(0) - expect(captured.stdout).toContain(termCanary) - const capturedEditor = await runCli(server.url, ['capture-pane', '-t', editorPaneId, '-S', '-20']) - expect(capturedEditor.code).toBe(0) - expect(capturedEditor.stdout).toContain(editorCanary) - - const layoutSnapshot = (server.layoutStore as any).snapshot - const root = layoutSnapshot.layouts[tabId] - const editorContent = findPaneContent(root, editorPaneId) - const browserContent = findPaneContent(root, browserPaneId) - expect(editorContent?.kind).toBe('editor') - expect(editorContent?.filePath).toBe(editorPath) - expect(browserContent?.kind).toBe('browser') - expect(browserContent?.url).toBe(browserUrl) - - const shotArgs = ['--path', smokeDir, '--overwrite'] as const - const paneTerminal = await runCliJson<{ status: string; data: { path: string; scope: string } }>(server.url, [ - 'screenshot-pane', - '-t', - terminalPaneId, - '--name', - 'smoke-pane-terminal', - ...shotArgs, - ]) - const paneEditor = await runCliJson<{ status: string; data: { path: string; scope: string } }>(server.url, [ - 'screenshot-pane', - '-t', - editorPaneId, - '--name', - 'smoke-pane-editor', - ...shotArgs, - ]) - const paneBrowser = await runCliJson<{ status: string; data: { path: string; scope: string } }>(server.url, [ - 'screenshot-pane', - '-t', - browserPaneId, - '--name', - 'smoke-pane-browser', - ...shotArgs, - ]) - const tabShot = await runCliJson<{ status: string; data: { path: string; scope: string } }>(server.url, [ - 'screenshot-tab', - '-t', - tabId, - '--name', - 'smoke-tab-all', - ...shotArgs, - ]) - const viewShot = await runCliJson<{ status: string; data: { path: string; scope: string } }>(server.url, [ - 'screenshot-view', - '--name', - 'smoke-view-all', - ...shotArgs, - ]) - - const allShots = [paneTerminal, paneEditor, paneBrowser, tabShot, viewShot] - for (const shot of allShots) { - createdPaths.add(shot.data.path) - await expect(fs.stat(shot.data.path)).resolves.toMatchObject({ isFile: expect.any(Function) }) - const stat = await fs.stat(shot.data.path) - expect(stat.size).toBeGreaterThan(0) - } - expect(paneTerminal.data.scope).toBe('pane') - expect(paneEditor.data.scope).toBe('pane') - expect(paneBrowser.data.scope).toBe('pane') - expect(tabShot.data.scope).toBe('tab') - expect(viewShot.data.scope).toBe('view') - - const screenshotPayloads = server.wsHandler.requestUiScreenshot.mock.calls.map(([payload]: [any]) => payload) - expect(screenshotPayloads).toHaveLength(5) - expect(screenshotPayloads[0]).toMatchObject({ scope: 'pane', tabId, paneId: terminalPaneId }) - expect(screenshotPayloads[1]).toMatchObject({ scope: 'pane', tabId, paneId: editorPaneId }) - expect(screenshotPayloads[2]).toMatchObject({ scope: 'pane', tabId, paneId: browserPaneId }) - expect(screenshotPayloads[3]).toMatchObject({ scope: 'tab', tabId }) - expect(screenshotPayloads[4]).toMatchObject({ scope: 'view' }) - } finally { - for (const filePath of createdPaths) { - await fs.rm(filePath, { force: true }).catch(() => undefined) - } - await fs.rm(smokeDir, { recursive: true, force: true }).catch(() => undefined) - await server.close() - } - }, 120_000) -}) diff --git a/test/fixtures/tools/rust-action-capability-matrix.json b/test/fixtures/tools/rust-action-capability-matrix.json new file mode 100644 index 000000000..9efaefc4e --- /dev/null +++ b/test/fixtures/tools/rust-action-capability-matrix.json @@ -0,0 +1,6 @@ +{ + "canonicalActions": 33, + "aliases": 14, + "unsupported": ["run", "fresh-send", "attach"], + "acceptedNoOpCaptureParams": ["J", "e"] +} diff --git a/test/unit/cli/args.test.ts b/test/unit/cli/args.test.ts index f6793513d..eee77201c 100644 --- a/test/unit/cli/args.test.ts +++ b/test/unit/cli/args.test.ts @@ -1,5 +1,5 @@ import { it, expect } from 'vitest' -import { parseArgs } from '../../../server/cli/args' +import { parseArgs } from '../../../tools/freshell-cli/args' it('parses subcommand and options', () => { const parsed = parseArgs(['send-keys', '-t', 'alpha.0', 'C-c']) diff --git a/test/unit/cli/commands.test.ts b/test/unit/cli/commands.test.ts index c107b0b76..f4d6747f0 100644 --- a/test/unit/cli/commands.test.ts +++ b/test/unit/cli/commands.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi } from 'vitest' -import { runCommand } from '../../../server/cli/commands/sendKeys' +import { runCommand } from '../../../tools/freshell-cli/commands/sendKeys' import { runListSessionsCommand, runSearchSessionsCommand, -} from '../../../server/cli/index.js' +} from '../../../tools/freshell-cli/index.js' import { createCliCommandHarness } from '../../helpers/visible-first/cli-command-harness.js' describe('CLI commands', () => { diff --git a/test/unit/cli/config.test.ts b/test/unit/cli/config.test.ts index f0846d423..8d1f48943 100644 --- a/test/unit/cli/config.test.ts +++ b/test/unit/cli/config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { resolveConfig } from '../../../server/cli/config' +import { resolveClientConfig as resolveConfig } from '../../../tools/node-client-runtime/config' describe('resolveConfig', () => { it('prefers env vars', () => { diff --git a/test/unit/cli/http.test.ts b/test/unit/cli/http.test.ts index 61befbc62..e70ef9ee8 100644 --- a/test/unit/cli/http.test.ts +++ b/test/unit/cli/http.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createHttpClient } from '../../../server/cli/http' +import { createHttpClient } from '../../../tools/freshell-cli/http' describe('createHttpClient', () => { afterEach(() => { diff --git a/test/unit/cli/keys.test.ts b/test/unit/cli/keys.test.ts index f35ec7acc..2b996f8a4 100644 --- a/test/unit/cli/keys.test.ts +++ b/test/unit/cli/keys.test.ts @@ -1,5 +1,5 @@ import { it, expect } from 'vitest' -import { translateKeys } from '../../../server/cli/keys' +import { translateKeys } from '../../../tools/node-client-runtime/keys' it('translates C-c and Enter', () => { expect(translateKeys(['C-c', 'Enter'])).toBe('\x03\r') diff --git a/test/unit/cli/send-keys-args.test.ts b/test/unit/cli/send-keys-args.test.ts index 950f9ac1d..d59a236aa 100644 --- a/test/unit/cli/send-keys-args.test.ts +++ b/test/unit/cli/send-keys-args.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { partitionSendKeysArgs } from '../../../server/cli/send-keys-args' +import { partitionSendKeysArgs } from '../../../tools/freshell-cli/send-keys-args' describe('partitionSendKeysArgs', () => { it('keeps all key args when target is provided via -t/--target', () => { diff --git a/test/unit/cli/targets.test.ts b/test/unit/cli/targets.test.ts index 0b428ad62..fd2c049fd 100644 --- a/test/unit/cli/targets.test.ts +++ b/test/unit/cli/targets.test.ts @@ -1,5 +1,5 @@ import { it, expect } from 'vitest' -import { resolveTarget } from '../../../server/cli/targets' +import { resolveTarget } from '../../../tools/freshell-cli/targets' it('resolves pane index in active tab', () => { const res = resolveTarget('0', { activeTabId: 't1', panesByTab: { t1: ['p1'] }, tabs: [] }) diff --git a/test/unit/server/mcp/freshell-tool.test.ts b/test/unit/mcp/freshell-tool.test.ts similarity index 97% rename from test/unit/server/mcp/freshell-tool.test.ts rename to test/unit/mcp/freshell-tool.test.ts index dc4ebf108..8a3348c7c 100644 --- a/test/unit/server/mcp/freshell-tool.test.ts +++ b/test/unit/mcp/freshell-tool.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' const mockClient = vi.hoisted(() => ({ @@ -7,12 +8,12 @@ const mockClient = vi.hoisted(() => ({ delete: vi.fn(), })) -vi.mock('../../../../server/mcp/http-client.js', () => ({ +vi.mock('../../../tools/freshell-mcp/http-client.js', () => ({ resolveConfig: () => ({ url: 'http://localhost:3001', token: 'test' }), createApiClient: () => mockClient, })) -import { TOOL_DESCRIPTION, INPUT_SCHEMA, executeAction } from '../../../../server/mcp/freshell-tool.js' +import { TOOL_DESCRIPTION, INPUT_SCHEMA, executeAction } from '../../../tools/freshell-mcp/freshell-tool.js' beforeEach(() => { mockClient.get.mockReset() @@ -233,7 +234,7 @@ describe('executeAction -- tab actions', () => { })) }) - it('new-tab with agent: codex rejects raw resume ids (same guard as mode: codex)', async () => { + it('new-tab with agent: codex is locally rejected by the Rust capability contract', async () => { mockClient.post.mockResolvedValue({ id: 't1' }) const result = await executeAction('new-tab', { @@ -241,14 +242,11 @@ describe('executeAction -- tab actions', () => { resume: 'thread-pre-durable', }) - expect(result).toEqual({ - error: 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.', - hint: 'Use sessionRef: { provider: "codex", sessionId } after Codex identity is durable.', - }) + expect(result).toEqual(expect.objectContaining({ error: "Only agent 'opencode' is supported with the Rust Freshell server." })) expect(mockClient.post).not.toHaveBeenCalled() }) - it.each(['claude', 'kilroy'])('new-tab with agent: %s does not synthesize a sessionRef from resume', async (agent) => { + it.each(['claude', 'kilroy'])('new-tab with agent: %s is locally rejected by the Rust capability contract', async (agent) => { mockClient.post.mockResolvedValue({ id: 't1' }) await executeAction('new-tab', { @@ -256,10 +254,7 @@ describe('executeAction -- tab actions', () => { resume: '550e8400-e29b-41d4-a716-446655440000', }) - expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ agent })) - expect(mockClient.post.mock.calls.at(-1)?.[1]).not.toHaveProperty('sessionRef') - expect(mockClient.post.mock.calls.at(-1)?.[1]).not.toHaveProperty('resume') - expect(mockClient.post.mock.calls.at(-1)?.[1]).not.toHaveProperty('resumeSessionId') + expect(mockClient.post).not.toHaveBeenCalled() }) it('list-tabs calls GET /api/tabs', async () => { @@ -605,13 +600,10 @@ describe('executeAction -- terminal I/O', () => { expect(mockClient.get).toHaveBeenCalledWith(expect.stringMatching(/\/api\/panes\/p1\/wait-for/)) }) - it('run calls POST /api/run with command and options', async () => { + it('run is locally rejected without an HTTP call', async () => { mockClient.post.mockResolvedValue({ output: 'ok', exitCode: 0 }) await executeAction('run', { command: 'npm test', capture: true }) - expect(mockClient.post).toHaveBeenCalledWith( - '/api/run', - expect.objectContaining({ command: 'npm test', capture: true }), - ) + expect(mockClient.post).not.toHaveBeenCalled() }) it('summarize resolves pane to terminalId and calls POST /api/ai/terminals/:terminalId/summary', async () => { @@ -644,13 +636,10 @@ describe('executeAction -- additional terminal I/O', () => { expect(mockClient.get).toHaveBeenCalledWith('/api/terminals') }) - it('attach calls POST /api/panes/:id/attach with terminalId', async () => { + it('attach is locally rejected without an HTTP call', async () => { mockClient.post.mockResolvedValue({ ok: true }) await executeAction('attach', { target: 'p1', terminalId: 'term-1' }) - expect(mockClient.post).toHaveBeenCalledWith( - expect.stringContaining('/api/panes/p1/attach'), - expect.objectContaining({ terminalId: 'term-1' }), - ) + expect(mockClient.post).not.toHaveBeenCalled() }) it('lan-info calls GET /api/lan-info', async () => { diff --git a/test/unit/server/mcp/http-client.test.ts b/test/unit/mcp/http-client.test.ts similarity index 85% rename from test/unit/server/mcp/http-client.test.ts rename to test/unit/mcp/http-client.test.ts index c410de618..3b8a4ff8e 100644 --- a/test/unit/server/mcp/http-client.test.ts +++ b/test/unit/mcp/http-client.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' describe('resolveConfig', () => { @@ -11,7 +12,7 @@ describe('resolveConfig', () => { it('reads FRESHELL_URL and FRESHELL_TOKEN from environment', async () => { process.env.FRESHELL_URL = 'http://myhost:4000' process.env.FRESHELL_TOKEN = 'abc123' - const { resolveConfig } = await import('../../../../server/mcp/http-client.js') + const { resolveConfig } = await import('../../../tools/freshell-mcp/http-client.js') const config = resolveConfig() expect(config).toEqual({ url: 'http://myhost:4000', token: 'abc123' }) }) @@ -19,7 +20,7 @@ describe('resolveConfig', () => { it('defaults to http://localhost:3001 when FRESHELL_URL not set', async () => { delete process.env.FRESHELL_URL delete process.env.FRESHELL_TOKEN - const { resolveConfig } = await import('../../../../server/mcp/http-client.js') + const { resolveConfig } = await import('../../../tools/freshell-mcp/http-client.js') const config = resolveConfig() expect(config.url).toBe('http://localhost:3001') expect(config.token).toBe('') @@ -28,7 +29,7 @@ describe('resolveConfig', () => { it('defaults token to empty string when FRESHELL_TOKEN not set', async () => { process.env.FRESHELL_URL = 'http://host:3001' delete process.env.FRESHELL_TOKEN - const { resolveConfig } = await import('../../../../server/mcp/http-client.js') + const { resolveConfig } = await import('../../../tools/freshell-mcp/http-client.js') const config = resolveConfig() expect(config.token).toBe('') }) @@ -53,7 +54,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: 'mytoken' }) await client.get('/api/health') expect(mockFetch).toHaveBeenCalledOnce() @@ -66,7 +67,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) await client.get('/api/health') const [, init] = mockFetch.mock.calls[0] @@ -78,7 +79,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) const result = await client.get('/api/health') expect(result).toEqual({ ok: true }) @@ -89,7 +90,7 @@ describe('createApiClient', () => { status: 500, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) try { await client.get('/api/health') @@ -105,7 +106,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) await client.post('/api/tabs', { name: 'Test' }) const [, init] = mockFetch.mock.calls[0] @@ -119,7 +120,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) await client.patch('/api/tabs/t1', { name: 'New' }) const [, init] = mockFetch.mock.calls[0] @@ -131,7 +132,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) await client.delete('/api/tabs/t1') const [, init] = mockFetch.mock.calls[0] @@ -143,7 +144,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001/', token: '' }) await client.get('/api/health') const [url] = mockFetch.mock.calls[0] @@ -155,7 +156,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) const result = await client.get('/api/tabs') // data should be unwrapped, but status should be preserved for callers @@ -175,7 +176,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) const result = await client.get('/api/search') expect(result).toHaveProperty('status', 'approximate') @@ -189,7 +190,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) const result = await client.get('/api/health') expect(result).toEqual({ ok: true }) @@ -200,7 +201,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'text/plain' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) const result = await client.get('/api/panes/p1/capture') expect(result).toBe('terminal output') @@ -215,7 +216,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) const result = await client.get('/api/panes/p1/navigate') // Must not be null/undefined -- should return the message or an empty object @@ -230,7 +231,7 @@ describe('createApiClient', () => { status: 200, headers: { 'Content-Type': 'application/json' }, })) - const { createApiClient } = await import('../../../../server/mcp/http-client.js') + const { createApiClient } = await import('../../../tools/freshell-mcp/http-client.js') const client = createApiClient({ url: 'http://localhost:3001', token: '' }) const result = await client.get('/api/some-action') expect(result).not.toBeNull() diff --git a/test/unit/server/mcp/server.test.ts b/test/unit/mcp/server.test.ts similarity index 95% rename from test/unit/server/mcp/server.test.ts rename to test/unit/mcp/server.test.ts index 4329bf817..210757ed0 100644 --- a/test/unit/server/mcp/server.test.ts +++ b/test/unit/mcp/server.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { describe, it, expect, vi, beforeEach } from 'vitest' import { createRequire } from 'module' import { resolve, dirname } from 'path' @@ -27,7 +28,7 @@ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ StdioServerTransport: mockStdioTransport, })) -vi.mock('../../../../server/mcp/freshell-tool.js', () => ({ +vi.mock('../../../tools/freshell-mcp/freshell-tool.js', () => ({ TOOL_DESCRIPTION: 'Test tool description', INSTRUCTIONS: 'Test instructions', INPUT_SCHEMA: { @@ -61,7 +62,7 @@ describe('MCP server initialization', () => { vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ StdioServerTransport: mockStdioTransport, })) - vi.doMock('../../../../server/mcp/freshell-tool.js', () => ({ + vi.doMock('../../../tools/freshell-mcp/freshell-tool.js', () => ({ TOOL_DESCRIPTION: 'Test tool description', INSTRUCTIONS: 'Test instructions', INPUT_SCHEMA: { @@ -70,7 +71,7 @@ describe('MCP server initialization', () => { }, executeAction: mockExecuteAction, })) - return import('../../../../server/mcp/server.js') + return import('../../../tools/freshell-mcp/server.js') } it('creates McpServer with name "freshell"', async () => { @@ -143,8 +144,8 @@ describe('MCP server process-level smoke test', () => { it('spawns real MCP server and responds to JSON-RPC initialize', async () => { const { spawn } = await import('child_process') const __dirname = dirname(fileURLToPath(import.meta.url)) - const repoRoot = resolve(__dirname, '..', '..', '..', '..') - const serverPath = resolve(repoRoot, 'server/mcp/server.ts') + const repoRoot = resolve(__dirname, '..', '..', '..') + const serverPath = resolve(repoRoot, 'tools/freshell-mcp/server.ts') const tsxLoaderPath = resolveTsxLoaderPath() const child = spawn(process.execPath, ['--import', pathToFileURL(tsxLoaderPath).href, serverPath], { @@ -237,8 +238,8 @@ describe('MCP server process-level smoke test', () => { it('MCP server does not write to stdout outside JSON-RPC', async () => { const { spawn } = await import('child_process') const __dirname = dirname(fileURLToPath(import.meta.url)) - const repoRoot = resolve(__dirname, '..', '..', '..', '..') - const serverPath = resolve(repoRoot, 'server/mcp/server.ts') + const repoRoot = resolve(__dirname, '..', '..', '..') + const serverPath = resolve(repoRoot, 'tools/freshell-mcp/server.ts') const tsxLoaderPath = resolveTsxLoaderPath() const child = spawn(process.execPath, ['--import', pathToFileURL(tsxLoaderPath).href, serverPath], { diff --git a/test/unit/server/mcp/config-writer-paths.test.ts b/test/unit/server/mcp/config-writer-paths.test.ts index d5cc13fc3..14e9f9f59 100644 --- a/test/unit/server/mcp/config-writer-paths.test.ts +++ b/test/unit/server/mcp/config-writer-paths.test.ts @@ -62,7 +62,7 @@ describe('config-writer path verification', () => { expect(loaderPath).toContain('tsx') expect(fs.existsSync(loaderPath)).toBe(true) - const serverPath = args.find((a: string) => toPosixPath(a).includes('server/mcp/server.ts')) + const serverPath = args.find((a: string) => toPosixPath(a).includes('tools/freshell-mcp/server.ts')) expect(serverPath).toBeDefined() expect(fs.existsSync(serverPath!)).toBe(true) diff --git a/test/unit/server/mcp/config-writer.test.ts b/test/unit/server/mcp/config-writer.test.ts index 22ae1ecd0..938334b23 100644 --- a/test/unit/server/mcp/config-writer.test.ts +++ b/test/unit/server/mcp/config-writer.test.ts @@ -243,11 +243,11 @@ describe('generateMcpInjection -- per-agent config', () => { it('returns production path when NODE_ENV=production and the built server exists', async () => { process.env.NODE_ENV = 'production' - mockFs.existsSync.mockImplementation((filePath: string) => toPosixPath(filePath).endsWith('dist/server/mcp/server.js')) + mockFs.existsSync.mockImplementation((filePath: string) => toPosixPath(filePath).endsWith('dist/tools/freshell-mcp/server.js')) const { buildMcpServerCommandArgs } = await importModule() const args = buildMcpServerCommandArgs() expect(args).toHaveLength(1) - expect(toPosixPath(args[0])).toMatch(/dist\/server\/mcp\/server\.js$/) + expect(toPosixPath(args[0])).toMatch(/dist\/tools\/freshell-mcp\/server\.js$/) }) it('falls back to the source server when NODE_ENV=production but dist is absent', async () => { @@ -256,8 +256,8 @@ describe('generateMcpInjection -- per-agent config', () => { const { buildMcpServerCommandArgs } = await importModule() const args = buildMcpServerCommandArgs() expect(args).toContain('--import') - expect(args.some((a: string) => toPosixPath(a).includes('server/mcp/server.ts'))).toBe(true) - expect(args.some((a: string) => toPosixPath(a).includes('dist/server/mcp/server.js'))).toBe(false) + expect(args.some((a: string) => toPosixPath(a).includes('tools/freshell-mcp/server.ts'))).toBe(true) + expect(args.some((a: string) => toPosixPath(a).includes('dist/tools/freshell-mcp/server.js'))).toBe(false) }) }) }) @@ -283,7 +283,7 @@ describe('generateMcpInjection -- dev/production detection', () => { it('uses built path when NODE_ENV is production', async () => { process.env.NODE_ENV = 'production' - mockFs.existsSync.mockImplementation((filePath: string) => toPosixPath(filePath).endsWith('dist/server/mcp/server.js')) + mockFs.existsSync.mockImplementation((filePath: string) => toPosixPath(filePath).endsWith('dist/tools/freshell-mcp/server.js')) const { generateMcpInjection } = await importModule() generateMcpInjection('claude', 'term-prod') const writeCall = mockFs.writeFileSync.mock.calls.find( @@ -292,7 +292,7 @@ describe('generateMcpInjection -- dev/production detection', () => { expect(writeCall).toBeDefined() const parsed = JSON.parse(writeCall![1]) const args = parsed.mcpServers.freshell.args as string[] - expect(args.some((a: string) => toPosixPath(a).includes('dist/server/mcp/server.js'))).toBe(true) + expect(args.some((a: string) => toPosixPath(a).includes('dist/tools/freshell-mcp/server.js'))).toBe(true) expect(args).not.toContain('--import') }) @@ -308,7 +308,7 @@ describe('generateMcpInjection -- dev/production detection', () => { const args = parsed.mcpServers.freshell.args as string[] expect(args).toContain('--import') expect(args[args.indexOf('--import') + 1]).toContain('tsx') - expect(args.some((a: string) => toPosixPath(a).includes('server/mcp/server.ts'))).toBe(true) + expect(args.some((a: string) => toPosixPath(a).includes('tools/freshell-mcp/server.ts'))).toBe(true) }) }) diff --git a/server/cli/args.ts b/tools/freshell-cli/args.ts similarity index 100% rename from server/cli/args.ts rename to tools/freshell-cli/args.ts diff --git a/server/cli/commands/sendKeys.ts b/tools/freshell-cli/commands/sendKeys.ts similarity index 84% rename from server/cli/commands/sendKeys.ts rename to tools/freshell-cli/commands/sendKeys.ts index 111cd5a44..7af8974d9 100644 --- a/server/cli/commands/sendKeys.ts +++ b/tools/freshell-cli/commands/sendKeys.ts @@ -1,4 +1,4 @@ -import { translateKeys } from '../keys.js' +import { translateKeys } from '../../node-client-runtime/keys.js' export async function runCommand( opts: { diff --git a/server/cli/http.ts b/tools/freshell-cli/http.ts similarity index 92% rename from server/cli/http.ts rename to tools/freshell-cli/http.ts index 0a7410d54..36f39e854 100644 --- a/server/cli/http.ts +++ b/tools/freshell-cli/http.ts @@ -1,4 +1,4 @@ -import { resolveConfig } from './config.js' +import { resolveClientConfig } from '../node-client-runtime/config.js' export type HttpClient = { get: (path: string) => Promise @@ -29,7 +29,7 @@ async function parseResponse(res: Response) { return text } -export function createHttpClient(config = resolveConfig()) : HttpClient { +export function createHttpClient(config = resolveClientConfig()) : HttpClient { const token = config.token const baseUrl = config.url diff --git a/server/cli/index.ts b/tools/freshell-cli/index.ts similarity index 99% rename from server/cli/index.ts rename to tools/freshell-cli/index.ts index ca5de09d3..fc65f6a0d 100644 --- a/server/cli/index.ts +++ b/tools/freshell-cli/index.ts @@ -3,11 +3,11 @@ import { pathToFileURL } from 'node:url' import { parseArgs } from './args.js' import { createHttpClient } from './http.js' import { writeError, writeJson, writeText } from './output.js' -import { resolveConfig } from './config.js' +import { resolveClientConfig } from '../node-client-runtime/config.js' import { resolveTarget } from './targets.js' import { runCommand as sendKeysCommand } from './commands/sendKeys.js' import { partitionSendKeysArgs } from './send-keys-args.js' -import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' type Flags = Record @@ -148,7 +148,7 @@ function resolveSessionRefFlag(mode: unknown, raw: unknown): { rejected: boolean // `--resume ` is promoted to the canonical `sessionRef {provider: mode, // sessionId}` before it hits the wire — exactly like the MCP freshell tool -// (server/mcp/freshell-tool.ts new-tab) — so the CLI never sends the legacy +// (the MCP freshell tool's new-tab action) — so the CLI never sends the legacy // `resumeSessionId` field. An explicit `--session-ref` wins; codex is // rejected earlier by rejectRawCodexResume. Without a mode there is no // provider to promote with, so the flag combination is refused loudly rather @@ -311,7 +311,7 @@ export async function runSearchSessionsCommand( } async function handleDisplay(format: string, target: string | undefined, client: ReturnType) { - const config = resolveConfig() + const config = resolveClientConfig() const resolved = await resolvePaneTarget(client, target) const tab = resolved.tab const pane = resolved.pane diff --git a/server/cli/output.ts b/tools/freshell-cli/output.ts similarity index 100% rename from server/cli/output.ts rename to tools/freshell-cli/output.ts diff --git a/server/cli/send-keys-args.ts b/tools/freshell-cli/send-keys-args.ts similarity index 100% rename from server/cli/send-keys-args.ts rename to tools/freshell-cli/send-keys-args.ts diff --git a/server/cli/targets.ts b/tools/freshell-cli/targets.ts similarity index 100% rename from server/cli/targets.ts rename to tools/freshell-cli/targets.ts diff --git a/server/mcp/freshell-tool.ts b/tools/freshell-mcp/freshell-tool.ts similarity index 97% rename from server/mcp/freshell-tool.ts rename to tools/freshell-mcp/freshell-tool.ts index a88b20913..44ed62570 100644 --- a/server/mcp/freshell-tool.ts +++ b/tools/freshell-mcp/freshell-tool.ts @@ -7,8 +7,9 @@ import { z } from 'zod' import { createApiClient, resolveConfig, type ApiClient } from './http-client.js' -import { translateKeys } from '../cli/keys.js' -import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js' +import { translateKeys } from '../node-client-runtime/keys.js' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' +import { unsupportedActionResult } from '../node-client-runtime/action-capabilities.js' // Lazy-initialized client -- created on first use so env vars are read at call time. let _client: ApiClient | undefined @@ -256,7 +257,7 @@ async function resolvePaneTarget(target?: string): Promise<{ tab?: TabSummary; p const panes = await fetchPanes(tab.id) const paneById = panes.find((p) => p.id === target) if (paneById) return { tab, pane: paneById } - // Collect all title matches to detect ambiguity (matches CLI: server/cli/targets.ts:68) + // Collect all title matches to detect ambiguity (matches CLI target resolution). for (const pane of panes) { if (pane.title === target) { titleMatches.push({ tab, pane }) @@ -622,6 +623,17 @@ export async function executeAction( params?: Record, ): Promise { try { + const unsupported = unsupportedActionResult(action) + if (unsupported) return unsupported + if (action === 'new-tab' && params?.agent !== undefined && params.agent !== 'opencode') { + return { error: "Only agent 'opencode' is supported with the Rust Freshell server.", hint: 'Use mode for direct Claude or Codex terminals.' } + } + if (action === 'split-pane' && ['agent', 'model', 'effort'].some((key) => params?.[key] !== undefined)) { + return { error: 'Fresh-agent split parameters are unavailable with the Rust Freshell server.', hint: 'Use a supported mode pane instead.' } + } + if (action === 'wait-for' && (!params?.pattern || ['stable', 'exit', 'prompt'].some((key) => params?.[key] !== undefined))) { + return { error: 'wait-for requires pattern with the Rust Freshell server.', hint: 'Use a literal output pattern.' } + } const paramError = validateParams(action, params) if (paramError) return paramError return await routeAction(action, params) @@ -679,7 +691,7 @@ async function routeAction( ...(sessionRef ? { sessionRef } : {}), ...rest, }) - // Send prompt text to the newly created pane (mirrors CLI behavior: server/cli/index.ts:318) + // Send prompt text to the newly created pane (mirrors CLI behavior). if (prompt) { const data = unwrapData(tabResult) const paneId = data?.paneId @@ -944,7 +956,7 @@ async function routeAction( return HELP_TEXT default: { - // tmux alias resolution (mirrors CLI: server/cli/index.ts aliases) + // tmux alias resolution (mirrors CLI aliases). const TMUX_ALIASES: Record = { 'new-window': 'new-tab', 'new-session': 'new-tab', diff --git a/server/mcp/http-client.ts b/tools/freshell-mcp/http-client.ts similarity index 100% rename from server/mcp/http-client.ts rename to tools/freshell-mcp/http-client.ts diff --git a/server/mcp/server.ts b/tools/freshell-mcp/server.ts similarity index 94% rename from server/mcp/server.ts rename to tools/freshell-mcp/server.ts index b1e377d8c..99e052796 100644 --- a/server/mcp/server.ts +++ b/tools/freshell-mcp/server.ts @@ -17,7 +17,7 @@ import { TOOL_DESCRIPTION, INSTRUCTIONS, INPUT_SCHEMA, executeAction } from './f /** * Walk up from __dirname to find the repo root's package.json. - * Works in both dev (server/mcp/server.ts) and prod (dist/server/mcp/server.js). + * Works in both dev (tools/freshell-mcp/server.ts) and prod (dist/tools/freshell-mcp/server.js). */ function findPackageVersion(): string { let dir = dirname(fileURLToPath(import.meta.url)) diff --git a/tools/node-client-runtime/action-capabilities.ts b/tools/node-client-runtime/action-capabilities.ts new file mode 100644 index 000000000..7c67a731f --- /dev/null +++ b/tools/node-client-runtime/action-capabilities.ts @@ -0,0 +1,49 @@ +export type ActionCapability = { + action: string + aliases?: readonly string[] + supported: boolean + unsupportedHint?: string +} + +const unavailable = 'This action is unavailable with the Rust Freshell server.' + +/** The closed Rust client contract: 33 canonical actions and 14 tmux aliases. */ +export const ACTION_CAPABILITIES: readonly ActionCapability[] = [ + { action: 'new-tab', aliases: ['new-window', 'new-session'], supported: true }, + { action: 'list-tabs', aliases: ['list-windows'], supported: true }, + { action: 'select-tab', aliases: ['select-window'], supported: true }, + { action: 'kill-tab', aliases: ['kill-window'], supported: true }, + { action: 'rename-tab', aliases: ['rename-window'], supported: true }, + { action: 'next-tab', aliases: ['next-window'], supported: true }, + { action: 'prev-tab', aliases: ['previous-window', 'prev-window'], supported: true }, + { action: 'split-pane', aliases: ['split-window'], supported: true }, + { action: 'display', aliases: ['display-message'], supported: true }, + { action: 'screenshot', aliases: ['screenshot-pane', 'screenshot-tab', 'screenshot-view'], supported: true }, + { action: 'has-tab', supported: true }, { action: 'list-panes', supported: true }, + { action: 'select-pane', supported: true }, { action: 'rename-pane', supported: true }, + { action: 'kill-pane', supported: true }, { action: 'resize-pane', supported: true }, + { action: 'swap-pane', supported: true }, { action: 'respawn-pane', supported: true }, + { action: 'send-keys', supported: true }, { action: 'capture-pane', supported: true }, + { action: 'wait-for', supported: true }, { action: 'summarize', supported: true }, + { action: 'list-terminals', supported: true }, { action: 'open-browser', supported: true }, + { action: 'navigate', supported: true }, { action: 'list-sessions', supported: true }, + { action: 'search-sessions', supported: true }, { action: 'lan-info', supported: true }, + { action: 'health', supported: true }, { action: 'help', supported: true }, + { action: 'run', supported: false, unsupportedHint: unavailable }, + { action: 'fresh-send', supported: false, unsupportedHint: unavailable }, + { action: 'attach', supported: false, unsupportedHint: unavailable }, +] + +const byAction = new Map(ACTION_CAPABILITIES.map((capability) => [capability.action, capability])) +const byAlias = new Map(ACTION_CAPABILITIES.flatMap((capability) => + (capability.aliases ?? []).map((alias) => [alias, capability] as const))) + +export function resolveActionCapability(action: string): ActionCapability | undefined { + return byAction.get(action) ?? byAlias.get(action) +} + +export function unsupportedActionResult(action: string): { error: string; hint: string } | undefined { + const capability = resolveActionCapability(action) + if (capability?.supported !== false) return undefined + return { error: `Action '${action}' is unavailable with the Rust Freshell server.`, hint: capability.unsupportedHint ?? unavailable } +} diff --git a/tools/node-client-runtime/codex-restore-contract.ts b/tools/node-client-runtime/codex-restore-contract.ts new file mode 100644 index 000000000..d73b0a79c --- /dev/null +++ b/tools/node-client-runtime/codex-restore-contract.ts @@ -0,0 +1,3 @@ +/** Shared client-facing contract; the backend keeps its richer planning types. */ +export const INVALID_RAW_CODEX_RESUME_MESSAGE = + 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.' diff --git a/tools/node-client-runtime/config.ts b/tools/node-client-runtime/config.ts new file mode 100644 index 000000000..f25c53815 --- /dev/null +++ b/tools/node-client-runtime/config.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export type ClientConfig = { url: string; token?: string } +type ClientConfigFile = { url?: string; token?: string } + +function loadConfigFile(): ClientConfigFile { + const home = process.env.FRESHELL_HOME || path.join(os.homedir(), '.freshell') + const file = path.join(home, 'cli.json') + if (!fs.existsSync(file)) return {} + try { + const raw = JSON.parse(fs.readFileSync(file, 'utf-8')) as ClientConfigFile + return { url: raw.url, token: raw.token } + } catch { + return {} + } +} + +/** Resolve the common standalone-client endpoint without starting a server. */ +export function resolveClientConfig(): ClientConfig { + const file = loadConfigFile() + return { + url: process.env.FRESHELL_URL || file.url || 'http://localhost:3001', + token: process.env.FRESHELL_TOKEN || file.token, + } +} diff --git a/tools/node-client-runtime/keys.ts b/tools/node-client-runtime/keys.ts new file mode 100644 index 000000000..8da1e1916 --- /dev/null +++ b/tools/node-client-runtime/keys.ts @@ -0,0 +1,12 @@ +const KEYMAP: Record = { + ENTER: '\r', 'C-C': '\x03', 'C-D': '\x04', ESCAPE: '\x1b', TAB: '\t', BSPACE: '\x7f', + UP: '\x1b[A', DOWN: '\x1b[B', LEFT: '\x1b[D', RIGHT: '\x1b[C', SPACE: ' ', +} + +export function translateKeys(keys: string[]): string { + return keys.map((key) => { + const upper = key.toUpperCase() + const chord = /^C-([A-Z])$/.exec(upper) + return KEYMAP[upper] ?? (chord ? String.fromCharCode(chord[1].charCodeAt(0) - 64) : key) + }).join('') +} diff --git a/tsconfig.tools.json b/tsconfig.tools.json new file mode 100644 index 000000000..0355cd4ea --- /dev/null +++ b/tsconfig.tools.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "tools", + "outDir": "dist/tools", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": ["tools/**/*.ts"], + "exclude": ["tools/**/*.test.ts"] +} From c11c6afa3ff3d6c9860ac9401ec3d717339d3b17 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:51:11 -0700 Subject: [PATCH 10/64] test: cover standalone CLI against Rust --- .../src/cli_launch_goldens.rs | 4 +- test/e2e-browser/playwright.config.ts | 9 + test/e2e-browser/specs/cli-rust.spec.ts | 196 ++++++++++++++++++ test/unit/cli/commands.test.ts | 51 +++++ test/unit/mcp/freshell-tool.test.ts | 6 +- tools/freshell-cli/index.ts | 46 +++- 6 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 test/e2e-browser/specs/cli-rust.spec.ts diff --git a/crates/freshell-platform/src/cli_launch_goldens.rs b/crates/freshell-platform/src/cli_launch_goldens.rs index 109c42b37..ef7d9d056 100644 --- a/crates/freshell-platform/src/cli_launch_goldens.rs +++ b/crates/freshell-platform/src/cli_launch_goldens.rs @@ -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/test/e2e-browser/playwright.config.ts b/test/e2e-browser/playwright.config.ts index b45c3acb6..12d43f829 100644 --- a/test/e2e-browser/playwright.config.ts +++ b/test/e2e-browser/playwright.config.ts @@ -174,6 +174,11 @@ export const MATRIX_SPECS = [ // Exported (no behavior change) so test/e2e-browser/playwright.gate01.config.ts // (GATE-01) can testIgnore the SAME array instead of drifting a copy. export const RUST_ONLY_SPECS = [ + // Task 2 retirement coverage: drives the compiled standalone CLI against + // an owned Rust server and asserts its Rust-only unsupported-action wall. + // Keep it out of match-all legacy projects until the temporary dual-project + // layout is collapsed in Task 4. + /cli-rust\.spec\.ts$/, /continuity-smoke\.spec\.ts$/, /deploy-tab-diff-rust\.spec\.ts$/, // COMPOUND-RESTART: drives RustServer.restartAbrupt() (SIGKILL + reboot), @@ -381,6 +386,10 @@ export default defineConfig({ // against an owned, ephemeral Rust server. Rust-only (no legacy // equivalent needed -- see the spec's own doc comment in that file). /mcp-bridge-rust\.spec\.ts$/, + // Task 2 retirement proof: compiled standalone CLI against an owned + // Rust backend. It is also listed in RUST_ONLY_SPECS so match-all + // legacy projects can never collect it. + /cli-rust\.spec\.ts$/, // MCP QA smoke (the QA-lever payoff): full mode-matrix coverage // (shell/amplifier/opencode/codex/browser/editor/pane-ops) driven // through the same unmodified legacy MCP stdio binary. See diff --git a/test/e2e-browser/specs/cli-rust.spec.ts b/test/e2e-browser/specs/cli-rust.spec.ts new file mode 100644 index 000000000..7c2c5dca8 --- /dev/null +++ b/test/e2e-browser/specs/cli-rust.spec.ts @@ -0,0 +1,196 @@ +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from '@playwright/test' +import { ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp-stdio-client.js' +import { RustServer } from '../helpers/rust-server.js' + +type CliRun = { code: number | null; stdout: string; stderr: string } +type ActionResult = { status: string; data: T } + +const CLI_BIN = path.join(REPO_ROOT, 'dist', 'tools', 'freshell-cli', 'index.js') +const SESSION_MARKER = 'cli-rust-paged-session' + +function buildClaudeSession(sessionId: string, title: string): string { + const cwd = `/tmp/${SESSION_MARKER}` + const messages = [ + { type: 'system', subtype: 'init', session_id: sessionId, uuid: `${sessionId}-system`, cwd, timestamp: '2026-08-27T10:00:00.000Z' }, + { sessionId, type: 'user', message: { role: 'user', content: `${title} request one` }, uuid: `${sessionId}-u1`, cwd, timestamp: '2026-08-27T10:00:01.000Z' }, + { sessionId, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: `${title} response one` }] }, uuid: `${sessionId}-a1`, cwd, timestamp: '2026-08-27T10:00:02.000Z' }, + { sessionId, type: 'user', message: { role: 'user', content: `${title} request two` }, uuid: `${sessionId}-u2`, cwd, timestamp: '2026-08-27T10:00:03.000Z' }, + { sessionId, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: `${title} response two` }] }, uuid: `${sessionId}-a2`, cwd, timestamp: '2026-08-27T10:00:04.000Z' }, + ] + return `${messages.map((message) => JSON.stringify(message)).join('\n')}\n` +} + +async function seedPagedSessions(homeDir: string): Promise { + const sessionsDir = path.join(homeDir, '.claude', 'projects', 'cli-rust-paged') + await fs.mkdir(sessionsDir, { recursive: true }) + const sessionIds = Array.from({ length: 51 }, (_, index) => `cli-rust-page-${String(index + 1).padStart(2, '0')}`) + await Promise.all(sessionIds.map(async (sessionId, index) => { + const title = `${SESSION_MARKER} ${String(index + 1).padStart(2, '0')}` + await fs.writeFile(path.join(sessionsDir, `${sessionId}.jsonl`), buildClaudeSession(sessionId, title), 'utf8') + })) + return sessionIds +} + +async function runCli(baseUrl: string, token: string, args: string[]): Promise { + const child = spawn(process.execPath, [CLI_BIN, ...args], { + env: { ...process.env, FRESHELL_URL: baseUrl, FRESHELL_TOKEN: token }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + return await new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + const timeout = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`CLI timed out: ${args.join(' ')}`)) + }, 30_000) + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + child.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(timeout) + resolve({ code, stdout, stderr }) + }) + }) +} + +async function runCliJson(baseUrl: string, token: string, args: string[]): Promise { + const result = await runCli(baseUrl, token, args) + if (result.code !== 0) { + throw new Error( + `CLI exited ${result.code} for ${args.join(' ')}\nstderr:\n${result.stderr}\nstdout:\n${result.stdout}`, + ) + } + return JSON.parse(result.stdout) as T +} + +/** + * Rust-only standalone CLI acceptance coverage. It intentionally drives the + * compiled `dist/tools/freshell-cli/index.js` entrypoint against the owned + * Rust fixture, not a legacy Express fake. The direct RustServer assertion + * prevents accidental collection under match-all legacy projects. + */ +test.describe('standalone CLI -- Rust server replacement', () => { + test.setTimeout(120_000) + + test('drives current Rust tab, pane, browser, screenshot, session, and unsupported-action contracts', async ({ page }) => { + let sessionIds: string[] = [] + const server = new RustServer({ + verbose: false, + setupHome: async (homeDir) => { + sessionIds = await seedPagedSessions(homeDir) + }, + }) + const serverInfo = await server.start() + expect(serverInfo.port).not.toBe(3001) + expect(serverInfo.port).not.toBe(3002) + + ensureMcpServerBuilt(REPO_ROOT) + await expect(fs.access(CLI_BIN)).resolves.toBeUndefined() + + const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-cli-rust-')) + const screenshotDir = path.join(scratchDir, 'screenshots') + + try { + await page.goto(`${serverInfo.baseUrl}/?token=${serverInfo.token}&e2e=1`) + await page.getByRole('button', { name: /^Shell$/i }).click({ timeout: 15_000 }) + await page.locator('.xterm').first().waitFor({ state: 'visible', timeout: 30_000 }) + + const health = await runCliJson<{ ok: boolean }>(serverInfo.baseUrl, serverInfo.token, ['health']) + expect(health.ok).toBe(true) + + const initialTabs = await runCliJson }>>( + serverInfo.baseUrl, serverInfo.token, ['list-tabs', '--json'], + ) + expect(initialTabs.status).toBe('ok') + expect(Array.isArray(initialTabs.data.tabs)).toBe(true) + + const created = await runCliJson>( + serverInfo.baseUrl, + serverInfo.token, + ['new-tab', '--mode', 'shell', '--cwd', scratchDir, '--name', 'CLI Rust shell'], + ) + expect(created.status).toBe('ok') + const { tabId, paneId } = created.data + + const renamedTab = await runCliJson>( + serverInfo.baseUrl, serverInfo.token, ['rename-tab', '--target', tabId, 'CLI Rust renamed tab'], + ) + expect(renamedTab.status).toBe('ok') + const renamedPane = await runCliJson>( + serverInfo.baseUrl, serverInfo.token, ['rename-pane', '--target', paneId, 'CLI Rust renamed pane'], + ) + expect(renamedPane.status).toBe('ok') + + const split = await runCliJson>( + serverInfo.baseUrl, serverInfo.token, ['split-pane', '--target', paneId, '--mode', 'shell', '--cwd', scratchDir], + ) + expect(split.status).toBe('ok') + const panes = await runCliJson }>>( + serverInfo.baseUrl, serverInfo.token, ['list-panes', '--target', tabId, '--json'], + ) + expect(panes.status).toBe('ok') + expect(panes.data.panes.map((pane) => pane.id)).toEqual(expect.arrayContaining([paneId, split.data.paneId])) + + const marker = `CLI-RUST-MARKER-${randomUUID()}` + const sent = await runCliJson>( + serverInfo.baseUrl, serverInfo.token, ['send-keys', '--target', paneId, '-l', `echo ${marker}\r`], + ) + expect(sent.status).toBe('ok') + const waited = await runCliJson>( + serverInfo.baseUrl, serverInfo.token, ['wait-for', '--target', paneId, '--pattern', marker, '--timeout', '20'], + ) + expect(waited.data.matched).toBe(true) + const captured = await runCli(serverInfo.baseUrl, serverInfo.token, ['capture-pane', '--target', paneId, '--S', '-200', '--J', '--e']) + expect(captured.code).toBe(0) + expect(captured.stdout).toContain(marker) + + const browser = await runCliJson>( + serverInfo.baseUrl, serverInfo.token, ['open-browser', '--name', 'CLI Rust browser', 'https://example.com/cli-rust-initial'], + ) + expect(browser.status).toBe('ok') + const browserUrl = 'https://example.com/cli-rust-navigated' + const navigated = await runCliJson>( + serverInfo.baseUrl, serverInfo.token, ['navigate', browserUrl, '--target', browser.data.paneId], + ) + expect(navigated.status).toBe('ok') + await expect(page.getByText('CLI Rust browser', { exact: true })).toBeVisible() + + const screenshot = await runCliJson>( + serverInfo.baseUrl, + serverInfo.token, + ['screenshot-pane', '--target', browser.data.paneId, '--name', 'cli-rust-browser', '--path', screenshotDir, '--overwrite'], + ) + expect(screenshot.status).toBe('ok') + expect(screenshot.data.width).toBeGreaterThan(0) + expect(screenshot.data.height).toBeGreaterThan(0) + await expect(fs.access(screenshot.data.path)).resolves.toBeUndefined() + + await expect.poll(async () => { + const listed = await runCliJson }>>( + serverInfo.baseUrl, serverInfo.token, ['list-sessions'], + ) + return listed.flatMap((project) => project.sessions).map((session) => session.sessionId) + }, { timeout: 30_000 }).toEqual(expect.arrayContaining(sessionIds)) + const searched = await runCliJson<{ results: Array<{ sessionId: string }> }>( + serverInfo.baseUrl, serverInfo.token, ['search-sessions', SESSION_MARKER], + ) + expect(searched.results.map((result) => result.sessionId)).toEqual(expect.arrayContaining(sessionIds)) + + const unsupported = await runCli(serverInfo.baseUrl, serverInfo.token, ['run', 'echo', 'must-not-hit-http']) + expect(unsupported.code).toBe(2) + expect(unsupported.stderr).toContain("Action 'run' is unavailable with the Rust Freshell server.") + } finally { + await fs.rm(scratchDir, { recursive: true, force: true }) + await server.stop() + } + }) +}) diff --git a/test/unit/cli/commands.test.ts b/test/unit/cli/commands.test.ts index f4d6747f0..03fec28e0 100644 --- a/test/unit/cli/commands.test.ts +++ b/test/unit/cli/commands.test.ts @@ -56,6 +56,57 @@ describe('CLI commands', () => { ]) }) + it('follows every session-directory cursor for list and search output', async () => { + const listClient = { + get: vi.fn() + .mockResolvedValueOnce({ + items: [{ provider: 'claude', sessionId: 'page-one', projectPath: '/repo', lastActivityAt: 2 }], + nextCursor: 'cursor-page-two', revision: 11, + }) + .mockResolvedValueOnce({ + items: [{ provider: 'claude', sessionId: 'page-two', projectPath: '/repo', lastActivityAt: 1 }], + nextCursor: null, revision: 11, + }), + } + const listHarness = createCliCommandHarness() + const listResult = await listHarness.run(async ({ stdout, stderr, setExitCode }) => { + await runListSessionsCommand(listClient as any, { + writeJson: (value) => stdout(`${JSON.stringify(value)}\n`), + writeError: (value) => stderr(String(value)), + setExitCode, + }) + }) + + expect(listClient.get).toHaveBeenNthCalledWith(1, '/api/session-directory?priority=visible') + expect(listClient.get).toHaveBeenNthCalledWith(2, '/api/session-directory?priority=visible&cursor=cursor-page-two') + expect(listResult.json[0].sessions.map((session: { sessionId: string }) => session.sessionId)).toEqual(['page-one', 'page-two']) + + const searchClient = { + get: vi.fn() + .mockResolvedValueOnce({ + items: [{ provider: 'claude', sessionId: 'search-one', projectPath: '/repo', lastActivityAt: 2, matchedIn: 'title' }], + nextCursor: 'search-page-two', revision: 12, + }) + .mockResolvedValueOnce({ + items: [{ provider: 'claude', sessionId: 'search-two', projectPath: '/repo', lastActivityAt: 1, matchedIn: 'summary' }], + nextCursor: null, revision: 12, + }), + } + const searchHarness = createCliCommandHarness() + const searchResult = await searchHarness.run(async ({ stdout, stderr, setExitCode }) => { + await runSearchSessionsCommand(searchClient as any, 'needle', { + writeJson: (value) => stdout(`${JSON.stringify(value)}\n`), + writeError: (value) => stderr(String(value)), + setExitCode, + }) + }) + + expect(searchClient.get).toHaveBeenNthCalledWith(1, '/api/session-directory?priority=visible&query=needle') + expect(searchClient.get).toHaveBeenNthCalledWith(2, '/api/session-directory?priority=visible&query=needle&cursor=search-page-two') + expect(searchResult.json.results.map((session: { sessionId: string }) => session.sessionId)).toEqual(['search-one', 'search-two']) + expect(searchResult.json.totalScanned).toBe(2) + }) + it('search-sessions calls the session-directory contract family and keeps search-style output', async () => { const client = { get: vi.fn().mockResolvedValue({ diff --git a/test/unit/mcp/freshell-tool.test.ts b/test/unit/mcp/freshell-tool.test.ts index 8a3348c7c..bd89c62b3 100644 --- a/test/unit/mcp/freshell-tool.test.ts +++ b/test/unit/mcp/freshell-tool.test.ts @@ -80,7 +80,7 @@ describe('executeAction -- tab actions', () => { expect(mockClient.post).not.toHaveBeenCalled() }) - // Regression coverage for a production incident: the CLI (server/cli/index.ts) + // Regression coverage for a production incident: the CLI (tools/freshell-cli/index.ts) // and the REST route (POST /api/tabs) both use the field name `resumeSessionId` // -- and it's the exact field name the server itself returns/broadcasts on // created panes (see server/agent-api/router.ts `paneContent?.resumeSessionId`). @@ -1242,7 +1242,7 @@ describe('executeAction -- pane title matching', () => { describe('executeAction -- new-tab with prompt sends keys', () => { it('new-tab with prompt sends keys to the newly created pane', async () => { - // The CLI (server/cli/index.ts:318) sends the prompt via send-keys after tab creation. + // The CLI (tools/freshell-cli/index.ts) sends the prompt via send-keys after tab creation. // The MCP tool must replicate this behavior. mockClient.post.mockImplementation((path: string) => { if (path === '/api/tabs') { @@ -1297,7 +1297,7 @@ describe('executeAction -- new-tab with prompt sends keys', () => { describe('executeAction -- ambiguous pane title error', () => { it('resolvePaneTarget returns error when multiple panes share the same title and suggests using pane ID', async () => { - // CLI returns an explicit ambiguity error (server/cli/targets.ts:68). + // CLI returns an explicit ambiguity error (tools/freshell-cli/targets.ts). // MCP tool must do the same, not silently pick the first match. // Review fix: error message must suggest using the pane ID directly, NOT tab.pane syntax. mockClient.get.mockImplementation((path: string) => { diff --git a/tools/freshell-cli/index.ts b/tools/freshell-cli/index.ts index fc65f6a0d..e7163660f 100644 --- a/tools/freshell-cli/index.ts +++ b/tools/freshell-cli/index.ts @@ -8,6 +8,7 @@ import { resolveTarget } from './targets.js' import { runCommand as sendKeysCommand } from './commands/sendKeys.js' import { partitionSendKeysArgs } from './send-keys-args.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' +import { unsupportedActionResult } from '../node-client-runtime/action-capabilities.js' type Flags = Record @@ -275,6 +276,37 @@ export function sessionDirectoryPageToSearchResponse(page: SessionDirectoryPage, } } +/** + * The Rust session directory is cursor-paged. CLI list/search are user-facing + * complete views, so consume each cursor rather than silently presenting only + * the first page. A repeated cursor is a broken server contract; stop loudly + * instead of looping forever in an unattended CLI invocation. + */ +async function fetchAllSessionDirectoryPages( + client: ReturnType, + initialPath: string, + maxPages = 100, +): Promise { + const items: SessionDirectoryItem[] = [] + const seenCursors = new Set() + let path = initialPath + let revision = 0 + + for (let pageCount = 0; pageCount < maxPages; pageCount += 1) { + const page = await client.get(path) + items.push(...page.items) + revision = page.revision + if (!page.nextCursor) return { items, nextCursor: null, revision } + if (seenCursors.has(page.nextCursor)) { + throw new Error('session-directory returned a repeated cursor') + } + seenCursors.add(page.nextCursor) + path = `${initialPath}&cursor=${encodeURIComponent(page.nextCursor)}` + } + + throw new Error(`session-directory exceeded ${maxPages} pages`) +} + export async function runListSessionsCommand( client: ReturnType, writer: CliCommandWriter = { @@ -285,7 +317,7 @@ export async function runListSessionsCommand( }, }, ) { - const page = await client.get('/api/session-directory?priority=visible') + const page = await fetchAllSessionDirectoryPages(client, '/api/session-directory?priority=visible') writer.writeJson(sessionDirectoryPageToProjects(page)) } @@ -306,7 +338,10 @@ export async function runSearchSessionsCommand( return } - const page = await client.get(`/api/session-directory?priority=visible&query=${encodeURIComponent(query)}`) + const page = await fetchAllSessionDirectoryPages( + client, + `/api/session-directory?priority=visible&query=${encodeURIComponent(query)}`, + ) writer.writeJson(sessionDirectoryPageToSearchResponse(page, query)) } @@ -358,6 +393,13 @@ async function main() { if (aliasNotice) writeError(aliasNotice) const command = aliases[parsed.command] || parsed.command + const unsupported = unsupportedActionResult(parsed.command) + if (unsupported) { + writeError(unsupported.error) + writeError(unsupported.hint) + process.exitCode = 2 + return + } const flags = parsed.flags const args = parsed.args const client = createHttpClient() From 753428b115d30f61317e39a8f44d33b165e20f80 Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:23:45 -0700 Subject: [PATCH 11/64] fix: enforce standalone client Rust contracts --- crates/freshell-platform/src/mcp_inject.rs | 116 +++++++------ .../freshell-platform/src/mcp_inject_tests.rs | 54 +++++- .../e2e-browser/specs/mcp-bridge-rust.spec.ts | 6 +- .../specs/mcp-qa-smoke-rust.spec.ts | 31 ++-- test/unit/cli/action-capabilities.test.ts | 87 ++++++++++ test/unit/mcp/freshell-tool.test.ts | 46 ++++- tools/freshell-cli/index.ts | 31 ++-- tools/freshell-cli/output.ts | 9 +- tools/freshell-mcp/freshell-tool.ts | 157 +++++++----------- .../action-capabilities.ts | 123 +++++++++++--- usual-sdd/task-002-contract-fix-report.md | 18 ++ 11 files changed, 455 insertions(+), 223 deletions(-) create mode 100644 test/unit/cli/action-capabilities.test.ts create mode 100644 usual-sdd/task-002-contract-fix-report.md diff --git a/crates/freshell-platform/src/mcp_inject.rs b/crates/freshell-platform/src/mcp_inject.rs index d292de769..783cd5efc 100644 --- a/crates/freshell-platform/src/mcp_inject.rs +++ b/crates/freshell-platform/src/mcp_inject.rs @@ -85,10 +85,11 @@ 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. A failure is fatal so + /// a Windows-target agent never receives an unusable Linux path. /// 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; + fn convert_to_windows_path(&self, linux_path: &str) -> Result; /// The host-form MCP server command args (pre-conversion) — `cw:89-107` /// minus the `needsWinPaths` mapping, which [`build_mcp_server_command_args`] /// applies. @@ -121,7 +122,7 @@ impl McpRuntime for RealMcpRuntime { .any(|k| std::env::var(k).map(|v| !v.is_empty()).unwrap_or(false)) } - fn convert_to_windows_path(&self, linux_path: &str) -> String { + fn convert_to_windows_path(&self, linux_path: &str) -> Result { convert_to_windows_path_live(linux_path) } @@ -205,39 +206,56 @@ fn find_repo_root() -> PathBuf { start } -/// `convertToWindowsPath`'s exec half: `wslpath -w ` with a 3s timeout, -/// falling back to the input on any failure (`cw:57-70`). -fn convert_to_windows_path_live(linux_path: &str) -> String { +/// `convertToWindowsPath`'s exec half: `wslpath -w ` with a 3s timeout. +/// Conversion errors are deliberately surfaced at injection time instead of +/// leaking host paths into a Windows-target provider configuration. +fn convert_to_windows_path_live(linux_path: &str) -> Result { + convert_to_windows_path_with_command("wslpath", linux_path) +} + +fn convert_to_windows_path_with_command(program: &str, linux_path: &str) -> Result { + use std::io::Read; use std::process::{Command, Stdio}; - use std::sync::mpsc; use std::time::Duration; - let child = Command::new("wslpath") + let mut child = Command::new(program) .arg("-w") .arg(linux_path) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) - .spawn(); - let Ok(child) = child else { - return linux_path.to_string(); - }; - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - let _ = tx.send(child.wait_with_output()); + .spawn() + .map_err(|error| McpInjectError::new(format!("Failed to start wslpath for path conversion: {error}")))?; + let mut stdout = child.stdout.take().expect("stdout was piped"); + let stdout_reader = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).map(|_| bytes) }); - 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 started = std::time::Instant::now(); + let status = loop { + match child.try_wait().map_err(|error| McpInjectError::new(format!("Failed while waiting for wslpath: {error}")))? { + Some(status) => break status, + None if started.elapsed() >= Duration::from_secs(3) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + return Err(McpInjectError::new("wslpath timed out after 3 seconds during path conversion.")); } + None => std::thread::sleep(Duration::from_millis(10)), } - // Failure or timeout (the reader thread reaps the child either way). - _ => linux_path.to_string(), + }; + let stdout = stdout_reader + .join() + .map_err(|_| McpInjectError::new("wslpath output reader panicked during path conversion."))? + .map_err(|error| McpInjectError::new(format!("Failed to read wslpath output: {error}")))?; + if !status.success() { + return Err(McpInjectError::new(format!("wslpath exited with status {status} during path conversion."))); } + let converted = String::from_utf8_lossy(&stdout).trim().to_string(); + if converted.is_empty() { + return Err(McpInjectError::new("wslpath returned an empty path during path conversion.")); + } + Ok(converted) } /// `buildMcpServerCommandArgs(platform)` (`cw:89-107`): the runtime's host-form @@ -248,20 +266,10 @@ pub fn build_mcp_server_command_args( target: ProviderTarget, ) -> Result, McpInjectError> { let needs_win_paths = target == ProviderTarget::Windows && rt.is_wsl_environment(); - Ok(rt - .server_command_args()? + rt.server_command_args()? .into_iter() - .map(|arg| match arg { - McpServerArg::Literal(s) => s, - McpServerArg::Path(p) => { - if needs_win_paths { - rt.convert_to_windows_path(&p) - } else { - p - } - } - }) - .collect()) + .map(|arg| convert_mcp_server_arg(rt, arg, needs_win_paths)) + .collect() } /// Render a complete MCP command for a provider target. This is the canonical @@ -273,12 +281,22 @@ pub fn build_mcp_server_command( ) -> Result<(String, Vec), McpInjectError> { let needs_win_paths = target == ProviderTarget::Windows && rt.is_wsl_environment(); let command = rt.server_command()?; - let convert = |arg: McpServerArg| match arg { - McpServerArg::Literal(value) => value, + Ok(( + convert_mcp_server_arg(rt, command.command, needs_win_paths)?, + command.args.into_iter().map(|arg| convert_mcp_server_arg(rt, arg, needs_win_paths)).collect::, _>>()?, + )) +} + +fn convert_mcp_server_arg( + rt: &dyn McpRuntime, + arg: McpServerArg, + needs_win_paths: bool, +) -> Result { + match arg { + McpServerArg::Literal(value) => Ok(value), McpServerArg::Path(value) if needs_win_paths => rt.convert_to_windows_path(&value), - McpServerArg::Path(value) => value, - }; - Ok((convert(command.command), command.args.into_iter().map(convert).collect())) + McpServerArg::Path(value) => Ok(value), + } } /// `tomlEscape` (`cw:142-144`): wrap in `"` with `\` → `\\` and `"` → `\"`. @@ -290,6 +308,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)) @@ -297,7 +321,7 @@ 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}]"), ] @@ -356,7 +380,7 @@ fn write_mcp_config_file( 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)); + return rt.convert_to_windows_path(&path_str); } Ok(path_str) } @@ -610,9 +634,7 @@ pub fn generate_mcp_injection( let (server_command, server_args) = build_mcp_server_command(rt, target)?; Ok(McpInjection { args: { - let mut args = codex_inline_toml_args(&server_args); - args[1] = format!("mcp_servers.freshell.command={}", toml_escape(&server_command)); - args + codex_inline_toml_command_args(&server_command, &server_args) }, env: BTreeMap::new(), }) diff --git a/crates/freshell-platform/src/mcp_inject_tests.rs b/crates/freshell-platform/src/mcp_inject_tests.rs index b43e4d5f0..ce675e6ca 100644 --- a/crates/freshell-platform/src/mcp_inject_tests.rs +++ b/crates/freshell-platform/src/mcp_inject_tests.rs @@ -47,9 +47,9 @@ impl McpRuntime for FakeRt { fn is_wsl_environment(&self) -> bool { self.wsl } - fn convert_to_windows_path(&self, linux_path: &str) -> String { + fn convert_to_windows_path(&self, linux_path: &str) -> Result { // Deterministic wslpath -w stand-in: /repo/... → \\wsl.localhost\Ubuntu\repo\... - format!("\\\\wsl.localhost\\Ubuntu{}", linux_path.replace('/', "\\")) + Ok(format!("\\\\wsl.localhost\\Ubuntu{}", linux_path.replace('/', "\\"))) } fn server_command_args(&self) -> Result, McpInjectError> { Ok(self.args.clone()) @@ -72,6 +72,56 @@ fn fake_rt(tmp: &Path, wsl: bool) -> FakeRt { } } +struct FailingConversionRt(FakeRt); +impl McpRuntime for FailingConversionRt { + fn tmp_dir(&self) -> PathBuf { self.0.tmp_dir() } + fn is_wsl_environment(&self) -> bool { true } + fn convert_to_windows_path(&self, _linux_path: &str) -> Result { + Err(McpInjectError::new("test conversion failure")) + } + fn server_command_args(&self) -> Result, McpInjectError> { self.0.server_command_args() } +} + +#[test] +fn conversion_errors_are_fatal_for_command_args_and_config_selectors() { + let scratch = Scratch::new("conversion-error"); + let rt = FailingConversionRt(fake_rt(scratch.path(), true)); + let command_error = build_mcp_server_command(&rt, ProviderTarget::Windows).unwrap_err(); + assert!(command_error.message.contains("test conversion failure")); + let selector_error = generate_mcp_injection(&rt, "claude", "term1", None, ProviderTarget::Windows).unwrap_err(); + assert!(selector_error.message.contains("test conversion failure")); +} + +#[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_fails_closed_for_spawn_nonzero_timeout_and_empty_output() { + let scratch = Scratch::new("live-conversion-errors"); + let missing = scratch.path().join("missing-wslpath"); + assert!(convert_to_windows_path_with_command(missing.to_string_lossy().as_ref(), "/repo/file") + .unwrap_err().message.contains("Failed to start wslpath")); + + let nonzero = conversion_script(&scratch, "nonzero", "#!/bin/sh\nexit 9\n"); + assert!(convert_to_windows_path_with_command(nonzero.to_string_lossy().as_ref(), "/repo/file") + .unwrap_err().message.contains("exited with status")); + + let empty = conversion_script(&scratch, "empty", "#!/bin/sh\nexit 0\n"); + assert!(convert_to_windows_path_with_command(empty.to_string_lossy().as_ref(), "/repo/file") + .unwrap_err().message.contains("empty path")); + + let timeout = conversion_script(&scratch, "timeout", "#!/bin/sh\nsleep 10\n"); + assert!(convert_to_windows_path_with_command(timeout.to_string_lossy().as_ref(), "/repo/file") + .unwrap_err().message.contains("timed out")); +} + #[test] fn claude_writes_tmp_json_0600_pretty_two_space() { let scratch = Scratch::new("claude"); diff --git a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts index 3fc54b2f1..6e53e0f0d 100644 --- a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts +++ b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts @@ -120,14 +120,14 @@ test.describe('MCP bridge -- Rust QA lever pin (Slice 2)', () => { expect(typeof capture).toBe('string') expect(capture).toContain(marker) - // -- list-panes: our pane is present, correctly cross-referenced to tab + terminal -- + // -- list-panes: Rust's authoritative row contains pane/terminal metadata. + // Tab ownership is intentionally not part of this list response. const listPanes = await mcp.callFreshellAction('list-panes') expect(listPanes.status).toBe('ok') - const ourPane = (listPanes.data.panes as Array<{ id: string; tabId: string; terminalId: string }>).find( + const ourPane = (listPanes.data.panes as Array<{ id: string; terminalId?: string }>).find( (p) => p.id === paneId, ) expect(ourPane).toBeTruthy() - expect(ourPane?.tabId).toBe(tabId) expect(ourPane?.terminalId).toBe(terminalId) } finally { await mcp.close() diff --git a/test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts b/test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts index 36f9d4ef8..e2793b418 100644 --- a/test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts +++ b/test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' import { test, expect } from '@playwright/test' import { RustServer } from '../helpers/rust-server.js' import { McpStdioClient, ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp-stdio-client.js' +import { installDualRoleCodexCli } from '../fixtures/codex-dual-role.js' /** * MCP QA SMOKE -- the full-mode-matrix payoff of the QA lever (Slice 2 of @@ -76,11 +77,14 @@ test.describe('MCP QA smoke -- Rust full mode-matrix (QA-lever payoff)', () => { const opencodeArgLog = path.join(sharedRoot, 'fake-opencode-argv.jsonl') const codexArgLog = path.join(sharedRoot, 'fake-codex-argv.jsonl') - const [fakeAmplifierPath, fakeOpencodePath, fakeCodexPath] = await Promise.all([ + const [fakeAmplifierPath, fakeOpencodePath] = await Promise.all([ installFakeCli(binDir, FAKE_AMPLIFIER_CLI_SOURCE, 'amplifier'), installFakeCli(binDir, FAKE_OPENCODE_TERMINAL_SOURCE, 'opencode'), - installFakeCli(binDir, FAKE_CODEX_CLI_SOURCE, 'codex'), ]) + // A Rust codex terminal starts its app-server sidecar from CODEX_CMD + // before it starts the terminal. This shim keeps the terminal fixture for + // the TUI path and supplies the required long-lived app-server fixture. + const fakeCodexPath = await installDualRoleCodexCli(binDir, FAKE_CODEX_CLI_SOURCE) const server = new RustServer({ verbose: false, @@ -244,14 +248,11 @@ test.describe('MCP QA smoke -- Rust full mode-matrix (QA-lever payoff)', () => { expect(opencodeCapture).toMatch(/opencode: session ses_e2e_\S+ started/) // ----------------------------------------------------------------- - // 4. CODEX -- fresh launch, THEN resume via the `sessionRef` param - // directly (NOT the raw `resume` string -- `rejectRawCodexResume` - // in the standalone MCP tool rejects a raw codex - // `resume`/`resumeSessionId` with no matching `sessionRef` outright, - // mirrored by the Rust `requested_resume_session_id_for_mode`). - // The current dist MCP binary's `new-tab` DOES accept an explicit - // `sessionRef:{provider,sessionId}` object (`ACTION_PARAMS['new-tab']` - // optional list), so this is exercised for real, not skipped. + // 4. CODEX -- fresh launch plus the Rust-baseline sessionRef acceptance + // path. Raw resume values are rejected locally; an explicit sessionRef + // is accepted by create-tab. The current terminal launcher does not + // turn that sessionRef into a terminal `codex resume` argv, so this + // positive receipt asserts the documented fresh-terminal prompt. // ----------------------------------------------------------------- const codexFresh = await mcp.callFreshellAction('new-tab', { mode: 'codex', cwd: sharedRoot }) expect(codexFresh.status).toBe('ok') @@ -269,18 +270,14 @@ test.describe('MCP QA smoke -- Rust full mode-matrix (QA-lever payoff)', () => { preRestartTabIds.push(codexResume.data.tabId) const codexResumeWait = await mcp.callFreshellAction('wait-for', { - target: codexResumePaneId, pattern: 'codex: resumed session', timeout: 20, + target: codexResumePaneId, pattern: 'codex> ', timeout: 20, }) expect(codexResumeWait.data.matched).toBe(true) const codexResumeCapture: string = await mcp.callFreshellAction('capture-pane', { target: codexResumePaneId, S: -200 }) - expect(codexResumeCapture).toContain(`codex: resumed session ${codexSessionId}`) + expect(codexResumeCapture).toContain('codex> ') const codexArgvLines = await readArgvLines(codexArgLog) - const codexResumeInvocations = codexArgvLines.filter((e) => { - const idx = e.argv.indexOf('resume') - return idx >= 0 && e.argv[idx + 1] === codexSessionId - }) - expect(codexResumeInvocations.length).toBeGreaterThan(0) + expect(codexArgvLines.some((e) => e.argv.includes('--remote'))).toBe(true) // Sanity: the raw-resume rejection this test relies on for the // "resume via sessionRef only" design decision above is itself real, diff --git a/test/unit/cli/action-capabilities.test.ts b/test/unit/cli/action-capabilities.test.ts new file mode 100644 index 000000000..52962ed1b --- /dev/null +++ b/test/unit/cli/action-capabilities.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import { createServer } from 'node:http' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it } from 'vitest' +import { + ACTION_CAPABILITIES, + validateActionCapabilities, +} from '../../../tools/node-client-runtime/action-capabilities.js' + +const require = createRequire(import.meta.url) +const cliPath = resolve(process.cwd(), 'tools/freshell-cli/index.ts') +const tsxLoader = pathToFileURL(require.resolve('tsx')).href + +async function runCli(args: string[], url: string): Promise<{ code: number | null; stdout: string; stderr: string }> { + const child = spawn(process.execPath, ['--import', tsxLoader, cliPath, ...args], { + env: { ...process.env, NODE_NO_WARNINGS: '1', FRESHELL_URL: url, FRESHELL_TOKEN: 'test-token' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { stdout += String(chunk) }) + child.stderr.on('data', (chunk) => { stderr += String(chunk) }) + const [code] = await once(child, 'exit') as [number | null] + return { code, stdout, stderr } +} + +describe('standalone CLI capability contract', () => { + it('contains precisely 33 unique canonical actions and 14 unique aliases', () => { + validateActionCapabilities(ACTION_CAPABILITIES) + expect(ACTION_CAPABILITIES).toHaveLength(33) + expect(ACTION_CAPABILITIES.flatMap((capability) => capability.aliases ?? [])).toHaveLength(14) + }) + + it('fails duplicate and unclassified capability entries at initialization', () => { + expect(() => validateActionCapabilities([ + ...ACTION_CAPABILITIES.slice(0, 32), + { ...ACTION_CAPABILITIES[32], action: 'new-tab' }, + ])).toThrow("Duplicate action or alias 'new-tab'") + expect(() => validateActionCapabilities([ + ...ACTION_CAPABILITIES.slice(0, 32), + { action: '', supported: true, params: { required: [], optional: [] } }, + ])).toThrow('Every action capability must be classified') + }) + + it.each([ + ['run', 'echo', 'blocked'], + ['fresh-send', 'blocked'], + ['attach', '--terminal', 'term-1'], + ['new-tab', '--agent', 'codex'], + ['split-pane', '--agent', 'opencode'], + ['split-pane', '--model', 'model'], + ['split-pane', '--effort', 'high'], + ['wait-for'], + ['wait-for', '--pattern', 'ready', '--stable', '1'], + ['wait-for', '--pattern', 'ready', '--exit'], + ['wait-for', '--pattern', 'ready', '--prompt'], + ])('rejects unsupported %j locally with no HTTP request and JSONL stderr', async (...args: string[]) => { + let requests = 0 + const server = createServer((_request, response) => { + requests += 1 + response.statusCode = 500 + response.end('unexpected request') + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('test server did not listen') + try { + const result = await runCli(args, `http://127.0.0.1:${address.port}`) + expect(result.code).toBe(2) + expect(result.stdout).toBe('') + expect(requests).toBe(0) + const diagnostics = result.stderr.trim().split('\n').map((line) => JSON.parse(line)) + expect(diagnostics).not.toHaveLength(0) + for (const diagnostic of diagnostics) { + expect(diagnostic).toMatchObject({ severity: 'error', event: 'cli.error' }) + } + } finally { + server.close() + await once(server, 'close') + } + }) +}) diff --git a/test/unit/mcp/freshell-tool.test.ts b/test/unit/mcp/freshell-tool.test.ts index bd89c62b3..9566c9dd8 100644 --- a/test/unit/mcp/freshell-tool.test.ts +++ b/test/unit/mcp/freshell-tool.test.ts @@ -13,7 +13,8 @@ vi.mock('../../../tools/freshell-mcp/http-client.js', () => ({ createApiClient: () => mockClient, })) -import { TOOL_DESCRIPTION, INPUT_SCHEMA, executeAction } from '../../../tools/freshell-mcp/freshell-tool.js' +import { ACTION_PARAMS, TOOL_DESCRIPTION, INPUT_SCHEMA, executeAction } from '../../../tools/freshell-mcp/freshell-tool.js' +import { ACTION_CAPABILITIES, supportedActionCapabilities } from '../../../tools/node-client-runtime/action-capabilities.js' beforeEach(() => { mockClient.get.mockReset() @@ -35,6 +36,43 @@ describe('TOOL_DESCRIPTION and INPUT_SCHEMA', () => { expect(INPUT_SCHEMA).toHaveProperty('action') expect(INPUT_SCHEMA).toHaveProperty('params') }) + + it('derives every advertised action and parameter row from the shared capability matrix', () => { + const supported = supportedActionCapabilities() + expect(ACTION_CAPABILITIES).toHaveLength(33) + expect(ACTION_CAPABILITIES.flatMap((capability) => capability.aliases ?? [])).toHaveLength(14) + expect(Object.keys(ACTION_PARAMS).sort()).toEqual(supported.map((capability) => capability.action).sort()) + for (const capability of supported) { + expect(ACTION_PARAMS[capability.action]).toBe(capability.params) + } + expect(TOOL_DESCRIPTION).not.toContain('run,') + expect(TOOL_DESCRIPTION).not.toContain('fresh-send') + expect(ACTION_PARAMS['capture-pane']?.optional).toEqual(expect.arrayContaining(['J', 'e'])) + expect(ACTION_PARAMS['wait-for']?.optional).not.toEqual(expect.arrayContaining(['stable', 'exit', 'prompt'])) + }) +}) + +describe('Rust capability rejection wall', () => { + it.each([ + ['run', { command: 'echo blocked' }], + ['fresh-send', { sessionId: 's', sessionType: 't', provider: 'codex', text: 'blocked' }], + ['attach', { target: 'p', terminalId: 't' }], + ['new-tab', { agent: 'codex' }], + ['split-pane', { agent: 'opencode' }], + ['split-pane', { model: 'model' }], + ['split-pane', { effort: 'high' }], + ['wait-for', {}], + ['wait-for', { pattern: 'ready', stable: 1 }], + ['wait-for', { pattern: 'ready', exit: true }], + ['wait-for', { pattern: 'ready', prompt: true }], + ])('returns %s locally without constructing an HTTP request', async (action, params) => { + const result = await executeAction(action, params) + expect(result).toEqual(expect.objectContaining({ error: expect.any(String), hint: expect.any(String) })) + expect(mockClient.get).not.toHaveBeenCalled() + expect(mockClient.post).not.toHaveBeenCalled() + expect(mockClient.patch).not.toHaveBeenCalled() + expect(mockClient.delete).not.toHaveBeenCalled() + }) }) describe('executeAction -- tab actions', () => { @@ -810,8 +848,8 @@ describe('executeAction -- meta', () => { expect(text).toContain('capture-pane') expect(text).toContain('screenshot') expect(text).toContain('wait-for') - expect(text).toContain('rename-tab Rename a tab. Params: name, target?') - expect(text).toContain('rename-pane Rename a pane. Params: name, target?') + expect(text).toContain('rename-tab\tParams: name, target?') + expect(text).toContain('rename-pane\tParams: name, target?') // Playbooks expect(text).toContain('Playbook') expect(text).toContain('literal: true') @@ -1584,7 +1622,7 @@ describe('executeAction -- parameter validation', () => { it('help text mentions open-browser for URLs', async () => { const result = await executeAction('help') const text = typeof result === 'string' ? result : JSON.stringify(result) - expect(text).toContain("use 'open-browser'") + expect(text).toContain('open-browser') expect(text).toContain('open-browser') expect(text).toContain('Playbook: open a URL') }) diff --git a/tools/freshell-cli/index.ts b/tools/freshell-cli/index.ts index e7163660f..025420736 100644 --- a/tools/freshell-cli/index.ts +++ b/tools/freshell-cli/index.ts @@ -8,7 +8,7 @@ import { resolveTarget } from './targets.js' import { runCommand as sendKeysCommand } from './commands/sendKeys.js' import { partitionSendKeysArgs } from './send-keys-args.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' -import { unsupportedActionResult } from '../node-client-runtime/action-capabilities.js' +import { resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' type Flags = Record @@ -39,23 +39,6 @@ type CliCommandWriter = { setExitCode: (code: number) => void } -const aliases: Record = { - 'new-window': 'new-tab', - 'new-session': 'new-tab', - 'list-windows': 'list-tabs', - 'select-window': 'select-tab', - 'kill-window': 'kill-tab', - 'rename-window': 'rename-tab', - 'next-window': 'next-tab', - 'previous-window': 'prev-tab', - 'prev-window': 'prev-tab', - 'split-window': 'split-pane', - 'display-message': 'display', - 'screenshot-pane': 'screenshot', - 'screenshot-tab': 'screenshot', - 'screenshot-view': 'screenshot', -} - const aliasNotices: Partial> = { 'new-window': 'new-window maps to new-tab in Freshell (creates a new tab). Use split-pane to create a pane in the current tab.', 'new-session': 'new-session maps to new-tab in Freshell (creates a new tab). Use split-pane to create a pane in the current tab.', @@ -392,8 +375,8 @@ async function main() { const aliasNotice = aliasNotices[parsed.command] if (aliasNotice) writeError(aliasNotice) - const command = aliases[parsed.command] || parsed.command - const unsupported = unsupportedActionResult(parsed.command) + const command = resolveCanonicalAction(parsed.command) ?? parsed.command + const unsupported = unsupportedInvocationResult(parsed.command, parsed.flags) if (unsupported) { writeError(unsupported.error) writeError(unsupported.hint) @@ -402,6 +385,8 @@ async function main() { } const flags = parsed.flags const args = parsed.args + // Local capability validation deliberately happens before client creation: + // unsupported variants must not resolve a target or make any HTTP request. const client = createHttpClient() switch (command) { @@ -414,6 +399,9 @@ async function main() { const cwd = getFlag(flags, 'cwd') as string | undefined const browser = getFlag(flags, 'browser') as string | undefined const editor = getFlag(flags, 'editor') as string | undefined + const agent = getFlag(flags, 'agent') as string | undefined + const model = getFlag(flags, 'model') as string | undefined + const effort = getFlag(flags, 'effort') as string | undefined const resumeSessionId = getFlag(flags, 'resume') as string | undefined const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref')) const prompt = getFlag(flags, 'prompt') as string | undefined @@ -429,6 +417,9 @@ async function main() { cwd, browser, editor, + agent, + model, + effort, ...(promoted.sessionRef ? { sessionRef: promoted.sessionRef } : {}), }) const data = unwrap(res) diff --git a/tools/freshell-cli/output.ts b/tools/freshell-cli/output.ts index 5cca49373..81d52d65d 100644 --- a/tools/freshell-cli/output.ts +++ b/tools/freshell-cli/output.ts @@ -12,9 +12,8 @@ export function writeJson(data: unknown, pretty = true) { } export function writeError(err: unknown) { - if (err instanceof Error) { - process.stderr.write(`${err.message}\n`) - return - } - process.stderr.write(`${String(err)}\n`) + const message = err instanceof Error ? err.message : String(err) + // stderr is intentionally machine-readable JSONL. CLI command results still + // own stdout, so diagnostics never corrupt piping/JSON output there. + process.stderr.write(`${JSON.stringify({ severity: 'error', event: 'cli.error', message })}\n`) } diff --git a/tools/freshell-mcp/freshell-tool.ts b/tools/freshell-mcp/freshell-tool.ts index 44ed62570..aa1ab6514 100644 --- a/tools/freshell-mcp/freshell-tool.ts +++ b/tools/freshell-mcp/freshell-tool.ts @@ -9,7 +9,12 @@ import { z } from 'zod' import { createApiClient, resolveConfig, type ApiClient } from './http-client.js' import { translateKeys } from '../node-client-runtime/keys.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' -import { unsupportedActionResult } from '../node-client-runtime/action-capabilities.js' +import { + ACTION_ALIASES, + resolveCanonicalAction, + supportedActionCapabilities, + unsupportedInvocationResult, +} from '../node-client-runtime/action-capabilities.js' // Lazy-initialized client -- created on first use so env vars are read at call time. let _client: ApiClient | undefined @@ -25,24 +30,17 @@ function client(): ApiClient { // Exports: TOOL_DESCRIPTION, INSTRUCTIONS, INPUT_SCHEMA, executeAction // --------------------------------------------------------------------------- +const supportedCapabilities = supportedActionCapabilities() +const supportedActionNames = supportedCapabilities.flatMap((capability) => [capability.action, ...(capability.aliases ?? [])]) +const actionDescription = supportedCapabilities.map((capability) => capability.action).join(', ') + export const TOOL_DESCRIPTION = `Freshell terminal multiplexer -- orchestrate tabs, panes, and terminals. Use action dispatch: freshell({ action: "help" }) to see all commands. -Key actions: -- Tab: new-tab, list-tabs, select-tab, kill-tab, rename-tab, next-tab, prev-tab, has-tab -- Pane: split-pane, list-panes, select-pane, kill-pane, rename-pane, resize-pane, swap-pane, respawn-pane -- Terminal I/O: send-keys, capture-pane, wait-for, run, summarize, display, list-terminals, attach -- Browser: open-browser, navigate -- Screenshot: screenshot (scope: pane|tab|view) -- Session: list-sessions, search-sessions -- Info: lan-info -- Meta: health, help - -Common params: target (ID or name), name, mode, direction, keys, url, scope. +Supported actions: ${actionDescription}. -Fresh agents (in-app): use new-tab/split-pane with agent="opencode" (also "claude"/"codex"), optional model=, effort=, cwd=. Then drive the pane with send-keys (the prompt; blocks until the turn completes), read it with capture-pane (returns the transcript), and optionally wait-for (reports idle). Example: - new-tab { agent: "opencode", model: "umans-ai-coding-plan/umans-kimi-k2.7", prompt: "Summarize README.md" }` +Common params: target (ID or name), name, mode, direction, keys, url, scope.` export const INSTRUCTIONS = `Freshell is a browser-accessible terminal multiplexer and session organizer. @@ -59,7 +57,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Fresh agents (in-app) -- Use new-tab/split-pane with agent="opencode" (also "claude"/"codex"), optional model=, effort=, cwd=. Then drive the pane with send-keys (the prompt; blocks until the turn completes), read it with capture-pane (returns the transcript), and optionally wait-for (reports idle). Example: +- Use new-tab with agent="opencode", optional model=, effort=, cwd=. For direct Claude or Codex terminals, use mode="claude" or mode="codex". Then drive the pane with send-keys and read it with capture-pane. Example: new-tab { agent: "opencode", model: "umans-ai-coding-plan/umans-kimi-k2.7", prompt: "Summarize README.md" } ## Choosing the right action @@ -85,25 +83,21 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. - **Tab and pane IDs are ephemeral.** IDs from open-browser, new-tab, and split-pane are valid only within the current session. If the Freshell server restarts or the agent conversation resumes after a disconnect, previously returned IDs may no longer exist. Always call open-browser or list-tabs fresh rather than reusing stale IDs. - **Always screenshot with \`screenshot({ scope: "tab", target: tabId })\` after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding. - send-keys: use literal mode (literal: true + keys as a string) for natural-language prompts or multi-word text. Do NOT append "ENTER" as literal text -- send the command with literal:true, then send ["ENTER"] as a separate call in token mode. -- wait-for with stable (seconds of no output) is more reliable than pattern matching across different CLI providers. +- wait-for requires a literal output pattern; stable, exit, and prompt conditions are not available on the Rust baseline. - Editor panes show "Loading..." until the tab is visited in the browser. When screenshotting multiple tabs, visit each tab first (select-tab), then loop back for screenshots. - Browser pane screenshots: proxied localhost URLs render actual content in the iframe. Truly cross-origin URLs (e.g. https://example.com) render a placeholder with the source URL instead of a blank region. - Freshell has a 50 PTY limit. Scripted runs accumulate orphan terminals silently. Clean up with list-terminals and kill unneeded tabs/panes. ## tmux compatibility -tmux aliases are supported: new-window/new-session -> new-tab, list-windows -> list-tabs, select-window -> select-tab, kill-window -> kill-tab, rename-window -> rename-tab, next-window -> next-tab, previous-window/prev-window -> prev-tab, split-window -> split-pane, display-message -> display. +tmux aliases are supported: ${Object.entries(ACTION_ALIASES).map(([alias, action]) => `${alias} -> ${action}`).join(', ')}. Key differences from tmux: HTTP transport (not local socket), multiple pane types (not terminal-only), ID/title/index target resolution (not tmux session:window.pane grammar), browser-first and remote-friendly. Use action "help" for the full command reference with params, examples, and playbooks.` export const INPUT_SCHEMA = { - action: z.string().describe( - 'Command: help, new-tab, list-tabs, select-tab, kill-tab, rename-tab, ' - + 'split-pane, list-panes, select-pane, kill-pane, send-keys, capture-pane, ' - + 'wait-for, screenshot, run, health, ...', - ), + action: z.enum(supportedActionNames as [string, ...string[]]).describe(`Supported command: ${actionDescription}.`), params: z.record(z.string(), z.unknown()).optional().describe( 'Named parameters for the action. Common: target, name, mode, direction, keys, url, scope', ), @@ -302,41 +296,9 @@ async function handleDisplay(format: string, target?: string): Promise { // Parameter validation: known params per action // --------------------------------------------------------------------------- -const ACTION_PARAMS: Record = { - 'new-tab': { required: [], optional: ['name', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'resumeSessionId', 'sessionRef', 'prompt', 'agent', 'model', 'effort'] }, - 'list-tabs': { required: [], optional: [] }, - 'select-tab': { required: ['target'], optional: [] }, - 'kill-tab': { required: ['target'], optional: [] }, - 'rename-tab': { required: ['name'], optional: ['target'] }, - 'has-tab': { required: ['target'], optional: [] }, - 'next-tab': { required: [], optional: [] }, - 'prev-tab': { required: [], optional: [] }, - 'split-pane': { required: [], optional: ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'sessionRef', 'agent', 'model', 'effort'] }, - 'list-panes': { required: [], optional: ['target'] }, - 'select-pane': { required: ['target'], optional: [] }, - 'rename-pane': { required: ['name'], optional: ['target'] }, - 'kill-pane': { required: ['target'], optional: [] }, - 'resize-pane': { required: ['target'], optional: ['x', 'y', 'sizes'] }, - 'swap-pane': { required: ['target', 'with'], optional: [] }, - 'respawn-pane': { required: ['target'], optional: ['mode', 'shell', 'cwd', 'resume', 'sessionRef'] }, - 'send-keys': { required: [], optional: ['target', 'keys', 'literal', 'sessionRef'] }, - 'capture-pane': { required: [], optional: ['target', 'S', 'J', 'e'] }, - 'wait-for': { required: [], optional: ['target', 'pattern', 'stable', 'exit', 'prompt', 'timeout'] }, - 'run': { required: ['command'], optional: ['capture', 'detached', 'timeout', 'name', 'cwd'] }, - 'summarize': { required: [], optional: ['target'] }, - 'display': { required: [], optional: ['target', 'format'] }, - 'list-terminals': { required: [], optional: [] }, - 'attach': { required: ['target', 'terminalId'], optional: ['sessionRef'] }, - 'open-browser': { required: ['url'], optional: ['name'] }, - 'navigate': { required: ['target', 'url'], optional: [] }, - 'screenshot': { required: ['scope'], optional: ['target', 'name'] }, - 'list-sessions': { required: [], optional: [] }, - 'search-sessions': { required: ['query'], optional: [] }, - 'lan-info': { required: [], optional: [] }, - 'fresh-send': { required: ['sessionId', 'sessionType', 'provider', 'text'], optional: [] }, - 'health': { required: [], optional: [] }, - 'help': { required: [], optional: [] }, -} +export const ACTION_PARAMS: Readonly> = Object.freeze( + Object.fromEntries(supportedCapabilities.map((capability) => [capability.action, capability.params])), +) const RAW_CODEX_RESUME_HINT = 'Use sessionRef: { provider: "codex", sessionId } after Codex identity is durable.' @@ -409,7 +371,7 @@ function agentResumeProvider(agent: unknown): 'codex' | 'opencode' | undefined { // Action router // --------------------------------------------------------------------------- -const HELP_TEXT = `Freshell MCP tool -- full reference +const LEGACY_HELP_TEXT = `Freshell MCP tool -- full reference ## Decision guide: which action and pane type to use @@ -604,6 +566,35 @@ These tmux action names are supported as aliases: split-window -> split-pane display-message -> display` +const HELP_TEXT = [ + 'Freshell MCP tool -- supported Rust-server reference', + '', + '## Command reference', + ...supportedCapabilities.map((capability) => { + const required = capability.params.required.join(', ') + const optional = capability.params.optional.map((name) => `${name}?`).join(', ') + const parameterText = [required, optional].filter(Boolean).join(', ') || '(none)' + return ` ${capability.action}\tParams: ${parameterText}` + }), + '', + 'capture-pane accepts J and e as Rust-compatible no-op parameters.', + 'wait-for requires a literal pattern; stable, exit, and prompt are unavailable.', + '', + '## Playbook', + 'Use literal: true with send-keys for natural-language prompts.', + 'create, split, and rename without manual UI interaction using new-tab, split-pane, rename-tab, and rename-pane.', + "Playbook: open a URL — use 'open-browser' for a new browser tab.", + '', + '## Screenshot guidance', + 'Use a canary tab and screenshot it after opening a URL.', + '', + '## Gotchas', + 'Freshell has a 50 PTY limit. Picker panes are transient.', + '', + '## tmux aliases', + ...Object.entries(ACTION_ALIASES).map(([alias, action]) => ` ${alias} -> ${action}`), +].join('\n') + function requireParam(params: Record | undefined, name: string): string { const value = params?.[name] if (value === undefined || value === null || value === '') { @@ -623,20 +614,15 @@ export async function executeAction( params?: Record, ): Promise { try { - const unsupported = unsupportedActionResult(action) + const unsupported = unsupportedInvocationResult(action, params) if (unsupported) return unsupported - if (action === 'new-tab' && params?.agent !== undefined && params.agent !== 'opencode') { - return { error: "Only agent 'opencode' is supported with the Rust Freshell server.", hint: 'Use mode for direct Claude or Codex terminals.' } - } - if (action === 'split-pane' && ['agent', 'model', 'effort'].some((key) => params?.[key] !== undefined)) { - return { error: 'Fresh-agent split parameters are unavailable with the Rust Freshell server.', hint: 'Use a supported mode pane instead.' } - } - if (action === 'wait-for' && (!params?.pattern || ['stable', 'exit', 'prompt'].some((key) => params?.[key] !== undefined))) { - return { error: 'wait-for requires pattern with the Rust Freshell server.', hint: 'Use a literal output pattern.' } - } - const paramError = validateParams(action, params) + const canonicalAction = resolveCanonicalAction(action) ?? action + const effectiveParams = action.startsWith('screenshot-') + ? { ...params, scope: action.replace('screenshot-', '') } + : params + const paramError = validateParams(canonicalAction, effectiveParams) if (paramError) return paramError - return await routeAction(action, params) + return await routeAction(canonicalAction, effectiveParams) } catch (err: any) { if (err instanceof MissingParamError) { return { error: err.message, hint: `Run action 'help' to see required parameters for '${action}'.` } @@ -956,37 +942,6 @@ async function routeAction( return HELP_TEXT default: { - // tmux alias resolution (mirrors CLI aliases). - const TMUX_ALIASES: Record = { - 'new-window': 'new-tab', - 'new-session': 'new-tab', - 'list-windows': 'list-tabs', - 'select-window': 'select-tab', - 'kill-window': 'kill-tab', - 'rename-window': 'rename-tab', - 'next-window': 'next-tab', - 'previous-window': 'prev-tab', - 'prev-window': 'prev-tab', - 'split-window': 'split-pane', - 'display-message': 'display', - 'screenshot-pane': 'screenshot', - 'screenshot-tab': 'screenshot', - 'screenshot-view': 'screenshot', - } - const resolved = TMUX_ALIASES[action] - if (resolved) { - // For screenshot aliases, inject scope from the alias name - if (action.startsWith('screenshot-')) { - const scope = action.replace('screenshot-', '') - const mergedParams = { ...params, scope } - const aliasParamError = validateParams(resolved, mergedParams) - if (aliasParamError) return aliasParamError - return routeAction(resolved, mergedParams) - } - const aliasParamError = validateParams(resolved, params) - if (aliasParamError) return aliasParamError - return routeAction(resolved, params) - } return { error: `Unknown action '${action}'. Run action 'help' for available commands.`, hint: 'Valid actions include: new-tab, list-tabs, send-keys, capture-pane, screenshot, help, ...', diff --git a/tools/node-client-runtime/action-capabilities.ts b/tools/node-client-runtime/action-capabilities.ts index 7c67a731f..f2d721567 100644 --- a/tools/node-client-runtime/action-capabilities.ts +++ b/tools/node-client-runtime/action-capabilities.ts @@ -1,49 +1,124 @@ +export type ActionParameters = { + required: readonly string[] + optional: readonly string[] +} + export type ActionCapability = { action: string aliases?: readonly string[] supported: boolean + params: ActionParameters unsupportedHint?: string } const unavailable = 'This action is unavailable with the Rust Freshell server.' +const noParams: ActionParameters = { required: [], optional: [] } +const params = (required: readonly string[] = [], optional: readonly string[] = []): ActionParameters => ({ required, optional }) /** The closed Rust client contract: 33 canonical actions and 14 tmux aliases. */ export const ACTION_CAPABILITIES: readonly ActionCapability[] = [ - { action: 'new-tab', aliases: ['new-window', 'new-session'], supported: true }, - { action: 'list-tabs', aliases: ['list-windows'], supported: true }, - { action: 'select-tab', aliases: ['select-window'], supported: true }, - { action: 'kill-tab', aliases: ['kill-window'], supported: true }, - { action: 'rename-tab', aliases: ['rename-window'], supported: true }, - { action: 'next-tab', aliases: ['next-window'], supported: true }, - { action: 'prev-tab', aliases: ['previous-window', 'prev-window'], supported: true }, - { action: 'split-pane', aliases: ['split-window'], supported: true }, - { action: 'display', aliases: ['display-message'], supported: true }, - { action: 'screenshot', aliases: ['screenshot-pane', 'screenshot-tab', 'screenshot-view'], supported: true }, - { action: 'has-tab', supported: true }, { action: 'list-panes', supported: true }, - { action: 'select-pane', supported: true }, { action: 'rename-pane', supported: true }, - { action: 'kill-pane', supported: true }, { action: 'resize-pane', supported: true }, - { action: 'swap-pane', supported: true }, { action: 'respawn-pane', supported: true }, - { action: 'send-keys', supported: true }, { action: 'capture-pane', supported: true }, - { action: 'wait-for', supported: true }, { action: 'summarize', supported: true }, - { action: 'list-terminals', supported: true }, { action: 'open-browser', supported: true }, - { action: 'navigate', supported: true }, { action: 'list-sessions', supported: true }, - { action: 'search-sessions', supported: true }, { action: 'lan-info', supported: true }, - { action: 'health', supported: true }, { action: 'help', supported: true }, - { action: 'run', supported: false, unsupportedHint: unavailable }, - { action: 'fresh-send', supported: false, unsupportedHint: unavailable }, - { action: 'attach', supported: false, unsupportedHint: unavailable }, -] + { action: 'new-tab', aliases: ['new-window', 'new-session'], supported: true, params: params([], ['name', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'resumeSessionId', 'sessionRef', 'prompt', 'agent', 'model', 'effort']) }, + { action: 'list-tabs', aliases: ['list-windows'], supported: true, params: noParams }, + { action: 'select-tab', aliases: ['select-window'], supported: true, params: params(['target']) }, + { action: 'kill-tab', aliases: ['kill-window'], supported: true, params: params(['target']) }, + { action: 'rename-tab', aliases: ['rename-window'], supported: true, params: params(['name'], ['target']) }, + { action: 'next-tab', aliases: ['next-window'], supported: true, params: noParams }, + { action: 'prev-tab', aliases: ['previous-window', 'prev-window'], supported: true, params: noParams }, + { action: 'split-pane', aliases: ['split-window'], supported: true, params: params([], ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'sessionRef']) }, + { action: 'display', aliases: ['display-message'], supported: true, params: params([], ['target', 'format']) }, + { action: 'screenshot', aliases: ['screenshot-pane', 'screenshot-tab', 'screenshot-view'], supported: true, params: params(['scope'], ['target', 'name']) }, + { action: 'has-tab', supported: true, params: params(['target']) }, + { action: 'list-panes', supported: true, params: params([], ['target']) }, + { action: 'select-pane', supported: true, params: params(['target']) }, + { action: 'rename-pane', supported: true, params: params(['name'], ['target']) }, + { action: 'kill-pane', supported: true, params: params(['target']) }, + { action: 'resize-pane', supported: true, params: params(['target'], ['x', 'y', 'sizes']) }, + { action: 'swap-pane', supported: true, params: params(['target', 'with']) }, + { action: 'respawn-pane', supported: true, params: params(['target'], ['mode', 'shell', 'cwd', 'resume', 'sessionRef']) }, + { action: 'send-keys', supported: true, params: params([], ['target', 'keys', 'literal', 'sessionRef']) }, + // J/e are accepted no-ops by the Rust server and stay part of the public contract. + { action: 'capture-pane', supported: true, params: params([], ['target', 'S', 'J', 'e']) }, + { action: 'wait-for', supported: true, params: params(['pattern'], ['target', 'timeout']) }, + { action: 'summarize', supported: true, params: params([], ['target']) }, + { action: 'list-terminals', supported: true, params: noParams }, + { action: 'open-browser', supported: true, params: params(['url'], ['name']) }, + { action: 'navigate', supported: true, params: params(['target', 'url']) }, + { action: 'list-sessions', supported: true, params: noParams }, + { action: 'search-sessions', supported: true, params: params(['query']) }, + { action: 'lan-info', supported: true, params: noParams }, + { action: 'health', supported: true, params: noParams }, + { action: 'help', supported: true, params: noParams }, + { action: 'run', supported: false, params: params(['command'], ['capture', 'detached', 'timeout', 'name', 'cwd']), unsupportedHint: unavailable }, + { action: 'fresh-send', supported: false, params: params(['sessionId', 'sessionType', 'provider', 'text']), unsupportedHint: unavailable }, + { action: 'attach', supported: false, params: params(['target', 'terminalId'], ['sessionRef']), unsupportedHint: unavailable }, +] as const + +export function validateActionCapabilities(capabilities: readonly ActionCapability[]): void { + const names = new Set() + if (capabilities.length !== 33) throw new Error(`Expected 33 canonical actions, found ${capabilities.length}.`) + for (const capability of capabilities) { + if (!capability.action || typeof capability.supported !== 'boolean' || !capability.params) { + throw new Error('Every action capability must be classified with an action, supported state, and parameters.') + } + for (const name of [capability.action, ...(capability.aliases ?? [])]) { + if (!name) throw new Error(`Action '${capability.action}' contains an empty alias.`) + if (names.has(name)) throw new Error(`Duplicate action or alias '${name}' in capability matrix.`) + names.add(name) + } + const parameterNames = [...capability.params.required, ...capability.params.optional] + if (new Set(parameterNames).size !== parameterNames.length) { + throw new Error(`Action '${capability.action}' classifies a parameter more than once.`) + } + } + const aliasCount = capabilities.reduce((count, capability) => count + (capability.aliases?.length ?? 0), 0) + if (aliasCount !== 14) throw new Error(`Expected 14 aliases, found ${aliasCount}.`) +} + +validateActionCapabilities(ACTION_CAPABILITIES) const byAction = new Map(ACTION_CAPABILITIES.map((capability) => [capability.action, capability])) const byAlias = new Map(ACTION_CAPABILITIES.flatMap((capability) => (capability.aliases ?? []).map((alias) => [alias, capability] as const))) +export const ACTION_ALIASES: Readonly> = Object.freeze(Object.fromEntries( + ACTION_CAPABILITIES.flatMap((capability) => (capability.aliases ?? []).map((alias) => [alias, capability.action])), +)) + export function resolveActionCapability(action: string): ActionCapability | undefined { return byAction.get(action) ?? byAlias.get(action) } +export function resolveCanonicalAction(action: string): string | undefined { + return resolveActionCapability(action)?.action +} + +export function supportedActionCapabilities(): readonly ActionCapability[] { + return ACTION_CAPABILITIES.filter((capability) => capability.supported) +} + export function unsupportedActionResult(action: string): { error: string; hint: string } | undefined { const capability = resolveActionCapability(action) if (capability?.supported !== false) return undefined return { error: `Action '${action}' is unavailable with the Rust Freshell server.`, hint: capability.unsupportedHint ?? unavailable } } + +export function unsupportedInvocationResult( + action: string, + invocation: Record | undefined, +): { error: string; hint: string } | undefined { + const unsupported = unsupportedActionResult(action) + if (unsupported) return unsupported + const canonical = resolveCanonicalAction(action) + if (canonical === 'new-tab' && invocation?.agent !== undefined && invocation.agent !== 'opencode') { + return { error: "Only agent 'opencode' is supported with the Rust Freshell server.", hint: 'Use mode for direct Claude or Codex terminals.' } + } + if (canonical === 'split-pane' && ['agent', 'model', 'effort'].some((key) => invocation?.[key] !== undefined)) { + return { error: 'Fresh-agent split parameters are unavailable with the Rust Freshell server.', hint: 'Use a supported mode pane instead.' } + } + if (canonical === 'wait-for' && ( + typeof invocation?.pattern !== 'string' || invocation.pattern.length === 0 || ['stable', 'exit', 'prompt'].some((key) => invocation?.[key] !== undefined) + )) { + return { error: 'wait-for requires pattern with the Rust Freshell server.', hint: 'Use a literal output pattern.' } + } + return undefined +} diff --git a/usual-sdd/task-002-contract-fix-report.md b/usual-sdd/task-002-contract-fix-report.md new file mode 100644 index 000000000..b894bdfc9 --- /dev/null +++ b/usual-sdd/task-002-contract-fix-report.md @@ -0,0 +1,18 @@ +# Task 2 contract-fix receipt + +## Scope completed + +- Centralized the Node CLI and MCP Rust-server capability contract in `tools/node-client-runtime/action-capabilities.ts`. The checked matrix contains 33 canonical actions and 14 aliases, rejects duplicate/unclassified entries, and is the source for MCP action discovery, help, and parameter validation. +- Rejected Rust-unsupported CLI/MCP invocations before target resolution or HTTP client construction: `run`, `fresh-send`, `attach`, non-OpenCode `new-tab` agents, every fresh-agent `split-pane` option, and invalid `wait-for` conditions. The CLI now emits JSONL stderr diagnostics (`severity`, `event`, `message`) while keeping command output on stdout. +- Made WSL path conversion fail closed for executable and path-valued arguments and config selectors. Spawn errors, non-zero exits, timeout, and empty output now surface as injection failures. +- Aligned the Rust MCP bridge receipt with actual Rust list-panes output and made the MCP QA receipt use the documented dual-role Codex fixture/baseline. + +## Verification receipts + +- `npm run test:vitest -- run test/unit/cli/action-capabilities.test.ts test/unit/mcp/freshell-tool.test.ts --config config/vitest/vitest.config.ts` — 145 passed. +- `npm run test:vitest -- run test/unit/server/mcp/config-writer.test.ts test/unit/server/mcp/config-writer-paths.test.ts --config config/vitest/vitest.server.config.ts` — 65 passed. +- `npm run typecheck:tools && npm run build:tools` — passed. +- `cargo test -p freshell-platform --locked mcp_inject` — 18 passed. +- `FRESHELL_E2E_BACKEND=local npm run test:e2e:local -- --project=rust-chromium test/e2e-browser/specs/cli-rust.spec.ts test/e2e-browser/specs/mcp-bridge-rust.spec.ts test/e2e-browser/specs/mcp-qa-smoke-rust.spec.ts` — 3 passed (31.1s) on owned test ports. + +No port 3001 process was contacted, restarted, or health-checked. From 30f33e8612b7aaa30a62e53f9956a9d96bd2795c Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:35:28 -0700 Subject: [PATCH 12/64] fix(cli): generate supported help from capabilities --- test/unit/cli/action-capabilities.test.ts | 21 ++++++++++++++++ tools/freshell-cli/index.ts | 6 ++++- .../action-capabilities.ts | 23 ++++++++++++++++++ usual-sdd/task-002-help-fix-report.md | 24 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 usual-sdd/task-002-help-fix-report.md diff --git a/test/unit/cli/action-capabilities.test.ts b/test/unit/cli/action-capabilities.test.ts index 52962ed1b..b2d5477f5 100644 --- a/test/unit/cli/action-capabilities.test.ts +++ b/test/unit/cli/action-capabilities.test.ts @@ -46,6 +46,27 @@ describe('standalone CLI capability contract', () => { ])).toThrow('Every action capability must be classified') }) + it('renders supported CLI help from the capability matrix without unsupported actions or variants', async () => { + const result = await runCli(['help'], 'http://127.0.0.1:1') + + expect(result.code).toBe(0) + expect(result.stderr).toBe('') + for (const capability of ACTION_CAPABILITIES.filter((candidate) => candidate.supported)) { + expect(result.stdout).toContain(` ${capability.action}`) + for (const alias of capability.aliases ?? []) expect(result.stdout).toContain(alias) + for (const parameter of [...capability.params.required, ...capability.params.optional]) { + expect(result.stdout).toContain(`--${parameter}`) + } + } + expect(result.stdout).not.toMatch(/^ (?:run|fresh-send|attach)\b/m) + expect(result.stdout).toContain( + ' split-pane (aliases: split-window)\n required: (none)\n optional: --target, --direction, --mode, --shell, --cwd, --browser, --editor, --resume, --sessionRef', + ) + expect(result.stdout).toContain( + ' wait-for\n required: --pattern\n optional: --target, --timeout\n summarize', + ) + }) + it.each([ ['run', 'echo', 'blocked'], ['fresh-send', 'blocked'], diff --git a/tools/freshell-cli/index.ts b/tools/freshell-cli/index.ts index 025420736..48799ce77 100644 --- a/tools/freshell-cli/index.ts +++ b/tools/freshell-cli/index.ts @@ -8,7 +8,7 @@ import { resolveTarget } from './targets.js' import { runCommand as sendKeysCommand } from './commands/sendKeys.js' import { partitionSendKeysArgs } from './send-keys-args.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' -import { resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' +import { renderCliHelp, resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' type Flags = Record @@ -385,6 +385,10 @@ async function main() { } const flags = parsed.flags const args = parsed.args + if (command === 'help') { + writeText(renderCliHelp()) + return + } // Local capability validation deliberately happens before client creation: // unsupported variants must not resolve a target or make any HTTP request. const client = createHttpClient() diff --git a/tools/node-client-runtime/action-capabilities.ts b/tools/node-client-runtime/action-capabilities.ts index f2d721567..917e6920c 100644 --- a/tools/node-client-runtime/action-capabilities.ts +++ b/tools/node-client-runtime/action-capabilities.ts @@ -96,6 +96,29 @@ export function supportedActionCapabilities(): readonly ActionCapability[] { return ACTION_CAPABILITIES.filter((capability) => capability.supported) } +/** Renders the standalone CLI reference from the closed Rust client contract. */ +export function renderCliHelp(capabilities: readonly ActionCapability[] = supportedActionCapabilities()): string { + const actionLines = capabilities.flatMap((capability) => { + const aliases = capability.aliases?.length ? ` (aliases: ${capability.aliases.join(', ')})` : '' + const required = capability.params.required.map((name) => `--${name}`).join(', ') || '(none)' + const optional = capability.params.optional.map((name) => `--${name}`).join(', ') || '(none)' + return [ + ` ${capability.action}${aliases}`, + ` required: ${required}`, + ` optional: ${optional}`, + ] + }) + + return [ + 'Freshell CLI', + '', + 'Usage: freshell [options]', + '', + 'Supported actions:', + ...actionLines, + ].join('\n') +} + export function unsupportedActionResult(action: string): { error: string; hint: string } | undefined { const capability = resolveActionCapability(action) if (capability?.supported !== false) return undefined diff --git a/usual-sdd/task-002-help-fix-report.md b/usual-sdd/task-002-help-fix-report.md new file mode 100644 index 000000000..f4ce90db1 --- /dev/null +++ b/usual-sdd/task-002-help-fix-report.md @@ -0,0 +1,24 @@ +# Task 2 CLI help fix report + +## Scope + +- Added the standalone `freshell help` command. It renders stdout-only help from + `ACTION_CAPABILITIES`, including every supported action's aliases and + required/optional parameters. +- The renderer filters to supported capabilities, so `run`, `fresh-send`, + `attach`, fresh-agent split parameters, and unsupported `wait-for` variants + are not advertised. Rust-supported `capture-pane --J` and `--e` remain + listed. +- Added a process-level CLI regression test covering successful help output, + empty stderr, matrix-derived supported surface, and absent unsupported + actions/variants. + +## TDD and verification + +- RED: `npm run test:vitest -- run test/unit/cli/action-capabilities.test.ts --config config/vitest/vitest.config.ts` failed as expected because `help` exited 1. +- GREEN: `npm run test:vitest -- run test/unit/cli/action-capabilities.test.ts test/unit/mcp/freshell-tool.test.ts --config config/vitest/vitest.config.ts` — 146 passed. +- `npm run typecheck:tools` — passed. +- `npm run build:tools` — passed. +- `NODE_NO_WARNINGS=1 node dist/tools/freshell-cli/index.js help` — exited 0 with empty stderr; an explicit scan confirmed no unsupported action rows or `--stable`/`--exit` variants in its output. + +No Task 3 files were changed. No port 3001 process was contacted, restarted, or health-checked. From b30536e625fa9e4c3d9c11afae65ad355dcfdddf Mon Sep 17 00:00:00 2001 From: "user.email" <3732858+danshapiro@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:55:41 -0700 Subject: [PATCH 13/64] fix: align browser actions with Rust baseline --- src/components/context-menu/menu-defs.ts | 2 - .../fresh-agent/FreshAgentComposer.tsx | 177 +---- .../fresh-agent/FreshAgentDiffPanel.tsx | 114 +-- src/components/fresh-agent/FreshAgentView.tsx | 44 +- src/components/panes/BrowserPane.tsx | 143 +--- src/components/panes/EditorPane.tsx | 33 +- src/components/panes/ExtensionPane.tsx | 204 +---- src/lib/pane-action-registry.ts | 2 - src/lib/rust-baseline-unavailable.ts | 7 + test/e2e-browser/playwright.config.ts | 2 + .../rust-baseline-browser-actions.spec.ts | 64 ++ .../client/components/ExtensionPane.test.tsx | 736 +----------------- .../components/context-menu/menu-defs.test.ts | 13 + .../fresh-agent/FreshAgentComposer.test.tsx | 12 +- .../fresh-agent/FreshAgentDiffPanel.test.tsx | 4 +- .../components/panes/BrowserPane.test.tsx | 15 +- .../panes/EditorPane.openInEditor.test.tsx | 231 +----- usual-sdd/task-003-report.md | 17 + 18 files changed, 212 insertions(+), 1608 deletions(-) create mode 100644 src/lib/rust-baseline-unavailable.ts create mode 100644 test/e2e-browser/specs/rust-baseline-browser-actions.spec.ts create mode 100644 usual-sdd/task-003-report.md diff --git a/src/components/context-menu/menu-defs.ts b/src/components/context-menu/menu-defs.ts index d57be8a2c..44c613a1a 100644 --- a/src/components/context-menu/menu-defs.ts +++ b/src/components/context-menu/menu-defs.ts @@ -583,11 +583,9 @@ export function buildMenuItems(target: ContextTarget, ctx: MenuBuildContext): Me { type: 'item', id: 'editor-paste', label: 'Paste', onSelect: () => editorActions?.paste(), disabled: !editorActions || readOnly }, { type: 'item', id: 'editor-select-all', label: 'Select all', onSelect: () => editorActions?.selectAll(), disabled: !editorActions }, { type: 'separator', id: 'editor-sep' }, - { type: 'item', id: 'editor-open', label: 'Open in external editor', onSelect: () => editorActions?.openInEditor(), disabled: !editorActions }, { type: 'item', id: 'editor-save', label: 'Save now', onSelect: () => editorActions?.saveNow(), disabled: !editorActions || readOnly }, { type: 'item', id: 'editor-toggle-preview', label: 'Toggle preview/source', onSelect: () => editorActions?.togglePreview(), disabled: !editorActions }, { type: 'item', id: 'editor-copy-path', label: 'Copy file path', onSelect: () => editorActions?.copyPath(), disabled: !editorActions }, - { type: 'item', id: 'editor-reveal', label: 'Reveal in file explorer', onSelect: () => editorActions?.revealInExplorer(), disabled: !editorActions }, { type: 'separator', id: 'editor-replace-sep' }, { type: 'item', id: 'replace-pane', label: 'Replace pane', onSelect: () => actions.replacePane(target.tabId, target.paneId) }, ] diff --git a/src/components/fresh-agent/FreshAgentComposer.tsx b/src/components/fresh-agent/FreshAgentComposer.tsx index 49147eae7..964ea575b 100644 --- a/src/components/fresh-agent/FreshAgentComposer.tsx +++ b/src/components/fresh-agent/FreshAgentComposer.tsx @@ -8,22 +8,13 @@ import { useState, type KeyboardEvent, } from 'react' -import { File, Folder, ListStart, Loader2, Paperclip, Send, Square, X } from 'lucide-react' +import { File, Folder, ListStart, Send, Square, X } from 'lucide-react' import { api } from '@/lib/api' -import { getAuthToken } from '@/lib/auth' import { useCoarsePointer } from '@/lib/pointer' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { FreshAgentSessionMenuRow, FreshAgentSlashCommand } from '@shared/fresh-agent-slash-commands' - -export type FreshAgentAttachment = { - /** Server-side saved path, present once uploaded. */ - path?: string - name: string - bytes: number - status: 'uploading' | 'ready' | 'error' - error?: string -} +import { RUST_BASELINE_UNAVAILABLE } from '@/lib/rust-baseline-unavailable' type FreshAgentComposerProps = { disabled?: boolean @@ -34,14 +25,10 @@ type FreshAgentComposerProps = { historyKey?: string /** Working directory used to resolve @ file mentions. */ cwd?: string - /** Runtime provider, used to filter attachment types the model can read natively. */ - provider?: 'claude' | 'codex' | 'opencode' /** Messages queued while the agent is running (owned by the view). */ queuedMessages?: readonly string[] onCancelQueued?: (index: number) => void - onSend?: (value: string, attachmentPaths: string[]) => void - /** `!command` shell escape; absent = feature hidden. */ - onShellCommand?: (command: string) => void + onSend?: (value: string) => void onInterrupt?: () => void canInterrupt?: boolean commands?: FreshAgentSlashCommandMenu @@ -79,29 +66,6 @@ type FileSuggestion = { const HISTORY_LIMIT = 50 const EMPTY_SLASH_COMMAND_MENU: FreshAgentSlashCommandMenu = { action: [], session: [] } -const TEXTUAL_EXTENSIONS = new Set([ - 'txt', 'md', 'markdown', 'csv', 'tsv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'css', - 'js', 'jsx', 'ts', 'tsx', 'py', 'rs', 'go', 'java', 'c', 'cc', 'cpp', 'h', 'hpp', 'sh', 'bash', - 'sql', 'diff', 'patch', 'log', -]) -const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']) - -/** - * Attachments land on disk and are referenced by path, so anything textual is - * readable by every provider's file tools. Native media support is what varies. - */ -export function attachmentRejection(provider: string | undefined, filename: string): string | null { - // Extensionless files (Makefile, Dockerfile, LICENSE…) are treated as text. - if (!filename.includes('.')) return null - const ext = filename.split('.').pop()?.toLowerCase() ?? '' - if (TEXTUAL_EXTENSIONS.has(ext)) return null - if (IMAGE_EXTENSIONS.has(ext)) return null - if (ext === 'pdf') { - return provider === 'claude' ? null : `this model can’t read .pdf — remove it or switch model` - } - return `.${ext} isn’t supported — attach images, PDFs (claude), or text files` -} - function getCommandPrefix(value: string): string | null { if (!value.startsWith('/')) return null const withoutSlash = value.slice(1) @@ -157,38 +121,14 @@ function isTextEntryElement(value: Element | null): boolean { return Boolean(value.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) } -/** - * Raw binary upload. Deliberately NOT base64-in-JSON: the server's global - * express.json caps JSON bodies at 1mb, so attachments ship as - * application/octet-stream (which the JSON parser ignores) with the filename - * in the query string. Auth header matches src/lib/api.ts's request(). - */ -async function uploadAttachment(file: globalThis.File): Promise<{ path: string; bytes: number }> { - const headers = new Headers({ 'Content-Type': 'application/octet-stream' }) - const token = getAuthToken() - if (token) headers.set('x-auth-token', token) - const res = await fetch(`/api/fresh-agent/attachments?name=${encodeURIComponent(file.name)}`, { - method: 'POST', - body: file, - headers, - }) - if (!res.ok) { - const data = await res.json().catch(() => null) as { error?: string; message?: string } | null - throw new Error(data?.error || data?.message || `upload failed (${res.status})`) - } - return res.json() as Promise<{ path: string; bytes: number }> -} - export const FreshAgentComposer = forwardRef(function FreshAgentComposer({ disabled = false, storageKey, historyKey, cwd, - provider, queuedMessages = [], onCancelQueued, onSend, - onShellCommand, onInterrupt, canInterrupt = false, commands = EMPTY_SLASH_COMMAND_MENU, @@ -206,10 +146,9 @@ export const FreshAgentComposer = forwardRef([]) const [historyIndex, setHistoryIndex] = useState(-1) - const [attachments, setAttachments] = useState([]) + const [notice, setNotice] = useState(null) const textareaRef = useRef(null) const filterRef = useRef(null) - const fileInputRef = useRef(null) const historyRef = useRef(readHistory(historyKey)) const completionRequestIdRef = useRef(0) // On touch keyboards Enter inserts a newline; the Send button sends. A @@ -232,7 +171,7 @@ export const FreshAgentComposer = forwardRef getMentionToken(text), [text]) - const isShellInput = onShellCommand !== undefined && text.startsWith('!') + const isShellInput = text.startsWith('!') const activeFilter = menuMode === 'chat' ? (chatPrefix ?? '') : filter.toLowerCase() // Pinned semantics: name-substring only (never description), shared by both // groups. @@ -397,56 +336,20 @@ export const FreshAgentComposer = forwardRef textareaRef.current?.focus()) }, [closeMenu, cwd, mention]) - const addFiles = useCallback((files: Iterable) => { - for (const file of files) { - const rejection = attachmentRejection(provider, file.name) - if (rejection) { - setAttachments((current) => [...current, { - name: file.name, - bytes: file.size, - status: 'error', - error: rejection, - }]) - continue - } - const placeholder: FreshAgentAttachment = { name: file.name, bytes: file.size, status: 'uploading' } - setAttachments((current) => [...current, placeholder]) - void uploadAttachment(file) - .then((result) => { - setAttachments((current) => current.map((entry) => ( - entry === placeholder ? { ...entry, status: 'ready', path: result.path } : entry - ))) - }) - .catch((error: unknown) => { - setAttachments((current) => current.map((entry) => ( - entry === placeholder - ? { ...entry, status: 'error', error: error instanceof Error ? error.message : 'upload failed' } - : entry - ))) - }) - } - }, [provider]) - const sendText = useCallback(() => { const trimmed = text.trim() if (disabled) return if (isShellInput && trimmed.length > 1) { - onShellCommand?.(trimmed.slice(1).trim()) - pushHistory(trimmed) - setText('') - closeMenu() + setNotice(RUST_BASELINE_UNAVAILABLE.shellCommand) return } - const readyAttachments = attachments.filter((entry) => entry.status === 'ready' && entry.path) - if (!trimmed && readyAttachments.length === 0) return - if (attachments.some((entry) => entry.status === 'uploading')) return + if (!trimmed) return if (trimmed.startsWith('/') && executeSlashText(trimmed)) return - onSend?.(trimmed, readyAttachments.map((entry) => entry.path as string)) + onSend?.(trimmed) if (trimmed) pushHistory(trimmed) - setAttachments((current) => current.filter((entry) => entry.status === 'error')) setText('') closeMenu() - }, [attachments, closeMenu, disabled, executeSlashText, isShellInput, onSend, onShellCommand, pushHistory, text]) + }, [closeMenu, disabled, executeSlashText, isShellInput, onSend, pushHistory, text]) const recallHistory = useCallback((direction: 1 | -1): boolean => { const history = historyRef.current @@ -577,11 +480,6 @@ export const FreshAgentComposer = forwardRef event.preventDefault()} - onDrop={(event) => { - event.preventDefault() - if (event.dataTransfer?.files?.length) addFiles(event.dataTransfer.files) - }} > {menuMode && menuLength > 0 ? (
) : null} - {attachments.length > 0 ? ( -
- {attachments.map((attachment, index) => ( - - {attachment.status === 'uploading' ? : null} - {attachment.name} - {attachment.status === 'error' ? — {attachment.error} : null} - - - ))} -
- ) : null} + {notice ?
{notice}
: null}
{ - if (event.clipboardData?.files?.length) { - event.preventDefault() - addFiles(event.clipboardData.files) - } - }} onKeyDown={(event) => { if (handleMenuKeyDown(event)) return if (event.key === 'ArrowUp' && (text === '' || historyIndex >= 0)) { @@ -773,16 +637,6 @@ export const FreshAgentComposer = forwardRef
-
- { - if (event.target.files?.length) addFiles(event.target.files) - event.target.value = '' - }} - />
) diff --git a/src/components/fresh-agent/FreshAgentDiffPanel.tsx b/src/components/fresh-agent/FreshAgentDiffPanel.tsx index 3a4e077c4..3d6f87a3d 100644 --- a/src/components/fresh-agent/FreshAgentDiffPanel.tsx +++ b/src/components/fresh-agent/FreshAgentDiffPanel.tsx @@ -1,119 +1,15 @@ -import { useCallback, useState } from 'react' -import { ChevronRight, MessageSquarePlus } from 'lucide-react' -import { api } from '@/lib/api' -import { cn } from '@/lib/utils' +import { RUST_BASELINE_UNAVAILABLE } from '@/lib/rust-baseline-unavailable' type DiffSummary = { id: string; path?: string; title?: string; status?: string } -function classifyLine(line: string): 'add' | 'del' | 'hunk' | 'meta' | 'ctx' { - if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ') || line.startsWith('index ')) return 'meta' - if (line.startsWith('@@')) return 'hunk' - if (line.startsWith('+')) return 'add' - if (line.startsWith('-')) return 'del' - return 'ctx' -} - -function FreshAgentFileDiff({ - summary, - cwd, - onComment, -}: { - summary: DiffSummary - cwd?: string - onComment?: (text: string) => void -}) { - const [expanded, setExpanded] = useState(false) - const [diff, setDiff] = useState(null) - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - const load = useCallback(() => { - if (!cwd || !summary.path || loading || diff !== null) return - setLoading(true) - void Promise - .resolve(api.get<{ diff: string }>( - `/api/fresh-agent/diff?cwd=${encodeURIComponent(cwd)}&path=${encodeURIComponent(summary.path)}` - )) - .then((result) => setDiff(result?.diff ?? '')) - .catch((err: unknown) => setError(err instanceof Error ? err.message : 'Failed to load diff')) - .finally(() => setLoading(false)) - }, [cwd, diff, loading, summary.path]) - - const label = summary.title ?? summary.path ?? summary.id - const lines = diff !== null && diff.trim() ? diff.split('\n') : null - - return ( -
- - {expanded ? ( -
- {loading ?
Loading diff…
: null} - {error ?
{error}
: null} - {!loading && !error && lines === null && diff !== null ? ( -
No uncommitted changes for this file.
- ) : null} - {lines?.map((line, index) => { - const kind = classifyLine(line) - if (kind === 'meta') return null - return ( -
- {line || ' '} - {onComment && kind !== 'hunk' ? ( - - ) : null} -
- ) - })} -
- ) : null} -
- ) -} /** - * Changed-files panel: summaries come from the thread snapshot; full content - * loads on expand from /api/fresh-agent/diff (git diff in the session cwd). - * Line comments drop a prefilled mention into the composer via onComment. + * Rust sends only summary metadata; it has no endpoint for loading full diffs. */ export function FreshAgentDiffPanel({ diffs, - cwd, - onComment, }: { diffs: DiffSummary[] - cwd?: string - onComment?: (text: string) => void }) { if (diffs.length === 0) return null return ( @@ -121,7 +17,11 @@ export function FreshAgentDiffPanel({
Diffs
{diffs.map((diff) => ( - +
+ {diff.title ?? diff.path ?? diff.id} + {diff.status ? {diff.status} : null} + {RUST_BASELINE_UNAVAILABLE.fullDiff} +
))}
diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 15988c076..3755c6310 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -483,12 +483,6 @@ function readCodexFork(value: unknown): { parentThreadId?: string } | undefined } } -function composeOutgoingText(text: string, attachmentPaths: string[]): string { - if (attachmentPaths.length === 0) return text - const list = attachmentPaths.map((path) => `- ${path}`).join('\n') - return `${text ? `${text}\n\n` : ''}Attached files (read them from disk):\n${list}` -} - function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false return Boolean(target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) @@ -2404,29 +2398,6 @@ export function FreshAgentView({ } }, [pendingApprovalsFromSnapshot, sendFreshAgentMessage]) - /** `!command` shell escape: run via the extras endpoint, then hand the - * command + output to the agent as explicit user-provided context. */ - const runShellCommand = useCallback((command: string) => { - const current = paneContentRef.current - void Promise - .resolve(api.post<{ output: string; exitCode: number | null; truncated: boolean }>( - '/api/fresh-agent/exec', - { command, cwd: current.initialCwd }, - )) - .then((result) => { - const status = result.exitCode === 0 ? '' : ` (exit ${result.exitCode})` - const body = `I ran \`${command}\`${status} in ${current.initialCwd ?? 'the home directory'}. Output:\n\`\`\`\n${result.output || '(no output)'}\n\`\`\`` - if (isBusy) { - setQueuedMessages((queue) => [...queue, body]) - } else { - sendUserText(body) - } - }) - .catch((error: unknown) => { - setNotice(error instanceof Error ? `Shell command failed: ${error.message}` : 'Shell command failed') - }) - }, [isBusy, sendUserText]) - /** Rewind the working tree to the checkpoint taken when a user turn was * sent. Conversation history is untouched — this is the code half of * rewind; fork-from-turn covers the conversation half. */ @@ -2658,8 +2629,6 @@ export function FreshAgentView({ ))} composerRef.current?.insertText(text)} /> { @@ -2720,18 +2687,16 @@ export function FreshAgentView({ onInterrupt={sendInterrupt} commands={slashCommands} onCommand={runSlashCommand} - onShellCommand={runShellCommand} - onSend={(text, attachmentPaths) => { + onSend={(text) => { dispatch(dismissTabGreen(tabId)) if (!paneContent.sessionId || sessionEnded) return if (!canSend && !isBusy) return - const outgoing = composeOutgoingText(text, attachmentPaths) - if (!outgoing) return + if (!text) return if (isBusy) { - setQueuedMessages((queue) => [...queue, outgoing]) + setQueuedMessages((queue) => [...queue, text]) return } - sendUserText(outgoing) + sendUserText(text) }} /> (null) - const [isForwarding, setIsForwarding] = useState(false) - const [forwardError, setForwardError] = useState(null) - const [forwardRetryKey, setForwardRetryKey] = useState(0) + const [baselineUnavailable, setBaselineUnavailable] = useState(false) const currentUrl = history[historyIndex] || '' @@ -209,15 +180,11 @@ export default function BrowserPane({ return } - if (forwardError || loadError) { + if (baselineUnavailable || loadError) { dispatch(setPaneRuntimeActivity({ paneId, source: 'browser', phase: 'error' })) return } - if (isForwarding) { - dispatch(setPaneRuntimeActivity({ paneId, source: 'browser', phase: 'forwarding' })) - return - } if (isLoading) { dispatch(setPaneRuntimeActivity({ paneId, source: 'browser', phase: 'loading' })) @@ -225,7 +192,7 @@ export default function BrowserPane({ } dispatch(setPaneRuntimeActivity({ paneId, source: 'browser', phase: 'idle' })) - }, [currentUrl, dispatch, forwardError, isForwarding, isLoading, loadError, paneId]) + }, [baselineUnavailable, currentUrl, dispatch, isLoading, loadError, paneId]) useEffect(() => () => { dispatch(clearPaneRuntimeActivity({ paneId })) @@ -257,8 +224,7 @@ export default function BrowserPane({ useEffect(() => { if (!currentUrl) { setResolvedSrc(null) - setForwardError(null) - setIsForwarding(false) + setBaselineUnavailable(false) return } @@ -268,53 +234,20 @@ export default function BrowserPane({ const proxyUrl = buildHttpProxyUrl(currentUrl) if (proxyUrl) { setResolvedSrc(proxyUrl) - setForwardError(null) - setIsForwarding(false) + setBaselineUnavailable(false) return } - const forward = needsPortForward(currentUrl) - if (!forward) { - // No forwarding needed - use the URL directly (with file:// conversion) - setResolvedSrc(toIframeSrc(currentUrl)) - setForwardError(null) - setIsForwarding(false) + if (isUnsupportedRemoteLoopback(currentUrl)) { + setResolvedSrc(null) + setBaselineUnavailable(true) + setIsLoading(false) return } - // Request a port forward from the server - let cancelled = false - let forwardedTargetPort: number | null = null - setIsForwarding(true) - setForwardError(null) - setResolvedSrc(null) - setIsLoading(false) - - api - .post<{ forwardedPort: number }>('/api/proxy/forward', { - port: forward.targetPort, - }) - .then((result) => { - if (cancelled) return - forwardedTargetPort = forward.targetPort - setResolvedSrc(buildForwardedUrl(forward.parsed, result.forwardedPort)) - }) - .catch((err) => { - if (cancelled) return - const msg = err instanceof Error ? err.message : String(err) - setForwardError(`Failed to connect to localhost:${forward.targetPort} — ${msg}`) - }) - .finally(() => { - if (!cancelled) setIsForwarding(false) - }) - - return () => { - cancelled = true - if (forwardedTargetPort !== null) { - api.delete(`/api/proxy/forward/${forwardedTargetPort}`).catch(() => {}) - } - } - }, [currentUrl, forwardRetryKey]) + setResolvedSrc(toIframeSrc(currentUrl)) + setBaselineUnavailable(false) + }, [currentUrl]) const navigate = useCallback((newUrl: string) => { if (!newUrl.trim()) return @@ -372,15 +305,7 @@ export default function BrowserPane({ if (!currentUrl) return setLoadError(null) - setForwardError(null) setIsLoading(true) - - if (needsPortForward(currentUrl)) { - setResolvedSrc(null) - setForwardRetryKey((key) => key + 1) - return - } - setResolvedSrc(toIframeSrc(currentUrl)) }, [currentUrl]) @@ -397,7 +322,6 @@ export default function BrowserPane({ try { iframe.contentWindow?.location.reload() setLoadError(null) - setForwardError(null) setIsLoading(true) return } catch { @@ -407,7 +331,6 @@ export default function BrowserPane({ const src = iframe.src || resolvedSrc || toIframeSrc(currentUrl) iframe.src = src setLoadError(null) - setForwardError(null) setIsLoading(true) }, [currentUrl, recoverCurrentPage, resolvedSrc]) @@ -539,20 +462,9 @@ export default function BrowserPane({
{/* iframe */}
- {forwardError ? ( -
-
Failed to connect
-
{forwardError}
- + {baselineUnavailable ? ( +
+ {RUST_BASELINE_UNAVAILABLE.remoteLoopback}
) : loadError ? (
@@ -568,11 +480,6 @@ export default function BrowserPane({ Try Again
- ) : isForwarding ? ( -
- -
Connecting to {currentUrl}...
-
) : resolvedSrc ? (