* 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
754 lines
24 KiB
TypeScript
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
|