Files
hermes-workspace/vite.config.ts
Interstellar-code c021ef5fcc feat(mcp): replace /settings/mcp with full-featured /mcp page (catalog + marketplace + sources) (#231)
* feat(mcp): MCP server management page (Phase 1)

Implements the MCP management plan (.omc/plans/mcp-management.md) Phase 1
end-to-end on a single feature branch (PR1+PR2+PR3+PR4 collapsed):

- New `/mcp` route with capability gate + BackendUnavailableState fallback.
- New `/api/mcp` (GET list, POST create), `/api/mcp/test` (POST connection
  probe), `/api/mcp/discover` (POST tool discovery for a draft config),
  `/api/mcp/configure` (PUT enable/toolMode/include/exclude), and
  `/api/mcp/$name` (DELETE).
- Strict `mcp` capability probe in gateway-capabilities: hits `GET /api/mcp`
  directly and validates the body parses through `normalizeMcpList` —
  dashboard-up-but-route-missing returns false (resolves Open Question #4).
- Type split: read shapes in `src/types/mcp.ts` (client+server), write
  shapes in `src/types/mcp-input.ts` (server-only; secrets contained here).
- Runtime normalization layer `src/server/mcp-normalize.ts` mirrors the
  Skills `asRecord`/`readString`/`normalizeSkill` defense — strips
  unknown fields, coerces enums, masks secrets via `MASK_SENTINEL`,
  re-applies via `maskSecretsInPlace` before every `json(...)`.
- All write endpoints CSRF-checked via `requireJsonContentType`.
- Capability-off responses use `createCapabilityUnavailablePayload('mcp')`
  with `{ servers: [], total: 0, categories }` for GET (200) and 503 for
  writes — feature gates fall open without throwing.
- Static preset catalog (`src/screens/mcp/presets.ts`) with GitHub,
  Filesystem, Postgres, Slack, Linear; Catalog tab installs prefilled
  drafts through the same dialog flow.
- Screens: `McpScreen` (Installed/Catalog/All tabs + search + category
  filter), `McpServerCard` (status badge + Test/Edit/Delete + enable
  toggle), `McpServerDialog` (HTTP/stdio + auth + Discover + Save with
  bearer-token clear-on-submit).
- TanStack Query hooks (`useMcpServers`, `useTestMcpServer`,
  `useDiscoverMcpTools`, `useUpsertMcpServer`, `useConfigureMcpServer`,
  `useDeleteMcpServer`).

Tests (vitest):
- `src/server/mcp-normalize.test.ts` — 13 tests covering enum coercion,
  list-shape variants, malformed-entry drop, presence flags without
  echo, env/header masking by key hint, idempotency, test-result
  normalization, payload-string scanner.
- `src/routes/api/-mcp.test.ts` — 8 tests covering input validation,
  capability fall-open shape, CSRF gate (415 on non-JSON POST, pass on
  JSON, pass on GET), and the **secret echo guard**: a worst-case agent
  that echoes a submitted bearer token in body/env/headers must never
  surface the original string in the workspace response.

Build, lint, and the new test files are clean. Pre-existing unrelated
test failures on `local` (router-route-resolution, context-usage,
markdown math, slash-command-menu, chat-message-list, gateway-capabilities
env-source) are unchanged by this PR.

Worked with Interstellar Code

* fix(mcp): strip secret fields from client-safe McpClientInput

Architect review flagged that `McpClientInput` in `src/types/mcp.ts` (the
file explicitly designated for client+server read shapes with no secrets)
contained `bearerToken` and `oauth.clientSecret`, allowing the browser
bundle to import a secret-bearing type via the dialog component.

Resolves the type-split violation:
- `src/types/mcp.ts`: drop `bearerToken` and `oauth` from `McpClientInput`.
  Now strictly the browser-safe form payload, no secret fields.
- `src/screens/mcp/components/mcp-server-dialog.tsx`: hold `bearerToken`
  in ephemeral component-local `useState<string>` typed inline. Cleared
  on submit and on dialog open. No exported type carries the field.
- `src/screens/mcp/hooks/use-mcp-mutations.ts`: `useUpsertMcpServer`
  accepts `McpClientInput & { bearerToken?: string }` inline at the
  call-site, again with no exported secret-bearing type. Server route
  `parseMcpServerInput` re-validates and forwards to the agent.

The full server-side write shape (`McpServerInput` with secrets) remains
in `src/types/mcp-input.ts`, server-only.

Worked with Interstellar Code

* fix(mcp): block client imports of server-only mcp-input types

Add no-restricted-imports rule scoped to src/screens/** and
src/components/** that blocks importing @/types/mcp-input. That
file may carry unmasked secrets and is server-only — clients should
import McpClientInput from @/types/mcp instead.

Worked with Interstellar Code

* feat(mcp): wire /mcp into all sidebar/nav surfaces

Mirror the existing /skills registration across every nav and
command surface so the MCP screen is reachable from the dashboard
overflow grid, command palette, mobile hamburger drawer, mobile tab
bar, slash menu, search modal quick actions, and workspace shell
(active-tab tracking + mobile page title). Inspector panel gets a
parallel MCP tab that lists configured servers via /api/mcp.

Worked with Interstellar Code

* feat(mcp): catalog tab search, category badges, and nav coverage tests

Catalog tab now reuses the screen's search state to filter presets
by name/description, surfaces an empty-state when no presets match,
and renders each preset as a card with an Official Presets category
badge styled to match the skills-screen design vocabulary.

Tests:
- src/components/-mcp-nav.test.tsx: each modified nav file references
  the /mcp route (or registers an mcp tab id for inspector-panel)
- src/screens/mcp/-presets.test.ts: filtering MCP_PRESETS by query
  narrows results by name and description, returns full catalog for
  empty queries, and returns nothing for unknown queries

Worked with Interstellar Code

* feat(mcp): add MCP entry to chat-sidebar Knowledge group

The primary visible left rail (`chat-sidebar.tsx`) was missed by the
prior nav-coverage commit. Slot MCP between Skills and Profiles in
`knowledgeItems`, mirroring the McpServerIcon used elsewhere.

Worked with Interstellar Code

* feat(mcp): Phase 3 — live tool refresh, OAuth reauth, per-server SSE logs

- `useMcpServers`: enable refetchOnWindowFocus for live state.
- `McpServerCard`: per-card Refresh button (re-runs Test, updates
  discoveredToolsCount), Reauth button when authType === 'oauth' (uses
  new useMcpOAuth hook), Logs button (opens McpLogsDrawer).
- `use-mcp-oauth.ts`: opens auth URL in new tab, polls /api/mcp/test
  every 2s until status === 'connected' or 60s timeout. Returns
  mutation-style { start, isPending, isError, error, data }.
- `mcp-logs-drawer.tsx`: fixed-right slide-in drawer subscribing via
  EventSource to /api/mcp/<name>/logs. Newest-first, max 500 lines,
  auto-scroll, tear down on close (no zombie EventSource).
- `routes/api/mcp/$name.logs.ts`: SSE proxy with auth + capability
  gates. Capability-off → 503. Pattern follows chat-events.ts.
- Tests: 3 new for logs route (input validation, capability-off,
  auth gate); smoke test for useMcpOAuth shape.

Total tests: 24 passing (13 normalize + 8 mcp + 3 logs). Build clean.

Worked with Interstellar Code

* feat(mcp): localhost-only config-fallback transport (Phase 1.5)

Adds an `mcpFallback` capability that lets the workspace perform CRUD on
`config.mcp_servers` via the existing dashboard `/api/config` route when
the agent does not yet expose the new `/api/mcp*` runtime endpoints.

Gated to loopback-only deployments by `isLocalhostDeployment()` (both
URLs loopback AND HOST unset/loopback). Test/Discover/Logs return a
structured "not yet available" payload in fallback mode; the MCP screen
renders an amber banner so the limitation is visible.

Worked with Interstellar Code

* feat(mcp): full catalog + marketplace + sources manager (Phase 2-3.2)

Workspace-only end-to-end MCP catalog + marketplace replacing the static
presets.ts and the upstream /settings/mcp surfaces.

Phase 2 — File-backed catalog:
- assets/mcp-presets.seed.json + ~/.hermes/mcp-presets.json (atomic
  bootstrap via tmp+linkSync, mtime+ino+ctime+size cache, malformed-file
  preservation, schema validation: id regex, transport-specific fields,
  env key regex, https URLs, category allowlist, duplicate-id rejection,
  unknown-field warnings)
- src/server/mcp-input-validate.ts: shared parseMcpServerInput returning
  per-field {path, message} errors; promoted from inline definition
- src/routes/api/mcp/presets.ts GET handler

Phase 3.0 — Federated marketplace:
- src/server/mcp-hub/{cache,trust,index,types}.ts + sources/{mcp-get,
  local-file}.ts: Smithery registry adapter (replaces speculative
  registry.mcp.run NXDOMAIN), ETag/If-Modified-Since with 304 reuse,
  rate-limit handling, parallel Promise.allSettled across sources with
  8s per-source timeout, dedupe by source+id+name, fallback to
  local-file when remote degraded
- Trust hardening: shell metachar reject, transport allowlist, env-key
  regex, control-char + absolute-path attack defenses, inline-exec
  flag detection (-c, -lc, -e for sh/bash/python/node/perl/ruby)
- src/screens/mcp/components/install-confirmation-dialog.tsx: 2-click
  commit with full template preview (command/args/env masked) and
  AbortController on dismiss
- Disk persistence for tool-discovery cache (mcp-tools-cache.ts) +
  hermes-mcp CLI bridge (mcp-cli-bridge.ts) for live test/tool
  enumeration in fallback mode

Phase 3.2 — User-configurable sources:
- ~/.hermes/mcp-hub-sources.json schema (built-ins always present,
  protected from mutation; user can add HTTPS-only generic-json
  sources with trust+format)
- src/routes/api/mcp/hub-sources{,.$id}.ts CRUD with per-process mutex
  (read-modify-write race protection)
- generic-json adapter: SSRF guard (private/loopback/link-local/IPv6
  ULA all rejected after DNS resolution, redirects disabled), 5MB
  response-size cap (streaming read), trust hard-cap at 'community'
  for user-source entries, source field 'user:<id>' for dedupe
- src/screens/mcp/components/sources-manager-dialog.tsx UI

Polish:
- Placeholder detection at install confirmation (inline fill form
  blocks commit until /path/to/, <your-...>, empty *_TOKEN/_KEY/etc
  resolved)
- Test result UX hints when stdio Connection closed + placeholder args
  or http fetch failed + placeholder url
- Env-ref preserved in normalize (${VAR_NAME} no longer masked) +
  Edit dialog diagnostic

UI: Skills-pattern parity for /mcp screen (Tabs + Marketplace tab,
Switch primitive, Button primitives, DialogRoot/Content, primary-*
Tailwind classes matching skills-screen.tsx). Single-row toolbar
(tabs + search + filter). Removed All + Catalog tabs, kept Installed +
Marketplace.

Backend:
- gateway-capabilities probeMcp uses authenticated dashboardFetch
  (Codex MAJOR fix); probeMcpConfigKey + isLocalhostDeployment for
  mcpFallback capability
- routes/mcp.tsx route gate accepts mcp || mcpFallback
- mcp-normalize.ts headers.Authorization + env *_TOKEN/_KEY/_SECRET
  /_AUTH/_APIKEY auth detection upgrades authType to 'bearer'

Removed (replaced by /mcp):
- src/screens/settings/mcp-settings-screen.tsx (759 LOC)
- src/routes/settings/mcp.tsx
- src/routes/api/mcp/{servers,reload}.ts (orphaned endpoints; reload
  posted to gateway 404s)
- src/screens/mcp/presets.ts (static array, replaced by file-backed)
- settings-sidebar MCP nav entries (replaced by main /mcp route)

Tests: 263+ passing across 19+ MCP suites — input-validate, presets-
store, hub-cache/trust/unified-search, sources/{mcp-get,local-file,
generic-json}, hub-sources-store, mcp-tools-cache, ssrf-guard,
marketplace-install-confirmation, marketplace-placeholder-detection,
hub-search/-presets/-hub-sources route tests. Pre-existing 2
gateway-capabilities env-resolution failures unrelated.

Reviewers: Codex critic 4 passes (Phase 2 REJECTED → 8 fixes applied,
Phase 3.0 APPROVED-WITH-CHANGES → 4 fixes, Phase 3.2 REJECTED → 6
fixes including SSRF guard + response-size cap + concurrent-CRUD
mutex + trust cap). Architect approved final pass.

Worked with Interstellar Code
2026-05-03 12:49:28 -04:00

754 lines
24 KiB
TypeScript

import { URL, fileURLToPath } from 'node:url'
import { execSync, spawn } from 'node:child_process'
import type { ChildProcess } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
import net from 'node:net'
import { resolve, dirname } from 'node:path'
import os from 'node:os'
// devtools removed
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// nitro plugin removed (tanstackStart handles server runtime)
import { defineConfig, loadEnv } from 'vite'
import viteTsConfigPaths from 'vite-tsconfig-paths'
// ---------------------------------------------------------------------------
// Hermes Agent auto-start helpers
// ---------------------------------------------------------------------------
/** Resolve the hermes-agent directory using a priority-ordered fallback chain:
* 1. CLAUDE_AGENT_PATH env var (explicit override)
* 2. ../hermes-agent — sibling clone (standard README setup)
* 3. ../../hermes-agent — one level up (monorepo / nested workspace)
* Returns null if none found.
*/
function resolveClaudeAgentDir(env: Record<string, string>): string | null {
const candidates: string[] = []
if (env.CLAUDE_AGENT_PATH?.trim()) {
candidates.push(env.CLAUDE_AGENT_PATH.trim())
}
// Resolve relative to the workspace root (parent of hermes-workspace/)
const workspaceRoot = dirname(resolve('.'))
candidates.push(
resolve(workspaceRoot, 'hermes-agent'), // sibling (old README)
resolve(workspaceRoot, '..', 'hermes-agent'), // one level up
resolve(os.homedir(), '.claude', 'hermes-agent'), // Nous installer default
resolve(os.homedir(), 'hermes-agent'), // ~/hermes-agent
)
for (const candidate of candidates) {
if (existsSync(resolve(candidate, 'webapi'))) return candidate
}
return null
}
/** Find the `claude` CLI binary installed by Nous's installer. */
function resolveClaudeBinary(): string | null {
const candidates = [
resolve(os.homedir(), '.claude', 'bin', 'claude'),
resolve(os.homedir(), '.local', 'bin', 'claude'),
]
for (const c of candidates) {
if (existsSync(c)) return c
}
return null
}
/** Resolve the Python executable to use for Hermes backend startup.
* Prefers .venv/bin/python inside agentDir, falls back to system python3.
*/
function resolveClaudePython(agentDir: string): string {
const venvPython = resolve(agentDir, '.venv', 'bin', 'python')
if (existsSync(venvPython)) return venvPython
// uv creates 'venv' not '.venv' sometimes
const uvVenv = resolve(agentDir, 'venv', 'bin', 'python')
if (existsSync(uvVenv)) return uvVenv
return 'python3'
}
/** Check if hermes-agent health endpoint is responding */
async function isClaudeAgentHealthy(port = 8642): Promise<boolean> {
try {
const r = await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(2000),
})
return r.ok
} catch {
return false
}
}
const config = defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd(), '')
const claudeApiUrl = env.CLAUDE_API_URL?.trim() || 'http://127.0.0.1:8642'
// Hermes Agent auto-start state
let claudeAgentChild: ChildProcess | null = null
let claudeAgentStarted = false
const startClaudeAgent = async () => {
if (claudeAgentStarted) return
// Skip auto-start when CLAUDE_API_URL is explicitly set to a non-local endpoint
const explicitUrl =
env.CLAUDE_API_URL || process.env.CLAUDE_API_URL || claudeApiUrl || ''
if (
explicitUrl &&
explicitUrl !== 'http://127.0.0.1:8642' &&
explicitUrl !== 'http://localhost:8642'
) {
console.log(
`[hermes-agent] Skipping auto-start — using external API: ${explicitUrl}`,
)
claudeAgentStarted = true
return
}
if (await isClaudeAgentHealthy()) {
console.log('[hermes-agent] Already running — reusing existing process')
claudeAgentStarted = true
return
}
const claudeBin = resolveClaudeBinary()
const agentDir = resolveClaudeAgentDir(env)
// Prefer the `hermes gateway run` binary path (Nous installer's canonical
// entrypoint). Fall back to launching uvicorn against the source tree if
// only a directory is present (dev / cloned-in-place setups).
let launchCmd: string
let commandArgs: string[]
let launchCwd: string | undefined
if (claudeBin) {
launchCmd = claudeBin
commandArgs = ['gateway', 'run']
launchCwd = agentDir ?? undefined
console.log(`[hermes-agent] Starting ${claudeBin} gateway run`)
} else if (agentDir) {
launchCmd = resolveClaudePython(agentDir)
const useGatewayRun = existsSync(resolve(agentDir, 'gateway', 'run.py'))
commandArgs = useGatewayRun
? ['-m', 'gateway.run']
: ['-m', 'uvicorn', 'webapi.app:app', '--host', '0.0.0.0', '--port', '8642']
launchCwd = agentDir
console.log(
`[hermes-agent] Starting from ${agentDir} using ${launchCmd} (${useGatewayRun ? 'gateway.run' : 'uvicorn'})`,
)
} else {
console.warn(
'[hermes-agent] Could not find hermes-agent installation.\n' +
' Run the installer:\n' +
' curl -fsSL https://hermes-workspace.com/install.sh | bash\n' +
' Or set CLAUDE_AGENT_PATH in .env to point at your hermes-agent clone.',
)
return
}
const child = spawn(
launchCmd,
commandArgs,
{
cwd: launchCwd,
detached: false, // keep tied to vite process — stops when dev server stops
stdio: 'pipe',
env: {
...process.env,
PATH: [
resolve(os.homedir(), '.claude', 'bin'),
resolve(os.homedir(), '.local', 'bin'),
agentDir ? resolve(agentDir, '.venv', 'bin') : '',
agentDir ? resolve(agentDir, 'venv', 'bin') : '',
process.env.PATH || '',
]
.filter(Boolean)
.join(':'),
},
},
)
claudeAgentChild = child
claudeAgentStarted = true
child.stdout?.on('data', (d: Buffer) => {
const line = d.toString().trim()
if (line) console.log(`[hermes-agent] ${line}`)
})
child.stderr?.on('data', (d: Buffer) => {
const line = d.toString().trim()
if (line) console.log(`[hermes-agent] ${line}`)
})
child.on('exit', (code) => {
claudeAgentChild = null
claudeAgentStarted = false
if (code !== 0 && code !== null) {
console.warn(`[hermes-agent] Exited with code ${code}`)
}
})
// Wait for healthy
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 1000))
if (await isClaudeAgentHealthy()) {
console.log('[hermes-agent] ✓ Ready on http://127.0.0.1:8642')
return
}
}
console.warn(
'[hermes-agent] Started but health check timed out — may still be loading',
)
}
let workspaceDaemonStarted = false
let workspaceDaemonStarting = false
let workspaceDaemonShuttingDown = false
let workspaceDaemonRestarting = false
let workspaceDaemonChild: ChildProcess | null = null
let workspaceDaemonRetryCount = 0
const workspaceDaemonPort = '3099'
const daemonCwd = resolve('workspace-daemon')
const daemonSrcEntry = resolve('workspace-daemon/src/server.ts')
const daemonDistEntry = resolve('workspace-daemon/dist/server.js')
const workspaceDaemonDbPath = resolve(
'workspace-daemon/.workspaces/workspace.db',
)
const getWorkspaceDaemonDelayMs = (attempt: number) =>
Math.min(1000 * 2 ** Math.max(attempt - 1, 0), 30000)
const startWorkspaceDaemon = () => {
if (workspaceDaemonShuttingDown) return
if (workspaceDaemonStarted || workspaceDaemonStarting) return
const spawnCommand = existsSync(daemonSrcEntry)
? {
commandName: 'npx',
args: ['tsx', 'watch', 'src/server.ts'],
options: {
cwd: daemonCwd,
env: {
...process.env,
PORT: workspaceDaemonPort,
DB_PATH: workspaceDaemonDbPath,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '',
},
stdio: 'inherit' as const,
},
}
: existsSync(daemonDistEntry)
? {
commandName: 'node',
args: ['dist/server.js'],
options: {
cwd: daemonCwd,
env: {
...process.env,
PORT: workspaceDaemonPort,
DB_PATH: workspaceDaemonDbPath,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '',
},
stdio: 'inherit' as const,
},
}
: null
if (!spawnCommand) {
workspaceDaemonStarting = false
console.error('[workspace-daemon] no server entry found to spawn.')
return
}
workspaceDaemonStarted = true
workspaceDaemonStarting = false
const child = spawn(
spawnCommand.commandName,
spawnCommand.args,
spawnCommand.options,
)
workspaceDaemonChild = child
child.on('exit', (code) => {
if (workspaceDaemonChild === child) {
workspaceDaemonChild = null
}
if (workspaceDaemonShuttingDown || workspaceDaemonRestarting) {
workspaceDaemonStarted = false
workspaceDaemonStarting = false
return
}
if (code === 0) {
workspaceDaemonStarted = false
workspaceDaemonStarting = false
return
}
if (workspaceDaemonRetryCount >= 20) {
workspaceDaemonStarted = false
workspaceDaemonStarting = false
console.error(
`[workspace-daemon] crashed with code ${code ?? 'unknown'}; max restart attempts reached.`,
)
return
}
workspaceDaemonRetryCount += 1
const delayMs = getWorkspaceDaemonDelayMs(workspaceDaemonRetryCount)
console.error(
`[workspace-daemon] crashed with code ${code ?? 'unknown'}; restarting in ${Math.round(
delayMs / 1000,
)}s (${workspaceDaemonRetryCount}/20).`,
)
workspaceDaemonStarting = true
workspaceDaemonStarted = false
setTimeout(() => {
startWorkspaceDaemon()
}, delayMs)
})
child.on('error', (error) => {
console.error(`[workspace-daemon] failed to spawn: ${error.message}`)
})
}
const stopWorkspaceDaemon = async () => {
const child = workspaceDaemonChild
if (!child) {
workspaceDaemonStarted = false
workspaceDaemonStarting = false
return
}
workspaceDaemonRestarting = true
await new Promise<void>((resolve) => {
const exitTimer = setTimeout(() => {
if (!child.killed && child.pid) {
try {
process.kill(child.pid, 'SIGKILL')
} catch {
// ignore
}
}
}, 5000)
child.once('exit', () => {
clearTimeout(exitTimer)
resolve()
})
if (child.pid) {
try {
process.kill(child.pid, 'SIGTERM')
} catch {
clearTimeout(exitTimer)
resolve()
}
} else {
clearTimeout(exitTimer)
resolve()
}
})
workspaceDaemonStarted = false
workspaceDaemonStarting = false
workspaceDaemonRestarting = false
}
const restartWorkspaceDaemon = async () => {
workspaceDaemonRetryCount = 0
await stopWorkspaceDaemon()
workspaceDaemonStarted = false
workspaceDaemonStarting = false
startWorkspaceDaemon()
}
const isPortInUse = (port: number) =>
new Promise<boolean>((resolvePortCheck) => {
const socket = net.createConnection({ port, host: '127.0.0.1' })
socket.once('connect', () => {
socket.destroy()
resolvePortCheck(true)
})
socket.once('error', () => resolvePortCheck(false))
})
const hasHealthyWorkspaceDaemon = async () => {
try {
const response = await fetch(
`http://127.0.0.1:${workspaceDaemonPort}/api/workspace/version`,
{
signal: AbortSignal.timeout(2000),
},
)
return response.ok
} catch {
return false
}
}
// Allow access from Tailscale, LAN, or custom domains via env var
// e.g. CLAUDE_ALLOWED_HOSTS=my-server.tail1234.ts.net,192.168.1.50
const _allowedHosts: string[] | true = env.CLAUDE_ALLOWED_HOSTS?.trim()
? env
.CLAUDE_ALLOWED_HOSTS!.split(',')
.map((h) => h.trim())
.filter(Boolean)
: ['.ts.net'] // allow all Tailscale hostnames by default
let proxyTarget = 'http://127.0.0.1:18789'
try {
const parsed = new URL(claudeApiUrl)
parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:'
parsed.pathname = ''
proxyTarget = parsed.toString().replace(/\/$/, '')
} catch {
// fallback
}
return {
test: {
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/skills-bundle/**',
'**/.{idea,git,cache,output,temp}/**',
],
// Force vitest to run React through its own transform pipeline so ESM
// `import` and CJS `require('react')` share a single module instance.
// Without this, react-dom sets the dispatcher on its CJS React copy while
// components call hooks on the ESM React copy → null dispatcher → crash.
deps: {
inline: [
'react',
'react-dom',
'@testing-library/react',
'@testing-library/dom',
],
},
},
define: {
// Note: Do NOT set 'process.env': {} here — TanStack Start uses environment-based
// builds where isSsrBuild is unreliable. Blanket process.env replacement breaks
// server-side code in Docker (kills runtime env var access).
// Client-side process.env is handled per-environment below.
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
ssr: {
external: [
'playwright',
'playwright-core',
'playwright-extra',
'puppeteer-extra-plugin-stealth',
],
},
optimizeDeps: {
exclude: [
'playwright',
'playwright-core',
'playwright-extra',
'puppeteer-extra-plugin-stealth',
],
},
server: {
// Force IPv4 — 'localhost' resolves to ::1 (IPv6) on Windows, breaking connectivity
host: '0.0.0.0',
// Port precedence:
// 1. --port CLI flag (wins, but we no longer hardcode it in package.json)
// 2. $PORT env var (for containers, reverse proxies, WhatsApp bridge collisions, etc. — see #96)
// 3. default 3000 (matches README/docs/docker-compose expectations)
port: process.env.PORT ? Number(process.env.PORT) : 3000,
strictPort: false, // allow fallback if port is taken, but log clearly
allowedHosts: true,
proxy: {
// WebSocket proxy: clients connect to /ws-claude on the Hermes Workspace
// server (any IP/port), which internally forwards to the local server.
// This means phone/LAN/Docker users never need to reach port 18789 directly.
'/ws-claude': {
target: proxyTarget,
changeOrigin: false,
ws: true,
rewrite: (path) => path.replace(/^\/ws-claude/, ''),
},
// REST API proxy: API proxy for Hermes backend
'/api/claude-proxy': {
target: proxyTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/claude-proxy/, ''),
},
'/claude-ui': {
target: proxyTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/claude-ui/, ''),
ws: true,
configure: (proxy) => {
proxy.on('proxyRes', (_proxyRes) => {
// Strip iframe-blocking headers so we can embed
delete _proxyRes.headers['x-frame-options']
delete _proxyRes.headers['content-security-policy']
})
},
},
'/workspace-api': {
target: 'http://127.0.0.1:3099',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/workspace-api/, ''),
},
},
},
plugins: [
// devtools(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
{
name: 'workspace-daemon',
buildStart() {
if (command !== 'serve') return
},
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const requestPath = req.url?.split('?')[0]
if (req.method === 'GET' && requestPath === '/api/healthcheck') {
res.statusCode = 200
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: true }))
return
}
// Portable-aware health check — returns ok if any chat backend is available
if (
req.method === 'GET' &&
requestPath === '/api/connection-status'
) {
try {
// Check for enhanced Hermes Agent gateway first (has /api/sessions)
const [modelsRes, sessionsRes] = await Promise.all([
fetch(`${claudeApiUrl}/v1/models`, {
signal: AbortSignal.timeout(3000),
}).catch(() => null),
fetch(`${claudeApiUrl}/api/sessions?limit=1`, {
signal: AbortSignal.timeout(3000),
}).catch(() => null),
])
const hasModels = modelsRes?.ok ?? false
const hasSessions = sessionsRes?.ok ?? false
if (hasModels && hasSessions) {
res.statusCode = 200
res.setHeader('content-type', 'application/json')
res.end(
JSON.stringify({
ok: true,
mode: 'enhanced',
backend: claudeApiUrl,
}),
)
return
}
if (hasModels) {
res.statusCode = 200
res.setHeader('content-type', 'application/json')
res.end(
JSON.stringify({
ok: true,
mode: 'portable',
backend: claudeApiUrl,
}),
)
return
}
// Fall back to /health for full Hermes backends
const healthRes = await fetch(`${claudeApiUrl}/health`, {
signal: AbortSignal.timeout(3000),
})
res.statusCode = healthRes.ok ? 200 : 502
res.setHeader('content-type', 'application/json')
res.end(
JSON.stringify({
ok: healthRes.ok,
mode: 'enhanced',
backend: claudeApiUrl,
}),
)
} catch {
res.statusCode = 502
res.setHeader('content-type', 'application/json')
res.end(
JSON.stringify({
ok: false,
mode: 'disconnected',
backend: claudeApiUrl,
}),
)
}
return
}
if (
req.method !== 'POST' ||
requestPath !== '/api/workspace/daemon/restart'
) {
next()
return
}
try {
await restartWorkspaceDaemon()
res.statusCode = 200
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: true }))
} catch (error) {
res.statusCode = 500
res.setHeader('content-type', 'application/json')
res.end(
JSON.stringify({
error:
error instanceof Error ? error.message : 'Internal error',
}),
)
}
})
// Dev-only: disable Node's default 5-minute request timeout so
// long-running SSE streams (agent runs that go silent for minutes
// during heavy reasoning / tool calls) don't get killed mid-stream
// by the HTTP layer. Heartbeats handle keep-alive at the application
// layer. Production servers should keep their default timeouts to
// avoid slowloris exposure.
if (command === 'serve' && server.httpServer) {
const httpServer = server.httpServer as unknown as {
requestTimeout?: number
headersTimeout?: number
timeout?: number
}
httpServer.requestTimeout = 0
httpServer.headersTimeout = 0
httpServer.timeout = 0
}
server.httpServer?.on('close', () => {
workspaceDaemonShuttingDown = true
workspaceDaemonStarted = false
workspaceDaemonStarting = false
if (workspaceDaemonChild) {
workspaceDaemonChild.kill()
workspaceDaemonChild = null
}
})
// Auto-start hermes-agent when dev server launches
if (command === 'serve') {
void startClaudeAgent()
}
// Shutdown hermes-agent when dev server stops
server.httpServer?.on('close', () => {
if (claudeAgentChild) {
console.log('[hermes-agent] Stopping...')
claudeAgentChild.kill('SIGTERM')
claudeAgentChild = null
claudeAgentStarted = false
}
})
if (
command !== 'serve' ||
workspaceDaemonStarted ||
workspaceDaemonStarting
)
return
workspaceDaemonStarting = true
void (async () => {
const running = await isPortInUse(Number(workspaceDaemonPort))
if (workspaceDaemonStarted) {
workspaceDaemonStarting = false
return
}
if (running) {
const healthy = await hasHealthyWorkspaceDaemon()
if (healthy) {
workspaceDaemonStarting = false
console.log('[workspace-daemon] Reusing existing daemon')
return
}
try {
execSync(
`lsof -ti:${workspaceDaemonPort} | xargs kill -9 2>/dev/null || true`,
)
} catch {
// ignore stale cleanup failures and continue with a fresh spawn
}
}
startWorkspaceDaemon()
})()
},
},
// Client-only: replace process.env references in client bundles
// Server bundles must keep real process.env for Docker runtime env vars
{
name: 'client-process-env',
enforce: 'pre',
transform(code, _id) {
const envName = this.environment?.name
if (envName !== 'client') return null
if (
!code.includes('process.env') &&
!code.includes('process.platform')
)
return null
// Replace specific env vars first, then the generic fallback
let result = code
result = result.replace(
/process\.env\.CLAUDE_API_URL/g,
JSON.stringify(claudeApiUrl),
)
result = result.replace(
/process\.env\.CLAUDE_API_TOKEN/g,
JSON.stringify(env.CLAUDE_API_TOKEN || ''),
)
result = result.replace(
/process\.env\.NODE_ENV/g,
JSON.stringify(mode),
)
result = result.replace(/process\.env/g, '{}')
result = result.replace(/process\.platform/g, '"browser"')
return result
},
},
// Copy pty-helper.py into the server assets directory after build
{
name: 'copy-pty-helper',
closeBundle() {
const src = resolve('src/server/pty-helper.py')
const destDir = resolve('dist/server/assets')
const dest = resolve(destDir, 'pty-helper.py')
if (existsSync(src)) {
mkdirSync(destDir, { recursive: true })
copyFileSync(src, dest)
}
},
},
],
}
})
export default config