From e5e6c8037e729c6546f65d6e6592b9a2bc346082 Mon Sep 17 00:00:00 2001 From: Jainath Ponnala <1083824+jainath@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:47:52 -0400 Subject: [PATCH] feat: MCP server for coding agents (F22) Add an opt-in, loopback-only HTTP MCP server so coding agents manage dev servers through DevHarbor instead of spawning npm/yarn/pnpm in a shell they lose track of. 22 tools: list/add/update/remove apps, add/remove tasks, start/stop/restart apps and individual tasks, read/search logs, inspect ports, env vars, run history, node versions, scan folders, and configure readiness probes. Highlights: - Security: binds 127.0.0.1 only, validates Host/Origin on every request, bearer token by default (sha256 + timingSafeEqual), secret env values masked in responses and redacted from log output. Optional HTTPS with a self-signed localhost certificate generated on first use. - Honest readiness: probe-less tasks report ready: null instead of a vacuous true; a factual listening flag is separate from verified ready; start results carry readinessVerified plus recent output; a new http readiness kind polls /health until a service can actually serve; set_readiness lets an agent configure a probe. Ports are labeled with their owning process and the start path waits for the port set to stabilize. - Monorepos: add_app with workspaceTasks: true registers a repo once with one task per workspace package (shared detection with the UI); add_task / remove_task shape the task set with per-task working dir, dependsOn ordering, and oneShot for migrations/builds. - Live UI: MCP mutations push state:invalidate so the desktop reflects them without a manual reload. Shared AppOps de-duplicates the create/update/remove/scan/search flows between IPC and MCP. Settings gains mcp_enabled/mcp_port/mcp_require_auth/ mcp_https with a Settings -> MCP server section (status, token, client config). Tests: gate, hygiene, resolver, TLS, HTTP readiness, and the full 22-tool catalog under vitest, plus a live Electron E2E harness. Bumps to 1.2.0. --- CHANGELOG.md | 44 +- README.md | 45 + package.json | 6 +- pnpm-lock.yaml | 573 ++++++++ specs/02-data-model.md | 5 + specs/03-features.md | 62 + specs/05-roadmap.md | 5 + specs/07-mcp-server.md | 426 ++++++ specs/PROGRESS.md | 8 +- src/main/index.ts | 13 + src/main/ipc/index.ts | 265 ++-- src/main/mcp/McpService.ts | 395 +++++ src/main/mcp/__tests__/fakeContext.ts | 282 ++++ src/main/mcp/__tests__/gate.test.ts | 64 + src/main/mcp/__tests__/hygiene.test.ts | 52 + src/main/mcp/__tests__/resolve.test.ts | 97 ++ src/main/mcp/__tests__/tls.test.ts | 42 + src/main/mcp/__tests__/tools.test.ts | 637 ++++++++ src/main/mcp/context.ts | 150 ++ src/main/mcp/gate.ts | 68 + src/main/mcp/hygiene.ts | 47 + src/main/mcp/tls.ts | 159 ++ src/main/mcp/token.ts | 80 + src/main/mcp/tools.ts | 1299 +++++++++++++++++ src/main/services/AppOps.ts | 237 +++ src/main/services/AppOrchestrator.ts | 29 + src/main/services/DetectionService.ts | 11 +- src/main/services/LogBuffer.ts | 9 + src/main/services/PortDetector.ts | 85 +- src/main/services/Settings.ts | 30 +- src/main/services/TaskRunner.ts | 11 + .../__tests__/AppOrchestrator.state.test.ts | 39 +- src/main/services/readiness/HttpReadiness.ts | 83 ++ .../readiness/__tests__/HttpReadiness.test.ts | 85 ++ src/main/services/readiness/index.ts | 3 + src/renderer/App.tsx | 43 +- src/renderer/components/EnvEditor.tsx | 24 + src/renderer/components/SettingsDrawer.tsx | 218 ++- src/renderer/components/TaskEditor.tsx | 65 +- src/renderer/components/TaskTabs.tsx | 2 + src/renderer/lib/useDebouncedSave.ts | 15 +- src/renderer/store/store.ts | 83 +- src/shared/ipc.ts | 52 +- src/shared/types.ts | 8 + 44 files changed, 5730 insertions(+), 226 deletions(-) create mode 100644 specs/07-mcp-server.md create mode 100644 src/main/mcp/McpService.ts create mode 100644 src/main/mcp/__tests__/fakeContext.ts create mode 100644 src/main/mcp/__tests__/gate.test.ts create mode 100644 src/main/mcp/__tests__/hygiene.test.ts create mode 100644 src/main/mcp/__tests__/resolve.test.ts create mode 100644 src/main/mcp/__tests__/tls.test.ts create mode 100644 src/main/mcp/__tests__/tools.test.ts create mode 100644 src/main/mcp/context.ts create mode 100644 src/main/mcp/gate.ts create mode 100644 src/main/mcp/hygiene.ts create mode 100644 src/main/mcp/tls.ts create mode 100644 src/main/mcp/token.ts create mode 100644 src/main/mcp/tools.ts create mode 100644 src/main/services/AppOps.ts create mode 100644 src/main/services/readiness/HttpReadiness.ts create mode 100644 src/main/services/readiness/__tests__/HttpReadiness.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a0bf6..8efd959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,47 @@ All notable changes to DevHarbor are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.2.0] - 2026-07-11 + +### Added +- **MCP server (F22)** - an opt-in, localhost-only HTTP server that exposes DevHarbor to MCP + clients (coding agents), so an assistant can manage your dev servers through DevHarbor + rather than running them in its own shell. 22 tools cover listing, adding, updating, and + removing apps; adding and removing tasks; starting, stopping, and restarting apps and + individual tasks; reading and searching logs; inspecting ports, run history, and Node + versions; reading and setting env vars; scanning a folder for projects; and configuring + readiness probes. Off by default. When on, it binds + `127.0.0.1` only, validates the `Host`/`Origin` on every request, and requires a bearer + token by default. Stored secret env values are masked in env responses and redacted from + log output. Configure it under Settings -> MCP server (enable, port, token, and + copy-paste client config). See `specs/07-mcp-server.md`. +- **Monorepos over MCP** - `add_app` accepts `workspaceTasks: true` to register a monorepo + once with one task per workspace package (pnpm/yarn/npm workspaces, same detection as the + UI), each running its own dev script from its own directory; registering a monorepo + without the flag returns the detected packages and says how to get per-service tasks. + New `add_task` and `remove_task` tools let an agent shape the task set itself: per-task + working directory, `dependsOn` ordering (migrate, then api, then web), and `oneShot` for + run-to-completion tasks (defaults to exit-code readiness so dependents wait). +- **MCP over HTTPS** - optional TLS for the MCP server using a self-signed localhost + certificate that DevHarbor generates on first use (Settings -> MCP server -> Use HTTPS; + the certificate path is shown for client trust, e.g. `NODE_EXTRA_CA_CERTS`). +- **Honest readiness over MCP** - a task with no readiness probe reports `ready: null` + (unknown) instead of a vacuous `true`; a factual `listening` flag says whether ports are + open, separate from verified `ready`; start results carry `readinessVerified` plus the + task's recent output, wait for the port set to stabilize so slow-binding siblings in a + monorepo are captured inline, and label every port with the process that owns it. The new + `set_readiness` tool lets an agent configure a port/log/http/delay probe so + `readinessVerified` becomes achievable for slow-booting services. +- **HTTP readiness probes** - a new readiness kind polls a `/health` or `/ready` URL until + it returns success, so a service that opens its port before its database/cache connect is + reported ready only when it can actually serve. Configurable in the task editor and, over + MCP, via `set_readiness`. +- **Live UI updates from MCP** - when the MCP server adds, updates, removes, starts, or stops + an app, the desktop reflects it immediately (previously it needed a manual reload). + +### Changed +- Softened the "auth off" note in Settings -> MCP server: it is now an informational note + sized to a single-user desktop, not a red danger banner. ## [1.1.0] - 2026-06-14 @@ -117,7 +157,7 @@ First public preview. macOS-only (Apple Silicon). - Dashboard control room, folder organization, and a ⌘K command palette. - Local-only storage (SQLite). No accounts, no telemetry. -[Unreleased]: https://github.com/jainath/devharbor/compare/v1.1.0...HEAD +[1.2.0]: https://github.com/jainath/devharbor/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/jainath/devharbor/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/jainath/devharbor/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/jainath/devharbor/compare/v0.1.0...v1.0.0 diff --git a/README.md b/README.md index d5f92b0..8135c3e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,50 @@ Website: [www.devharbor.app](https://www.devharbor.app) **encrypted at rest** (macOS Keychain). Your settings always win over a repo's `.env`. - **Keyboard-first** - ⌘K palette, folders/tags, dashboard with live CPU/memory, per-app auto-start + launch at login, signed/notarized builds with auto-update. +- **MCP server for coding agents** - opt-in, loopback-only HTTP server that lets an AI coding + assistant list, add, start, stop, and inspect your apps *through DevHarbor* instead of + spawning `npm run dev` in a shell it then loses track of. See [MCP server](#mcp-server). + +## MCP server + +DevHarbor doubles as an MCP server, so a coding agent (Claude Code, Cursor, or anything +else that speaks MCP) manages your dev servers through DevHarbor instead of running them +in its own shell. Servers an agent starts this way outlive the agent's session, show up in +the app like everything else, and get logs, port tracking, readiness checks, and clean +shutdown for free. The desktop updates live as the agent works. + +22 tools cover the full surface: list, add, update, and remove apps; add and remove tasks; +start, stop, and restart apps or individual tasks; read and search logs; inspect ports, +run history, and Node versions; read and set env vars; scan a folder for projects to +register; and configure readiness probes so the agent can tell "port is open" apart from +"actually ready to serve". + +Monorepos are first-class: `add_app` with `workspaceTasks: true` registers the repo once +and creates one task per workspace package (pnpm, yarn, or npm workspaces), each running +its own dev script from its own directory. The agent can then start or stop individual +services, wire "API before web" ordering with `add_task` dependencies, give each service +its own health probe, and see which detected port belongs to which service. + +It is off by default. Turn it on under Settings → MCP server, copy the client config it +shows, and drop that into your agent's MCP settings: + +```json +{ + "mcpServers": { + "devharbor": { + "type": "http", + "url": "http://127.0.0.1:6872/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Security posture: binds `127.0.0.1` only (not configurable), requires a bearer token by +default, validates `Host`/`Origin` on every request, masks secret env values in responses, +and redacts them from log output. Optional HTTPS with a locally generated certificate for +clients that want it. Design and full tool list in +[`specs/07-mcp-server.md`](specs/07-mcp-server.md). ## Install @@ -60,6 +104,7 @@ The `specs/` folder is the source of truth for this project. See [`specs/WORKFLO 6. [`specs/03-features.md`](specs/03-features.md) - every feature, with acceptance criteria 7. [`specs/04-ui.md`](specs/04-ui.md) - screens, layout, key interactions 8. [`specs/05-roadmap.md`](specs/05-roadmap.md) - phased delivery plan +9. [`specs/07-mcp-server.md`](specs/07-mcp-server.md) - the MCP server: tools, transport, security ## Stack (as built) diff --git a/package.json b/package.json index 15d9693..47b899b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devharbor", - "version": "1.1.0", + "version": "1.2.0", "private": true, "description": "DevHarbor - a harbor for your local dev servers. Desktop app for managing local Node.js projects on macOS.", "author": "Jainath Ponnala", @@ -38,6 +38,7 @@ }, "dependencies": { "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", + "@modelcontextprotocol/sdk": "^1.29.0", "better-sqlite3": "^11.5.0", "chokidar": "^5.0.0", "electron-updater": "^6.8.3", @@ -46,7 +47,8 @@ "pidusage": "^4.0.1", "semver": "^7.8.1", "tree-kill": "^1.2.2", - "ulid": "^2.3.0" + "ulid": "^2.3.0", + "zod": "^3.25.76" }, "devDependencies": { "@electron/fuses": "^1.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c65f4f..f34258c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@homebridge/node-pty-prebuilt-multiarch': specifier: ^0.13.1 version: 0.13.1 + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) better-sqlite3: specifier: ^11.5.0 version: 11.10.0 @@ -38,6 +41,9 @@ importers: ulid: specifier: ^2.3.0 version: 2.4.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@electron/fuses': specifier: ^1.8.0 @@ -491,6 +497,12 @@ packages: resolution: {integrity: sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==} engines: {node: '>=18.0.0 <25.0.0'} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -539,6 +551,16 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1104,6 +1126,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1130,6 +1156,14 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -1138,6 +1172,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + anser@2.3.5: resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} @@ -1271,6 +1308,10 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -1314,6 +1355,10 @@ packages: builder-util@25.1.7: resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1334,6 +1379,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1459,15 +1508,39 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -1538,6 +1611,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1581,6 +1658,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -1625,6 +1705,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -1669,6 +1753,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1728,6 +1815,18 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -1739,6 +1838,16 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -1761,6 +1870,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1790,6 +1902,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1809,9 +1925,17 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1945,6 +2069,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hono@4.12.28: + resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} + engines: {node: '>=16.9.0'} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -1952,6 +2080,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -1984,6 +2116,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2024,6 +2160,10 @@ packages: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -2059,6 +2199,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2089,6 +2232,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2107,6 +2253,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2226,6 +2378,14 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2238,10 +2398,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -2342,6 +2510,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-abi@3.92.0: resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} @@ -2390,10 +2562,18 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2435,6 +2615,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2454,6 +2638,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -2491,6 +2678,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -2580,6 +2771,10 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -2587,6 +2782,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2594,6 +2793,14 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -2676,6 +2883,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -2721,6 +2932,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2760,13 +2975,24 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2775,6 +3001,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2836,6 +3078,10 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -2952,6 +3198,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2982,6 +3232,10 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript-eslint@8.61.0: resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3017,6 +3271,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3052,6 +3310,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -3181,6 +3443,14 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@5.0.13: resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} engines: {node: '>=12.20.0'} @@ -3569,6 +3839,10 @@ snapshots: node-addon-api: 7.1.1 prebuild-install: 7.1.3 + '@hono/node-server@1.19.14(hono@4.12.28)': + dependencies: + hono: 4.12.28 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3626,6 +3900,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.28) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.28 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4152,6 +4448,11 @@ snapshots: abbrev@1.1.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -4175,6 +4476,10 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: ajv: 6.15.0 @@ -4186,6 +4491,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + anser@2.3.5: {} ansi-regex@5.0.1: {} @@ -4354,6 +4666,20 @@ snapshots: bluebird@3.7.2: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolean@3.2.0: optional: true @@ -4426,6 +4752,8 @@ snapshots: transitivePeerDependencies: - supports-color + bytes@3.1.2: {} + cac@6.7.14: {} cacache@16.1.3: @@ -4468,6 +4796,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camelcase-css@2.0.1: {} @@ -4591,13 +4924,28 @@ snapshots: console-control-strings@1.1.0: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-util-is@1.0.2: optional: true core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -4658,6 +5006,8 @@ snapshots: delegates@1.0.0: {} + depd@2.0.0: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -4715,6 +5065,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -4798,6 +5150,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -4859,6 +5213,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-plugin-react-hooks@5.2.0(eslint@9.39.4(jiti@1.21.7)): @@ -4939,12 +5295,58 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expand-template@2.0.3: {} expect-type@1.3.0: {} exponential-backoff@3.1.3: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -4972,6 +5374,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.3: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4998,6 +5402,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5023,8 +5438,12 @@ snapshots: hasown: 2.0.3 mime-types: 2.1.35 + forwarded@0.2.0: {} + fraction.js@5.3.4: {} + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -5193,12 +5612,22 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.12.28: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.1 @@ -5247,6 +5676,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -5275,6 +5708,8 @@ snapshots: ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -5301,6 +5736,8 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + is-unicode-supported@0.1.0: {} isarray@1.0.0: {} @@ -5325,6 +5762,8 @@ snapshots: jiti@1.21.7: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -5337,6 +5776,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -5457,6 +5900,10 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -5466,10 +5913,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} mimic-fn@2.1.0: {} @@ -5553,6 +6006,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + node-abi@3.92.0: dependencies: semver: 7.8.1 @@ -5604,9 +6059,15 @@ snapshots: object-hash@3.0.0: {} + object-inspect@1.13.4: {} + object-keys@1.1.1: optional: true + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5658,6 +6119,8 @@ snapshots: dependencies: callsites: 3.1.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5671,6 +6134,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-to-regexp@8.4.2: {} + pathe@1.1.2: {} pathval@2.0.1: {} @@ -5693,6 +6158,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.10 @@ -5768,6 +6235,11 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -5775,10 +6247,24 @@ snapshots: punycode@2.3.1: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -5868,6 +6354,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -5941,6 +6429,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5970,19 +6468,74 @@ snapshots: semver@7.8.1: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 optional: true + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -6043,6 +6596,8 @@ snapshots: stat-mode@1.0.0: {} + statuses@2.0.2: {} + std-env@3.10.0: {} string-width@4.2.3: @@ -6193,6 +6748,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} truncate-utf8-bytes@1.0.2: @@ -6218,6 +6775,12 @@ snapshots: type-fest@0.13.1: optional: true + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript-eslint@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) @@ -6247,6 +6810,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -6276,6 +6841,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -6411,6 +6978,12 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + zustand@5.0.13(@types/react@18.3.29)(react@18.3.1): optionalDependencies: '@types/react': 18.3.29 diff --git a/specs/02-data-model.md b/specs/02-data-model.md index 53af43e..c386e33 100644 --- a/specs/02-data-model.md +++ b/specs/02-data-model.md @@ -235,6 +235,7 @@ export type ReadinessSignal = | { kind: 'none' } | { kind: 'port'; port: number; host?: string } | { kind: 'log'; regex: string; flags?: string } + | { kind: 'http'; url: string; status?: number } // ready when GET url returns status (default 2xx/3xx) | { kind: 'exit'; code?: number } | { kind: 'delay'; ms: number }; @@ -267,6 +268,10 @@ export interface RunningTask { pid: number; state: ProcessState; // task-level state ready: boolean; // has the readiness signal fired? + readinessKind: ReadinessSignal['kind']; // which probe fired it - lets consumers (MCP + // honesty reporting) tell a verified ready from the vacuous + // ready of a 'none' task, even if the stored config was + // edited after start startedAt: number; command: string; nodeVersion: string; diff --git a/specs/03-features.md b/specs/03-features.md index cdb15f1..9f465e6 100644 --- a/specs/03-features.md +++ b/specs/03-features.md @@ -381,6 +381,12 @@ This is the foundation for: monorepos (one task per workspace), single-repo mult - `none` - proceed immediately after spawn (default, current Phase 1 behaviour). - `port:N` - wait until the task's process group is listening on port `N`. Implementation: `lsof -iTCP -sTCP:LISTEN -P -n` filtered by PID group, polled every 500ms. - `log:/regex/` - wait until a stdout chunk matches. Useful for "ready in Xms" banners or anything not bound to a port. + - `http:url` - poll `GET url` (~700ms interval) until it returns the expected status + (default: any 2xx/3xx). The right probe for a service that opens its port before it + can actually serve (DB/cache still connecting) - point it at `/health` or `/ready`. + `https` URLs to loopback hosts skip certificate validation (local self-signed dev + certs); non-loopback URLs validate normally. Resolves not-ready if the task exits + first. Configurable in the task editor and over MCP via `set_readiness`. - `exit:0` - task is `oneShot`; ready means it exited with the expected code. Required for any `oneShot` task. - `delay:N ms` - fixed timer, last-resort fallback. - **Dependency graph** - `dependsOn` is a DAG. Cycles rejected at save time with a clear error citing the cycle. Tasks at the same topological level start in parallel, in `position` order for UI determinism. @@ -474,6 +480,62 @@ This is the foundation for: monorepos (one task per workspace), single-repo mult --- +## F22. MCP server for coding agents *(post-v1)* + +**Summary.** An opt-in, localhost-only HTTP server that exposes DevHarbor to MCP (Model +Context Protocol) clients, so a coding agent can register and operate the user's dev servers +*through DevHarbor* instead of spawning `npm run dev` in a shell it then loses track of. The +full design (architecture, transport, security, the 22-tool catalog) lives in +[`07-mcp-server.md`](07-mcp-server.md); this is the feature-level summary. + +**Behaviour.** + +- **Off by default.** Enabled under Settings -> MCP server, which also shows live status, + the port, the auth token, and a copy-paste client config block. +- **Loopback only.** Binds `127.0.0.1:` (default 6872). Every request must carry a + loopback `Host` (exact match, blocks DNS-rebinding) and, if present, a loopback `Origin`. +- **Bearer token by default.** A 32-byte token (encrypted at rest via the same Keychain path + as env secrets) is required on every request; comparison is constant-time. Auth can be + turned off for clients that cannot send headers, with a clear warning that any local + process could then run commands as the user. +- **Stateless Streamable HTTP.** A fresh MCP server is built per request; no sessions. +- **22 tools.** Read (overview, list/get apps, tasks, running, logs, search, ports, env + vars, node versions, folder scan) and mutate (add/update/remove apps, add/remove tasks, + start/stop/restart apps and tasks, set env var, set readiness probe). Apps are addressed + by id, exact name, or absolute path. +- **Monorepos.** `add_app` with `workspaceTasks: true` creates one task per detected + workspace package (same detection as the UI's add flow); `add_task`/`remove_task` let an + agent shape the task set - per-task working directory, `dependsOn` ordering, `oneShot` + for migrations/builds - so a monorepo is one app with per-service tasks, not N apps. +- **Secrets protected.** Stored secret env values are masked in env responses and redacted + from any log-bearing response. +- **Honest readiness.** A task without a readiness probe reports `ready: null`, never a + vacuous `true`; a factual `listening` flag reports open ports separately from verified + readiness; start results carry a tri-state `readinessVerified` plus the task's recent + output so the caller can judge for itself; and the `set_readiness` tool lets an agent + configure a probe (port / log / http / delay) so verification becomes achievable. +- **Ports, labeled and on time.** Start results wait briefly for port detection instead of + returning empty, and each detected port is labeled with the owning process - which port + is which service in a monorepo task that fans out into several servers. +- **Optional HTTPS.** `mcp_https` serves the endpoint over TLS with a self-signed + localhost certificate generated on first use; certificate problems fail closed. +- **Clean lifecycle.** The listener starts on boot when enabled, restarts on a port change, + and closes before running tasks are torn down on quit so an in-flight tool call can never + spawn a process that outlives the app. + +**Acceptance.** + +- Enabling the server in Settings shows "Running at http://127.0.0.1:6872/mcp"; disabling it + stops the listener. +- A request with `Host: evil.com` is rejected 403; a `GET /mcp` is 405; a request with no or + a wrong bearer token is 401. +- An agent calls `add_app` with a project path, then `start_app`, and the app runs under + DevHarbor with logs and ports visible in the UI - no shell `npm run dev` involved. +- `get_logs` on an app whose dev server printed a secret env value returns that value + redacted. + +--- + ## Stretch (post-v1, not in this spec set) - Docker Compose integration (treat services as pseudo-apps). diff --git a/specs/05-roadmap.md b/specs/05-roadmap.md index ceba190..5424251 100644 --- a/specs/05-roadmap.md +++ b/specs/05-roadmap.md @@ -105,6 +105,11 @@ Why now (not later): Phase 2 is logs polish (xterm addons, ring buffer, run hist - Download DMG → drag to Applications → first launch passes Gatekeeper with no warnings. - App auto-updates from v0.1.0 → v0.1.1 with no user intervention beyond a quit & relaunch. +## Post-v1, shipped + +- **MCP server (F22)** - opt-in localhost HTTP server exposing DevHarbor to coding agents. + See [`07-mcp-server.md`](07-mcp-server.md). + ## Stretch - post-v1 Tracked but not in v1 scope: diff --git a/specs/07-mcp-server.md b/specs/07-mcp-server.md new file mode 100644 index 0000000..5b900d6 --- /dev/null +++ b/specs/07-mcp-server.md @@ -0,0 +1,426 @@ +# 07 - MCP server (F22) + +DevHarbor exposes a local HTTP MCP (Model Context Protocol) server so coding agents and +LLM-powered tools can manage dev servers through DevHarbor instead of spawning `npm run dev` +in a shell they then lose track of. The agent gets structured tools; the user keeps one +place where every process is visible, logged, port-tracked, and cleanly stoppable. + +The contract, stated in the server instructions every client receives on initialize and +reinforced in the lifecycle tool descriptions: **agents never run project dev servers +directly - they register and control them through DevHarbor.** + +## Goals + +1. A configurable, localhost-only, token-protected HTTP MCP server inside the main process. +2. A complete tool catalog: list/add/inspect/update/remove apps, start/stop/restart apps + and tasks, read and search logs, ports, env vars, run history, node versions. +3. An honestly stated, tightly gated attack surface: off by default, bearer-token auth on + by default, Host/Origin validated, stored secret values masked and redacted from log + output. Stated plainly: the token is an execute-code-as-you credential (register a path, + start its scripts), so protecting it IS the security model. +4. First-class setup UX: a Settings section with live status, token management, and + copy-paste client config. + +## Non-goals + +- Remote (non-loopback) access. The listener binds `127.0.0.1` only, not configurable. +- MCP resources/prompts, notifications, or SSE streaming. Tools only, stateless transport. + (Streaming log subscriptions are a possible later addition; see Future work.) +- Writing secret values back to agents. Secrets stay write-only through MCP. +- Windows/Linux differences: same code path, macOS is the shipping target as everywhere else. + +## Architecture + +New module `src/main/mcp/`: + +| File | Responsibility | +|---|---| +| `McpService.ts` | Lifecycle: `start()`, `stop()`, `applySettings()`. Owns the `node:http` server bound to `127.0.0.1:`, request gatekeeping (path, Host, Origin, auth), per-request MCP server construction, status snapshot + `onStatus` event for the UI. | +| `tools.ts` | `registerTools(server, ctx)` - the whole tool catalog against the injected service context. | +| `gate.ts` | Pure gatekeeping: exact-loopback Host/Origin checks, bearer parse + sha256/timingSafeEqual compare. No electron/db imports, so it unit-tests under plain node. | +| `hygiene.ts` | Pure log hygiene: ANSI strip (CSI + OSC), secret redaction, tail. Same import discipline. | +| `token.ts` | Bearer-token persistence only: create/load/rotate in the `settings` table under `mcp_token`, encrypted with `safeStorage` using the same `enc1:` prefix convention as EnvStore. Comparison logic lives in gate.ts, deliberately. | +| `tls.ts` | HTTPS material for `mcp_https`: loads or generates a self-signed localhost certificate (SAN: `localhost`, `127.0.0.1`, `::1`, RSA 2048, 10 years) under `userData/mcp-tls/` via the system `openssl`, using a config file rather than `-addext` so older LibreSSL works. Key file chmod 600. Pure fs + child_process - the directory is injected, so it unit-tests under plain node. | + +Dependency: `@modelcontextprotocol/sdk` (1.29.x) + `zod` (3.25.x) in `dependencies` +(main-process externalized, shipped in the asar like better-sqlite3 and friends). + +### Transport: stateless Streamable HTTP + +Per request: McpService buffers the request stream ITSELF, counting actual bytes received +(never trusting Content-Length - chunked encoding can lie); on overflow past 4 MB it +responds 413 and destroys the socket, on JSON parse failure 400. SDK 1.29's own body read +is a bare `req.json()` with no size limit, so the cap only exists if we do this. The +pre-parsed body then goes to a fresh `McpServer` + `StreamableHTTPServerTransport` with +`sessionIdGenerator: undefined` and `enableJsonResponse: true` via +`handleRequest(req, res, body)`. No session table, no cleanup races, and a client that +reconnects after an app restart just keeps working. Tool registration per request is a few +object allocations; the underlying services are singletons. + +**Response-time budget.** The SDK client's default request timeout is 60 s, and +`readiness_timeout_ms` defaults to 60 s PER topo level - so awaiting full readiness can +deterministically outlive the client. `start_app`/`restart_app`/`start_task(wait)` therefore +await readiness up to a 45 s budget; if the app is still coming up they return a non-error +"still starting" result with per-task states and ports so far, plus an instruction to poll +`get_app`. The underlying orchestrator operation keeps running - the per-app lock keeps it +coherent. The fast-failure path (crash, compile error) stays well inside the budget and +returns the log tail as designed. + +- `POST /mcp` - the MCP endpoint. +- `GET /healthz` - unauthenticated `{ ok: true }` liveness probe (leaks nothing but presence). +- `GET|DELETE /mcp` - 405 (stateless mode has no server-push stream to offer). +- Anything else - 404. + +### HTTPS mode + +`mcp_https` (default off) serves the same endpoint over TLS. On first enable, `tls.ts` +generates a self-signed certificate for `localhost`/`127.0.0.1`/`::1` under +`userData/mcp-tls/` and the listener becomes `https.createServer` with it; the status URL, +Settings UI, and client-config snippet switch to `https://`. Clients must trust the +certificate (the UI shows its path; e.g. `NODE_EXTRA_CA_CERTS` for Node-based clients, or +add it to the login keychain). Certificate setup failure fails CLOSED: the listener does +not fall back to plain HTTP - it stays down with `lastError` explaining why. Honest scope +note: on a single-user loopback the practical gain is wire encryption against local +packet capture and client policies that require `https://` URLs; the bearer token remains +the real access control either way. Toggling `mcp_https` bounces the listener (like a +port change). + +### Readiness honesty + +A task with `readiness: { kind: 'none' }` has NO probe - the runner's internal `ready` +flag is vacuously true the moment it spawns, which field feedback showed reads as a false +positive (the process was up while its backend reported not-ready for several more +seconds). The MCP surface therefore never presents unprobed readiness as `true`: + +- Task-level `ready` is `true`/`false` only when the task has a real probe; it is `null` + when `readiness.kind === 'none'` (unknown, not verified). +- Separately, task-level `listening` is a factual port-derived signal: `true`/`false` + whether the task has ≥1 detected listening port, `null` when not running. This is + deliberately NOT folded into `ready` - a port being open is not the same as the service + being able to serve (an API opens its port before its DB/cache connect), which is exactly + the church-portal case that motivated the honesty work. `listening` answers "is anything + bound"; `ready` answers "did a probe confirm it is serving". +- The start tools' header reports `readinessVerified`: `true` (every running task probed + and ready), `false` (a probed task failed), or `null` (at least one running task has no + probe). The `null` note now distinguishes listening-but-unverified from not-listening, + and points at `set_readiness`. +- For a running-but-unprobed task, the start tools include its recent log tail anyway + (labeled as such), so the agent can judge readiness from the output in one call instead + of guessing or curling the service blind. + +**Making readiness verifiable.** `set_readiness` configures a task's probe - `port`, `log`, +`http`, `delay`, or `none` - so a subsequent start returns `readinessVerified: true` and +holds until the service actually serves. The `http` kind (new `ReadinessSignal` variant +`{ kind: 'http', url, status? }`) polls a URL until it returns the expected status (default +any 2xx/3xx) and is the correct fit for a service that binds its port before it can serve: +point it at a `/health` or `/ready` endpoint. A `port` probe would call such a service +ready too early; `http` does not. The `http` url is restricted to loopback +(`127.0.0.1`/`localhost`/`[::1]`) - the main process polls it, so a non-loopback host would +be an SSRF/egress primitive - and TLS is skipped only for loopback (self-signed dev certs). + +**Config-vs-live honesty.** `set_readiness` edits the STORED config; it takes effect on the +next start, not immediately. So `ready`/`readinessVerified` are derived from the readiness +kind the LIVE run was spawned with (`RunningTask.readinessKind`), NOT the stored config - +otherwise setting an `http` probe on an already-running probe-less task would flip +`ready: null → true` for a probe that never executed, re-introducing the false positive. + +### Ports in start results + +Port discovery is asynchronous (an lsof/ps tick every ~2s), so a start result would +naturally race it and report `ports: []` for an app that is about to expose three. The +start tools wait for detection in two phases: (1) up to 3s for the FIRST port - if none +appears the task is likely portless (a worker/builder) and the wait ends; (2) once a port +is seen, keep polling until the port set stops GROWING for ~2.4s (deliberately longer than +the ~2s detection tick, so a port that binds during the wait gets at least one tick to +surface before we call it stable), bounded by a ~12s hard cap. Phase 2 is what catches a slow sibling: field feedback showed a turbo monorepo whose +API on `:4000` (tsx watch + Postgres/Redis connect) bound several seconds after web/:3000, +so the old "first port + fixed 700ms settle" returned before `:4000` existed. This remains +best-effort - a service slower than the hard cap still shows up on a later `get_ports` - +and `set_readiness` (e.g. an `http` probe) is the way to make the start actually WAIT until +such a service is serving. Callers that need instant returns use `wait: false` on +start_task. + +### Port ownership labels + +The PortDetector tick already attributes each listening port to a pid (that is how ports +map to tasks); the same single `ps` snapshot now also carries each pid's command line, so +DevHarbor can label WHICH process inside a task owns a port - the answer to "one turbo +task, three anonymous ports". `get_ports` and the task objects in `get_app`/start results +include `portDetails: [{ port, pid, process }]`, where `process` is a shortened command +(absolute path segments reduced to basenames, capped length; processes that retitle +themselves, like `next-server (v15)`, label for free). Command lines can carry secrets as +CLI arguments, so process labels go through the same known-secret redaction as log text. +Log-hinted ports that lsof has not confirmed yet have `pid: null`. + +### Request gatekeeping (in order) + +1. **Host allowlist**: the `Host` header's hostname (port stripped) must EXACTLY equal + `127.0.0.1`, `localhost`, or `[::1]` - strict equality, never a suffix/substring test + (`localhost.evil.com` must fail). Otherwise 403. This blocks DNS-rebinding: a malicious + page at `evil.com` resolving to 127.0.0.1 sends `Host: evil.com`. Applies to EVERY path + including `/healthz` (only the auth step is skipped there), so rebinding cannot even + probe for DevHarbor's presence. +2. **Origin check**: if an `Origin` header is present (a browser context), its hostname must + also be exactly loopback, else 403. Loopback origins stay allowed so MCP Inspector works. + No CORS headers are ever emitted, so cross-origin browser reads fail regardless. +3. **Auth**: when `mcp_require_auth` is on (default), `Authorization: Bearer ` must + match the stored token. Both sides are sha256-hashed to equal-length buffers before + `crypto.timingSafeEqual` - the raw comparison throws on attacker-controlled length + mismatch, and a length pre-check would leak length. Otherwise 401 with + `WWW-Authenticate: Bearer`. The token is a 32-byte random hex string, generated on first + enable, encrypted at rest, shown and copyable in Settings. + +**What the token actually protects.** `add_app` on an arbitrary path plus `start_app` runs +that project's package.json scripts as the user; `set_env_var` can set variables like +`NODE_OPTIONS` that the next start executes. Holding the token is therefore equivalent to +running commands as the user. That is inherent to what DevHarbor is (it runs dev servers), +not an implementation flaw - but it is why auth defaults on, why the auth-off warning says +"any local process can run commands as you through DevHarbor", and why scoped tokens are +listed under future work. + +**DoS bounds** (loopback-only, but gatekeeping runs after headers arrive): explicit +`headersTimeout` (10s) so dribbled headers can't hold sockets; `requestTimeout` disabled +deliberately because tool calls legitimately block for readiness (up to +`readiness_timeout_ms` per level); `maxConnections` 32; the 4 MB body cap counts bytes as +they stream in and destroys the socket on exceed - never trusts Content-Length, never +buffers past the cap. + +Security posture summary: off by default; loopback bind; Host+Origin validated; token by +default; stored secret values masked in env responses and redacted from log output; body +capped while streaming; bounded connections; no state shared between requests. Turning +`mcp_require_auth` off is allowed (some clients cannot send headers) but the UI warns +plainly that any local process can then run commands as the user. + +### Lifecycle wiring + +`McpService` is constructed in `registerAllIpcHandlers` alongside the other services (it +needs the same singletons), before the auto-start loop. Start is fire-and-forget +(`registerAllIpcHandlers` is synchronous; the Updater precedent). It starts on boot when +`mcp_enabled`. Settings semantics: only `mcp_port` changes and enable/disable bounce the +listener; `mcp_require_auth` and token rotation are evaluated per request and need no +restart, so flipping the auth toggle never kills an agent's in-flight call. A listener +bounce destroys in-flight request sockets - the underlying tool operations run to +completion and the client retries. + +Quit ordering matters: `IpcRuntime` grows a `disposeServices()` member that index.ts calls +at teardown START - in the zero-tasks fast path before `closeDb()`, and in the confirm path +immediately after the user confirms "Stop & Quit", BEFORE `stopAllRunning()` (not before +the dialog - Cancel must leave the server running). Otherwise an agent could `start_app` +DURING the multi-second teardown and spawn a process that outlives the app as an orphan, +the exact failure DevHarbor exists to prevent. McpService also carries a `disposed` flag +its request handler checks, so anything arriving after stop() gets a fast 503 instead of +running tools against a closing DB. `db:reset` hard-exits without before-quit, so nothing +may depend on graceful stop always running. `EADDRINUSE` and other listen errors do not +crash the app: the service lands in `{ running: false, lastError }`, pushes a status event, +and the Settings UI shows it. + +Everything `McpService` needs arrives by constructor injection (settings, token store, the +tool context, a status callback) so vitest can boot it with plain fakes - the test +environment is node and must never transitively import `electron` or better-sqlite3. + +### Operations reuse: AppOps + +The IPC handlers own non-trivial flows the MCP tools must not duplicate-and-drift from: +atomic create-with-rollback (`apps:create`), remove guards, watcher (re)wiring on +add/update/remove, folder scanning, global log search. These move into +`src/main/services/AppOps.ts`, constructed with the app/task registries, EnvStore +(createApp writes initial env vars), DetectionService (scanFolder + detection-driven +defaults), both watchers, the orchestrator (guards + runner buffer access), and an +`onAppsChanged` callback (tray refresh). IPC handlers and MCP tools both delegate to it, +so there is exactly one implementation of each flow. The extraction is a code move plus +three deliberate behavior fixes (below) - the searchLogs one is renderer-visible (global +search now also finds recently exited tasks) and intentional. + +AppOps also fixes three latent issues while extracting: + +- `createApp` validates the path exists (directory) before `realpathSync`, so a bad path + reads "Invalid path", not a raw ENOENT. +- `searchLogs` strips `g`/`y` regex flags (a stateful `RegExp.test` silently skips matches) + and searches every registered task's buffer via `readBuffer` instead of only live tasks, + so recently exited tasks (buffers live ~10 minutes past exit) remain searchable. +- The remove/stop guards stay in AppOps, not the IPC layer, so no caller can bypass them. + +### Log hygiene + +Task buffers contain raw ANSI (CSI and OSC sequences). Every MCP response that carries log +text - `get_logs`, `search_logs`, and the failure tails in the start/restart tools - goes +through one hygiene pass: + +1. **ANSI strip** (CSI and OSC sequences both - the readiness watcher's regex misses OSC). +2. **Secret redaction**: dev servers print secrets (`console.log(process.env)`, connection + strings in stack traces, dotenv debug output). DevHarbor knows the plaintext of every + stored secret env var, so every occurrence of a known secret value (all scopes, length + >= 4 to avoid mangling output on trivial values) is replaced with `[redacted]` before + the text leaves the process. Without this, `get_logs` would quietly bypass the + list_env_vars masking. Secrets a dev server invents on its own (not stored in DevHarbor) + cannot be redacted - the spec and README say so rather than overclaiming. + +Buffers are memory-only: they expire ~10 minutes after task exit and never survive an app +relaunch, and tools say so when they come back empty. + +## Settings + +New keys (settings table, `Settings.ts` + `SettingsState`): + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `mcp_enabled` | boolean | `false` | Run the MCP server. | +| `mcp_port` | number | `6872` | Listen port (1024-65535, loopback only). | +| `mcp_require_auth` | boolean | `true` | Require the bearer token. | +| `mcp_https` | boolean | `false` | Serve over TLS with the self-signed localhost certificate. | + +`mcp_token` also lives in the settings table but not in `SettingsState`: it is not a +renderer-editable setting and must not ride along every `settings:get`. It is reachable +only through the dedicated IPC below. + +## IPC additions + +| Channel | Shape | Notes | +|---|---|---| +| `mcp:status` (invoke) | `void → McpStatus` | `{ enabled, running, port, url, requireAuth, https, certPath, lastError, tokenStoredPlaintext }` | +| `mcp:token` (invoke) | `void → string \| null` | Plaintext token for display/copy. Null when auth off or never generated. | +| `mcp:regenerateToken` (invoke) | `void → string` | Rotates the token; takes effect on the next request. | +| `mcp:status` (event) | `McpStatus` | Pushed on start/stop/error/settings change. | + +`settings:set` gains a side effect: any `mcp_*` key change calls +`mcpService.applySettings()` (start/stop/restart as needed), which pushes the status event. + +## Tool catalog + +Naming: `snake_case`, verb-first. Server identity: name `devharbor`, version = app version. +Server `instructions` (sent to every client on initialize) state the contract: + +> DevHarbor manages local dev servers. Never start project dev servers, watchers, or build +> daemons by running shell commands yourself - register the project as a DevHarbor app and +> use these tools to start, stop, and inspect it. Processes started through DevHarbor keep +> running after your session, stay visible to the user, and get logs, port tracking, and +> clean shutdown for free. Apps are addressed by id, exact name, or absolute path. +> Monorepos: register the repo once with add_app({ path, workspaceTasks: true }) - one task +> per workspace package; add_task/remove_task adjust the set, set_readiness probes each +> service. Do not register workspace subdirectories as separate apps. + +Most tool results are a single JSON text block (`JSON.stringify(..., null, 2)`). The +exception is log-bearing output - `get_logs`, `search_logs`, and the failure tails in the +start/restart tools - which would otherwise double-escape hundreds of newline/quote-heavy +lines inside a JSON string. Those return TWO text blocks: a small JSON header (app, task, +liveness, line count, expiry note) followed by the raw log text. Failures an agent can act +on (unknown app, ambiguous name, app already running, port conflict) return +`isError: true` with a one-line reason plus, where useful, the candidate list. + +The never-run-servers-yourself contract lives in the server `instructions` (with a concrete +substitution recipe: instead of `npm run dev`, call `add_app` then `start_app`) and in the +descriptions of the three entry-point tools (`get_overview`, `add_app`, `start_app`) only. +Repeating it in all tool descriptions would burn ~600 tokens of every agent context for no +compliance gain. + +### Addressing + +- `app` parameter accepts an app id, exact name (case-insensitive), or absolute path. + Inbound paths are trailing-slash-stripped and realpath'd before comparison (symlinks, + `/var` vs `/private/var`); relative paths return an error saying the path must be + absolute. Ambiguous names return an error listing the matching ids. Unknown returns an + error naming the nearest matches. +- `task` parameter accepts a task id, or a task name scoped by the `app` parameter. A task + name without `app` resolves only if globally unique, otherwise errors with the + app/task candidates. + +### Read-only tools (`annotations.readOnlyHint: true`) + +| Tool | Input | Output (JSON) | +|---|---|---| +| `get_overview` | - | App count, running/crashed counts, active ports, DevHarbor version. The cheap first call. | +| `list_apps` | `state?` filter | Per app: id, name, path, state, ports, tags, folder, defaultScript, packageManager, autoStart, lastStartedAt. | +| `get_app` | `app`, `runs_limit? (default 5)` | Full app record + tasks (command, dependsOn, readiness, enabled, plus live state/ready/ports/cpu/memMB) + env file names + recent runs. Subsumes what separate list_tasks / run-history tools would return - fewer schemas in every agent context. | +| `list_running` | - | Every live task: app, task, pid, state, ready, uptime, cpu, memMB, ports. | +| `get_logs` | `app`, `task?`, `lines? (default 100, max 1000)` | Ring-buffer tail. Without `task`: every buffered task of the app, labeled. Notes whether the task is live. | +| `search_logs` | `query`, `regex? (default false)`, `app?`, `limit? (default 100)` | Literal substring by default; `regex: true` for patterns (invalid patterns error rather than silently degrading). The header states how the query was interpreted. Searches all buffered task output, including tasks that exited in the last ~10 minutes. | +| `list_env_vars` | `app`, `task?` | Keys with scope + enabled + isSecret. Values shown for non-secret vars only; secret values are masked, always. | +| `get_ports` | `port?` | All listening ports grouped by app; with `port`, who owns it (or that nothing tracked does). | +| `scan_folder` | `dir` | Import candidates one level deep: path, name, packageManager, scripts, alreadyRegistered. | +| `list_node_versions` | - | Installed Node versions and their source (nvm/fnm/volta/asdf/system). Its actuator is `update_app.nodeVersion`. | + +### Mutating tools + +| Tool | Input | Behavior | +|---|---|---| +| `add_app` | `path`, `name?`, `script?`, `command?`, `workspaceTasks?`, `autoStart?` | Detection-driven create, same atomic flow as the UI (AppOps). Default task chosen as `script` (explicit) -> `command` (explicit raw command) -> the detected default script - so an agent's explicit input always wins over detection, and script-less projects are not a dead end. **Monorepos**: `workspaceTasks: true` creates one task per detected workspace package (same detection and mapping as the UI's "create a task per workspace package" - name, suggested script, `workingDirOverride` = package dir); packages without runnable scripts are skipped, and if nothing is runnable it falls back to the single default task with a note. The response includes the detected scripts, the created task names, and any detected workspace candidates; when a monorepo is registered WITHOUT the flag, a note points at the per-service options. Duplicate path returns the existing app with a note (plus add_task guidance if it is a monorepo and workspaceTasks was requested). | +| `update_app` | `app` + any of `name`, `defaultScript`, `autoStart`, `autoRestartOnChange`, `watchGlobs`, `tags`, `folder`, `portHint`, `nodeVersion` | Patches via AppOps so watchers resync. `nodeVersion` accepts `"auto"`, `"system"`, or an explicit version - the actuator for `list_node_versions`. | +| `remove_app` | `app`, `confirm` | `destructiveHint`. Refuses unless `confirm: true`. Refuses while live (same guard as UI). Does not touch files on disk - only the registration. | +| `start_app` | `app` | Awaits the full topo start including readiness. `startApp` resolving does NOT mean success (readiness failure resolves too), so the tool re-reads `appState` and reports the real final state, per-task states, and ports. Includes the last 30 log lines of every task that did not reach ready - crashed OR alive-but-not-ready (a dev server that printed a compile error and kept running is the common agent-caused failure) - so the agent can diagnose without a second call. Unknown apps are pre-validated against the registry (the orchestrator's own error for that case is misleading). | +| `stop_app` | `app` | Reverse-topo graceful stop (SIGTERM, grace, SIGKILL tree). Returns final state. | +| `restart_app` | `app` | Stop + start under one lock. Idempotent on a stopped app (just starts). Same result shape as `start_app`. | +| `start_task` | `task` (+ `app`), `wait? (default true)` | Starts one task. With `wait`, awaits the readiness outcome and reports like `start_app` (including the not-ready tail); `wait: false` returns the spawn snapshot immediately. | +| `stop_task` | `task` (+ `app`) | Stops one task. | +| `set_env_var` | `app`, `key`, `value`, `secret?`, `enabled?` | Upserts an app-scoped var. `secret` defaults to the shared `isSecretKey` heuristic; the response reports the resolved flag and notes that explicit `secret: false` overrides the heuristic. Secret values are write-only through MCP - stated in the description. Takes effect on next start; the response says so. | +| `set_readiness` | `app`, `task`, `kind` (`none`/`port`/`log`/`http`/`delay`) + kind fields | Configures how DevHarbor decides the task is READY, so a later start returns `readinessVerified: true` and waits until it is serving. Validates the required field per kind (port/regex/url/ms) and the url/regex themselves. Takes effect on next start. This is the answer to a `ready: null` task. | +| `add_task` | `app`, `name`, `script` XOR `command`, `workingDir?`, `dependsOn?`, `oneShot?`, `enabled?` | Adds a per-service task - the building block for monorepo structure an agent shapes itself. `workingDir` is validated as an existing subdirectory relative to the app root (no absolute paths, no `..`). `dependsOn` accepts task ids or names within the app; cycle detection is TaskRegistry's (throws with the cycle path, surfaced as the tool error). Task names must be unique within the app so they stay addressable. `oneShot: true` (migrations, builds) defaults readiness to `exit: 0` so dependents wait for completion; long-running tasks default to `none` with set_readiness as the follow-up. | +| `remove_task` | `app`, `task`, `confirm` | `destructiveHint`. Refuses unless `confirm: true`, while the task is running, and while other tasks depend on it (TaskRegistry's guard names the dependents). Task-scoped env vars are deleted with it (FK cascade); files on disk untouched. | + +22 tools. Task lifecycle, per-service task structure (monorepos), and the readiness/env +config an agent needs to make a project actually verifiable. Task reordering/rename, +folder CRUD, and settings stay UI-only on purpose: the MCP surface is for operating apps, +not administering DevHarbor. + +## Settings UI (SettingsDrawer) + +New "MCP server" section, after the existing sections and before the danger zone: + +- Enable toggle with a one-line explanation ("Let coding agents manage your dev servers + through DevHarbor"). +- Status row: dot + "Running at `http://127.0.0.1:6872/mcp`" / "Stopped" / error text + (e.g. port in use), live via the `mcp:status` event. +- Port input (numeric, 1024-65535), applied on commit, server restarts in place. +- "Require auth token" toggle; when off, an inline warning that any local process can + control DevHarbor. +- Token row (only when auth on): masked value, Copy, Regenerate (confirm modal - old token + stops working immediately). +- "Client setup" block: copyable JSON config for MCP clients + (`{"mcpServers":{"devharbor":{"type":"http","url":...,"headers":{"Authorization":"Bearer ..."}}}}`). + +Renderer state follows the house rules: zustand selectors return stable refs, IPC via +`invokeOrToast`, dialogs via the `useDialog` stack. + +## Failure modes + +| Failure | Behavior | +|---|---| +| Port in use | Server not running, `lastError` set, status event, Settings shows the error. App unaffected. | +| Invalid token from client | 401 per request. Never crashes, never logs the attempted token. | +| Tool throws (unknown app, task registry error) | Caught per call, returned as `isError` result with the message. Transport stays healthy. | +| `safeStorage` unavailable (rare: no Keychain) | Unlike EnvStore's silent plaintext fallback, the token is a security credential: it is still persisted (clients must survive restarts) but the status reports `tokenStoredPlaintext: true` and Settings shows a warning that file permissions are the only protection. Auth still enforced. | +| HTTPS certificate generation/read fails | Listener stays DOWN with `lastError` (never a silent fallback to plain HTTP); disable `mcp_https` to serve over HTTP again. | +| Body over 4 MB / invalid JSON | 413 (socket destroyed, cap counted while streaming) / 400 before any MCP processing. | +| Slow tool call vs client timeout | SDK clients default to a 60 s request timeout; the start tools return a "still starting" result at 45 s instead of racing it. | +| App quit | `disposeServices()` closes the listener and in-flight sockets BEFORE `stopAllRunning()`, so no tool can spawn a process mid-teardown; late requests get 503 via the `disposed` flag. | +| db:export backup | Contains `mcp_token` (ciphertext under Keychain; plaintext in the fallback case - regenerate the token after sharing an export). | + +## Testing + +- **Unit (vitest, node env, no native deps)**: `gate.ts` (host/origin allow list, bearer + parse + sha256/timingSafeEqual compare incl. wrong-length input) and `hygiene.ts` (ANSI + strip, secret redaction, tail) - both dependency-free by design. Plus the app/task + resolver (id, name, ambiguous, path) and tool result shapes against a scripted fake + context. Nothing in the unit suite imports electron or better-sqlite3. +- **Catalog (vitest)**: `registerTools` against a fake `McpServer` + in-memory context - all + 22 tools registered with correct read-only/destructive hints, and per-tool behavior + (overview counts, list filtering, env masking, log ANSI-strip + secret redaction, the + remove confirm/running guards, start_app state re-read + crash tail, add_app dedupe). No + electron import, so it runs in the node env. +- **Live HTTP (Electron E2E harness)**: `McpService` transitively imports electron (Logger, + safeStorage), so the real listener + transport + gatekeeping are driven from the packaged + app under Electron, not vitest - see the E2E step below. It uses the real SDK `Client` + + `StreamableHTTPClientTransport`: initialize, tools/list, representative calls, 401 without + token, 403 with a bad Host, 405 on GET. +- **E2E (manual harness, packaged app)**: dir-build via electron-builder, launch with a + throwaway profile, enable via seeded settings, then run the full client script against a + real sample project: add, start, status, ports, logs, search, env, restart, stop, + history, remove, plus the auth/Host negative cases. + +## Future work + +- Log streaming to agents (stateful sessions + `notifications/resources/updated` or SSE). +- `update_task` (rename, re-point dependsOn, toggle enabled) if agent demand shows up - + add_task/remove_task shipped with the monorepo round; set_readiness already covers the + most-edited field. +- Scoped tokens (read-only token vs control token). +- An MCP resource exposing the app list for clients that prefer resources over tools. diff --git a/specs/PROGRESS.md b/specs/PROGRESS.md index 52e6b34..77c1183 100644 --- a/specs/PROGRESS.md +++ b/specs/PROGRESS.md @@ -11,7 +11,7 @@ Legend: - 📐 spec'd - exists in `specs/`, no code yet - 💭 stretch - listed in `specs/` stretch sections, not committed for v1 -Last sync: **2026-05-22** (after Phase 6 polish marathon - audit findings closed). +Last sync: **2026-07-10** (MCP server field-feedback rounds closed; 1.2.0 release prep). --- @@ -37,6 +37,7 @@ Last sync: **2026-05-22** (after Phase 6 polish marathon - audit findings closed | 13 | Dashboard IA redesign | ✅ | **Removed the Recent strip** (redundant with sidebar + grid; `RecentStrip.tsx` deleted). Dashboard reframed as the **control room**: leads with an **aggregate stat strip** (Running / CPU / Memory / Open ports across all running tasks) - value the navigation-only sidebar can't provide. Dropped the redundant "ALL APPS" label. **Running cards emphasised in place** (teal inset left edge + lifted surface) - no reorder, no layout shift. Sidebar kept as the switcher; dashboard is now monitoring + control. | | 13.b | macOS menu actions | ✅ | HIG-standard system-menu items wired to the renderer via typed `menu:*` events: **Settings… (⌘,)** in the app menu → opens the Settings drawer; **Add App… (⌘N)** + **Add Folder… (⌘⇧N)** in File. `installAppMenu` takes a window getter; `App.tsx` handles settings/add-app and relays new-folder to the sidebar via a DOM event. | | 15 | Code-review fixes (high-effort recall pass) | ✅ | **Add-app**: stop persisting `default_script` (killed backfill resurrecting a deleted task) + roll back the app if task/env creation throws (no orphan). **Shared `src/shared/dotenv.ts`** parser + `isSecretKey` replaces 3 diverged copies (EnvBuilder expanded escapes, renderer copies didn't) + 8 new tests. **Menu** create/focus a window when none is open (⌘,/⌘N/⌘⇧N no longer dead keys). **Dashboard** `React.memo(AppCard)` - idle cards skip the ~1Hz re-render. **OpenIn** detect editors via LaunchServices (`osascript`) so non-standard install locations aren't hidden. **store** clears CPU/mem/ports when a task leaves running (no stale values on restart). **AddAppDrawer** memoizes env key-count parse. **will-navigate** compares URL origin, not string prefix. **Sidebar** drops a folder from the pinned list once it has an app (no zombie empty folders). | +| 16 | MCP server (F22) | ✅ | Opt-in, loopback-only HTTP **MCP server** (`src/main/mcp/`) exposing **22 tools** to coding agents. Stateless Streamable HTTP via `@modelcontextprotocol/sdk` (externalized, CJS). Gatekeeping in pure `gate.ts`: exact-loopback `Host`/`Origin`, bearer parse + sha256/`timingSafeEqual` (no throw on length). `token.ts` stores a 32-byte token via `safeStorage` (plaintext-fallback flagged). `hygiene.ts` strips ANSI (CSI+OSC) + redacts stored secret values from all log-bearing responses. Shared `AppOps.ts` de-duplicates the create/update/remove/scan/search flows between IPC and MCP (fixes: friendly path validation, `g`/`y` regex stripping, search covers recently-exited tasks). Settings section with live status, port, token copy/regenerate, client-config snippet. Listener closes before task teardown on quit. New settings keys `mcp_enabled`/`mcp_port`/`mcp_require_auth`/`mcp_https`. Tests: gate, hygiene, resolver, TLS, HTTP readiness, and the 22-tool catalog, plus an Electron E2E driving the real server with the SDK client (functional + auth + 403/405/413). Field-feedback round: **honest readiness** (`ready: null` for probe-less tasks, `readinessVerified` tri-state, judgeable log tail for unprobed running tasks), **port backfill** (start results wait for detection instead of returning `ports: []`), **per-port process labels** (`ps args` captured in the existing snapshot; `get_ports` names the owning process per port), and **HTTPS mode** (`mcp_https`, self-signed localhost cert generated via openssl into `userData/mcp-tls/`, fail-closed on cert errors). Second feedback round: factual `listening` flag distinct from verified `ready`, port-set stabilization wait (slow-binding monorepo siblings captured inline), `set_readiness` tool + new `http` readiness kind (loopback-only URL, polls until success), and live desktop refresh via `state:invalidate` on every MCP mutation. Monorepo round: `add_app({ workspaceTasks: true })` creates one task per workspace package (UI-detection reuse), `add_task`/`remove_task` (workingDir validation, dependsOn by name with cycle guard, `oneShot` -> exit readiness default, dependent/running/confirm guards). | ## Feature-by-feature @@ -60,7 +61,10 @@ Last sync: **2026-05-22** (after Phase 6 polish marathon - audit findings closed | F16 | Deep links (`devharbor://...`) | ✅ | ✅ | Protocol registered + `open-url` event. `open?path=` and `open?id=` push `deepLink:focusApp` to the renderer which selects the app. Unknown paths surface `deepLink:unknownPath` which opens the Add drawer pre-filled. `start?id=` focuses + starts. | | F17 | Auto-update | ✅ | ✅ | `Updater` + `update:available` / `update:progress` / `update:ready` / `update:install`. `UpdateBanner` shows download percentage and Quit & install. GitHub Releases publish wired. Activates once a signed Release is published. | | F18 | Settings | ✅ | ✅ | All settings honored: `kill_grace_ms` (TaskRunner), `log_ring_size` (LogBuffer + xterm scrollback), `theme` (renderer `` class), `dashboard_refresh_ms` (StatsMonitor), `auto_update` (Updater). Node detection panel lists discovered nvm/fnm/volta/asdf/system installs. Danger zone: Export DB (file picker), Reset DB (archives + relaunches with empty DB). (The earlier inert `log_disk_persist_default` and `telemetry` flags were removed before 1.0 - no placeholder controls ship.) | -| F19 | Multi-task orchestration | ✅ | ✅ | Full implementation: `tasks` table, TaskRegistry CRUD with cycle detection, AppOrchestrator with topo levels, all four readiness watchers (`none`/`port`/`log`/`exit`/`delay`), TaskTabs + TaskEditor UI, per-task and per-app lifecycle IPC. | +| F19 | Multi-task orchestration | ✅ | ✅ | Full implementation: `tasks` table, TaskRegistry CRUD with cycle detection, AppOrchestrator with topo levels, readiness watchers (`none`/`port`/`log`/`http`/`exit`/`delay`), TaskTabs + TaskEditor UI, per-task and per-app lifecycle IPC. | +| F20 | Node version selection | ✅ | ✅ | Per-app picker (post-Phase-6) + per-task override; resolution across nvm / fnm / volta / asdf / system honoring `.nvmrc` / `engines.node`. | +| F21 | Folders in sidebar | ✅ | ✅ | `apps.folder` column, collapsible sections, drag-and-drop between folders, rename/delete, pinned empty folders. See Phase 8 rows above. | +| F22 | MCP server | ✅ | ✅ | Opt-in loopback HTTP(S) server, 22 tools, bearer auth, secret redaction, honest readiness (`ready: null` / `listening` / `set_readiness`), monorepo registration (`workspaceTasks`, `add_task`/`remove_task`), live UI refresh on MCP mutations. See Phase 16 row above and `specs/07-mcp-server.md`. | ## Stack inventory diff --git a/src/main/index.ts b/src/main/index.ts index 47f89de..53335fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -266,6 +266,12 @@ app.on('before-quit', (event) => { } const running = ipcRuntime?.runningTaskCount() ?? 0; if (running === 0) { + // No live tasks - but an in-flight MCP start_app could still be queued (not yet at + // runner.start(), so it counts as 0). Flag the orchestrator so any such queued start + // no-ops instead of spawning an orphan after we quit. Then close the MCP listener + // best-effort (fire-and-forget - the socket also closes when the process exits) and go. + ipcRuntime?.beginShutdown(); + void ipcRuntime?.disposeServices(); closeDb(); return; } @@ -285,6 +291,13 @@ app.on('before-quit', (event) => { teardownInFlight = false; return; // cancelled - stay open } + // Close the MCP listener FIRST - before stopAllRunning - so no in-flight tool call can + // spawn a task that misses the teardown snapshot and survives as an orphan. + try { + await ipcRuntime?.disposeServices(); + } catch (e) { + logger.error('service dispose on quit failed', e); + } try { await ipcRuntime?.stopAllRunning(); } catch (e) { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index cff5acc..4710b89 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,6 +1,6 @@ import { BrowserWindow, dialog, ipcMain, Notification } from 'electron'; -import type { InvokeChannelName, InvokeChannels, ImportCandidate, GlobalLogMatch } from '@shared/ipc'; -import type { AppId, EnvVar, TaskId } from '@shared/types'; +import type { InvokeChannelName, InvokeChannels } from '@shared/ipc'; +import type { AppId, TaskId } from '@shared/types'; import { closeDb, db, dbFile } from '../db/index.js'; import { logger } from '../services/Logger'; import { TrayController, type TrayApp } from '../services/TrayController'; @@ -10,7 +10,10 @@ import { NodeResolver } from '../services/NodeResolver'; import { TaskRegistry } from '../services/TaskRegistry'; import { TaskRunner, type TaskLogEvent, type TaskStatusEvent } from '../services/TaskRunner'; import { AppOrchestrator } from '../services/AppOrchestrator'; +import { AppOps } from '../services/AppOps'; import { RunHistory } from '../services/RunHistory'; +import { McpService } from '../mcp/McpService'; +import type { McpToolContext } from '../mcp/context'; import { EnvStore } from '../services/EnvStore'; import { EnvBuilder } from '../services/EnvBuilder'; import { EnvFileWatcher, type EnvFileChange } from '../services/EnvFileWatcher'; @@ -21,8 +24,8 @@ import { DeepLinks } from '../services/DeepLinks'; import { Updater } from '../services/Updater'; import { OpenIn } from '../services/OpenIn'; import { app as electronApp, dialog as electronDialog } from 'electron'; -import { existsSync, readdirSync, realpathSync, renameSync, statSync } from 'node:fs'; -import { basename, join } from 'node:path'; +import { existsSync, realpathSync, renameSync } from 'node:fs'; +import { join } from 'node:path'; import type { StatsTick } from '../services/StatsMonitor'; import type { PortsEvent } from '../services/PortDetector'; @@ -41,6 +44,17 @@ export interface IpcRuntime { stopAllRunning: () => Promise; /** Reset per-renderer state (log subscriptions) when the window reloads/navigates. */ onRendererReload: () => void; + /** + * Enter quit teardown: block/cancel starts so nothing new spawns. Idempotent; call at the + * committed point of BOTH quit paths (including the zero-task fast path) before proceeding. + */ + beginShutdown: () => void; + /** + * Tear down long-lived, externally-facing services (the MCP HTTP listener). Called at the + * START of the quit teardown - before stopAllRunning - so no in-flight MCP request can + * spawn a process that then outlives the app as an orphan. + */ + disposeServices: () => Promise; } export function registerAllIpcHandlers( @@ -252,6 +266,55 @@ export function registerAllIpcHandlers( if (evt.state === 'exited' || evt.state === 'crashed') taskReady.delete(evt.taskId); }); + // --- Shared app-operation layer (used by IPC handlers AND the MCP server) -------------------- + const appOps = new AppOps(registry, taskRegistry, envStore, detector, orchestrator, { + watchEnvFiles: (id, path) => envFileWatcher.watch(id, path), + unwatchEnvFiles: (id) => envFileWatcher.unwatch(id), + syncRestartWatcher, + unwatchRestart: (id) => restartWatcher.unwatch(id), + refreshTray: () => tray.refresh() + }); + + // --- MCP server (F22): expose app management to coding agents over local HTTP ---------------- + const mcpContext: McpToolContext = { + appVersion: electronApp.getVersion(), + registry, + taskRegistry, + orchestrator, + runner, + envStore, + detector, + nodes, + runHistory, + settings, + appOps, + envFiles: (appId) => { + const a = registry.get(appId); + return a ? envFileWatcher.list(a.path) : []; + }, + // MCP-driven mutations bypass the renderer's own IPC flow, so nudge it to re-sync its + // cached app/task/running view. Debounced on the renderer side. Must never throw: the + // mutation has already committed, and the webContents can be mid-destroy during a + // window close - that must not turn a successful tool call into an error result. + notifyChanged: () => { + try { + const w = win(); + if (w && !w.isDestroyed()) w.webContents.send('state:invalidate', {}); + } catch { + // best-effort notification + } + } + }; + const mcpService = new McpService( + mcpContext, + settings, + join(electronApp.getPath('userData'), 'mcp-tls'), + (status) => { + win()?.webContents.send('mcp:statusChanged', status); + } + ); + mcpService.start(); + // --- Auto-start flagged apps on launch (IMPROVEMENT-PLAN 14.6) ------------------------------- for (const a of registry.list()) { if (a.autoStart) { @@ -269,131 +332,21 @@ export function registerAllIpcHandlers( tray.refresh(); return app; }); - register('apps:update', ({ id, patch }) => { - const before = registry.get(id); - const app = registry.update(id, patch); - if (!before || before.path !== app.path) { - envFileWatcher.watch(app.id, app.path); - } - // Resync restart watcher if its config changed (or path changed). - if ( - !before || - before.autoRestartOnChange !== app.autoRestartOnChange || - before.path !== app.path || - JSON.stringify(before.watchGlobs) !== JSON.stringify(app.watchGlobs) - ) { - syncRestartWatcher(app.id); - } - tray.refresh(); // rename / folder changes show in the tray menu - return app; - }); + register('apps:update', ({ id, patch }) => appOps.updateApp(id, patch)); register('apps:remove', ({ id }) => { - // Only block removal while the app is actually LIVE. The sticky outcome design means an - // app that ever ran reports 'exited'/'crashed' forever, so the old `!== 'idle'` guard made - // every previously-run app permanently unremovable (IMPROVEMENT-PLAN 5.2). - const st = orchestrator.appState(id as AppId); - if (st === 'running' || st === 'starting' || st === 'exiting') { - throw new Error('Stop the app before removing it.'); - } - envFileWatcher.unwatch(id); - restartWatcher.unwatch(id); - orchestrator.clearOutcome(id as AppId); - registry.remove(id); - tray.refresh(); + // Guard + watcher teardown live in AppOps so the MCP remove path enforces them too. + appOps.removeApp(id as AppId); }); register('apps:detect', ({ path }) => detector.detect(path)); - // Atomic create: app + first task + env vars in ONE main-process handler with rollback, so a + // Atomic create: app + first task + env vars in ONE transaction-ish flow with rollback, so a // partial failure (or a renderer reload mid-flow) can't leave an orphan app row - // (IMPROVEMENT-PLAN 12.7). FK cascade cleans tasks/env if we roll back. - register('apps:create', async (input) => { - const real = realpathSync(input.path); - if (registry.getByPath(real)) { - throw new Error('This folder is already registered.'); - } - const app = await registry.add(input.path); - try { - const patched = registry.update(app.id, { - name: input.name?.trim() || app.name, - nodeVersionPref: input.nodeVersionPref ?? { kind: 'auto' }, - packageManager: input.packageManager ?? null, - defaultScript: input.defaultScript ?? null - }); - const taskSpecs = [...(input.firstTask ? [input.firstTask] : []), ...(input.tasks ?? [])]; - for (const spec of taskSpecs) { - taskRegistry.add(app.id, { - name: spec.name, - commandKind: spec.commandKind, - script: spec.script ?? null, - customCommand: spec.customCommand ?? null, - workingDirOverride: spec.workingDirOverride ?? null, - enabled: true - }); - } - if (input.envVars && input.envVars.length > 0) { - const vars: EnvVar[] = input.envVars - .filter((v) => v.key.trim()) - .map((v) => ({ - id: '', - appId: app.id, - key: v.key.trim(), - value: v.value, - enabled: true, - isSecret: v.isSecret ?? false - })); - envStore.setApp(app.id, vars); - } - envFileWatcher.watch(app.id, app.path); - syncRestartWatcher(app.id); - tray.refresh(); - return patched; - } catch (err) { - try { - registry.remove(app.id); - } catch { - /* best-effort rollback */ - } - throw err; - } - }); + // (IMPROVEMENT-PLAN 12.7). Delegated to AppOps so IPC and MCP share one implementation. + register('apps:create', (input) => appOps.createApp(input)); - // Shallow-scan a folder for package.json projects (bulk import). One level deep; skips - // already-registered folders' "alreadyRegistered" flag so the picker can disable them. - register('apps:scanFolder', async ({ dir }) => { - const out: ImportCandidate[] = []; - let entries: string[]; - try { - entries = readdirSync(dir); - } catch { - return out; - } - for (const name of entries) { - if (name.startsWith('.')) continue; - const full = join(dir, name); - try { - if (!statSync(full).isDirectory()) continue; - if (!existsSync(join(full, 'package.json'))) continue; - } catch { - continue; - } - let real = full; - try { - real = realpathSync(full); - } catch { - // use raw - } - const detection = await detector.detect(full); - out.push({ - path: full, - name: basename(real), - alreadyRegistered: !!registry.getByPath(real), - packageManager: detection.packageManager, - suggestedScript: detection.suggestedDefaultScript, - scripts: Object.keys(detection.scripts) - }); - } - return out.sort((a, b) => a.name.localeCompare(b.name)); - }); + // Shallow-scan a folder for package.json projects (bulk import). One level deep; the + // "alreadyRegistered" flag lets the picker disable folders that are already added. + register('apps:scanFolder', ({ dir }) => appOps.scanFolder(dir)); register('apps:findByPath', ({ path }) => { try { const real = realpathSync(path); @@ -442,38 +395,12 @@ export function registerAllIpcHandlers( logSubs.delete(id); }); - // Global log search: fan over every live task's ring buffer in main and return matches. - register('logs:searchAll', ({ query, flags, limit }) => { - const out: GlobalLogMatch[] = []; - if (!query.trim()) return out; - let re: RegExp; - try { - re = new RegExp(query, flags ?? 'i'); - } catch { - // Fall back to a literal substring match if the regex is invalid. - re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); - } - const cap = limit ?? 500; - for (const rt of runner.list()) { - const app = registry.get(rt.appId); - const tasks = taskRegistry.list(rt.appId); - const taskName = tasks.find((t) => t.id === rt.taskId)?.name ?? ''; - const buf = runner.readBuffer(rt.taskId); - for (const line of buf.split('\n')) { - if (re.test(line)) { - out.push({ - appId: rt.appId, - taskId: rt.taskId, - appName: app?.name ?? '', - taskName, - line: line.length > 2000 ? line.slice(0, 2000) : line - }); - if (out.length >= cap) return out; - } - } - } - return out; - }); + // Global log search: delegated to AppOps (shared with the MCP search_logs tool). Regex with + // an 'i' default keeps the renderer's power-user behavior; AppOps additionally covers tasks + // that exited in the last ~10 minutes, not just live ones. + register('logs:searchAll', ({ query, flags, limit }) => + appOps.searchLogs({ query, regex: true, flags, limit }) + ); register('runs:list', ({ appId, limit }) => runHistory.list(appId, limit)); @@ -523,9 +450,23 @@ export function registerAllIpcHandlers( if (typeof patch.launch_at_login === 'boolean') { electronApp.setLoginItemSettings({ openAtLogin: patch.launch_at_login, openAsHidden: true }); } + // Start/stop/rebind the MCP listener when any of its settings changed. applySettings + // decides whether a listener bounce is actually needed (auth toggle needs none). + if ( + typeof patch.mcp_enabled === 'boolean' || + typeof patch.mcp_port === 'number' || + typeof patch.mcp_require_auth === 'boolean' || + typeof patch.mcp_https === 'boolean' + ) { + void mcpService.applySettings(); + } return next; }); + register('mcp:status', () => mcpService.status()); + register('mcp:token', () => mcpService.getToken()); + register('mcp:regenerateToken', () => mcpService.regenerateToken()); + register('update:install', () => { updater.quitAndInstall(); }); @@ -600,9 +541,19 @@ export function registerAllIpcHandlers( return { runningTaskCount: () => orchestrator.runningTaskCount(), - stopAllRunning: () => orchestrator.stopAllRunning(), + // Quit-path teardown: flag + cancel in-flight starts FIRST so queued/blocked starts don't + // spawn (or hang the quit). The tray's plain "Stop all" calls orchestrator.stopAllRunning + // directly, without this flag, so it stays reusable. + beginShutdown: () => orchestrator.beginShutdown(), + stopAllRunning: () => { + orchestrator.beginShutdown(); + return orchestrator.stopAllRunning(); + }, // A renderer reload (⌘R is kept in prod) loses the renderer-side unsubscribe calls; if the // stale subscriptions lingered, log forwarding would stay gated to dead taskIds forever. - onRendererReload: () => logSubs.clear() + onRendererReload: () => logSubs.clear(), + // Close the MCP listener BEFORE tasks are torn down so an in-flight tool call can't spawn + // a process that outlives the quit as an orphan. + disposeServices: () => mcpService.dispose() }; } diff --git a/src/main/mcp/McpService.ts b/src/main/mcp/McpService.ts new file mode 100644 index 0000000..ec2b62b --- /dev/null +++ b/src/main/mcp/McpService.ts @@ -0,0 +1,395 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { createServer as createHttpsServer } from 'node:https'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { McpStatus } from '@shared/ipc'; +import { logger } from '../services/Logger.js'; +import type { Settings } from '../services/Settings.js'; +import { hostHeaderAllowed, originAllowed, bearerMatches } from './gate.js'; +import { TokenStore } from './token.js'; +import { generateTlsMaterial, loadTlsMaterial, tlsFilesExist } from './tls.js'; +import { registerTools } from './tools.js'; +import type { McpToolContext } from './context.js'; + +const LOOPBACK = '127.0.0.1'; +const MAX_BODY_BYTES = 4 * 1024 * 1024; // 4 MB, counted while streaming +const HEADERS_TIMEOUT_MS = 10_000; +const MAX_CONNECTIONS = 32; + +const SERVER_INSTRUCTIONS = [ + 'DevHarbor manages the user\'s local dev servers. Do NOT start a project\'s dev server,', + 'watcher, or build daemon by running npm/yarn/pnpm in your own shell - register the', + 'project with add_app and run it with start_app instead. Processes started through', + 'DevHarbor keep running after your session, stay visible to the user, and get logs, port', + 'tracking, and clean shutdown for free.', + '', + 'Typical loop: get_overview to orient, then instead of `npm run dev` in the project dir,', + 'add_app({ path: "" }) then start_app({ app: "" }) - start_app', + 'waits for readiness and returns the log tail on failure. Apps are addressed by id, exact', + 'name, or absolute path.', + '', + 'Monorepos: register the repo ONCE with add_app({ path, workspaceTasks: true }) - one task', + 'per workspace package, each running its own dev script from its own directory. start_app', + 'runs them all, start_task/stop_task control one service. add_task/remove_task adjust the', + 'set (dependsOn orders startup, oneShot for migrations/builds); set_readiness gives each', + 'service its own readiness probe. Do not register workspace subdirectories as separate', + 'apps - task ordering and grouped start/stop only work within one app.' +].join(' '); + +/** + * Local HTTP MCP server (see specs/07-mcp-server.md). Off by default; loopback-only; bearer + * token by default; stateless Streamable HTTP transport with a fresh McpServer per request. + * + * All collaborators arrive via the injected `McpToolContext` so this class carries no direct + * dependency on the individual services beyond Settings (for live config) and the token store. + */ +export class McpService { + private server: Server | null = null; + /** Port + scheme the current `server` was told to bind, set synchronously so applySettings + can compare against the desired settings even before `listen` has resolved. */ + private serverPort: number | null = null; + private serverHttps: boolean | null = null; + private running = false; + private lastError: string | null = null; + private disposed = false; + private readonly tokens = new TokenStore(); + /** In-flight request-handling promises, so dispose() can drain them (bounded). */ + private readonly inflight = new Set>(); + /** Serialises applySettings so two rapid settings changes can't interleave stop/start. */ + private applyChain: Promise = Promise.resolve(); + /** True while the self-signed cert is being generated off-thread (first HTTPS enable). */ + private preparingTls = false; + + constructor( + private readonly ctx: McpToolContext, + private readonly settings: Settings, + /** Directory for the self-signed TLS material (userData/mcp-tls). */ + private readonly tlsDir: string, + private readonly onStatus: (status: McpStatus) => void + ) {} + + /** Start the listener if enabled in settings. Safe to call repeatedly (idempotent-ish). */ + start(): void { + if (this.disposed) return; + if (!this.settings.get('mcp_enabled')) { + this.emitStatus(); + return; + } + // Guard against a concurrent start() (e.g. a settings:set arriving before boot's listen + // callback has fired). `this.server`/`this.serverPort` are assigned SYNCHRONOUSLY below, + // not in the async listen callback, so this guard actually catches the second call + // instead of racing two listens onto the same port. `running` stays false until listen + // confirms the bind, and both callbacks no-op if a newer server has replaced this one. + if (this.server || this.preparingTls) return; + this.lastError = null; + const port = this.settings.get('mcp_port'); + const https = this.settings.get('mcp_https'); + + // First HTTPS enable: the cert may not exist yet. Generate it OFF-THREAD so we never + // block the Electron main process on openssl, then re-enter start() (which now finds the + // files and proceeds synchronously - keeping the synchronous this.server guard intact). + if (https && !tlsFilesExist(this.tlsDir)) { + this.preparingTls = true; + this.emitStatus(); + void generateTlsMaterial(this.tlsDir).then( + () => { + this.preparingTls = false; + this.start(); + }, + (e) => { + this.preparingTls = false; + this.lastError = `HTTPS setup failed: ${e instanceof Error ? e.message : String(e)}`; + logger.warn('[mcp]', this.lastError); + this.emitStatus(); + } + ); + return; + } + + // Ensure a token exists up front so the UI can show it immediately. + if (this.settings.get('mcp_require_auth')) this.tokens.getOrCreate(); + + const onRequest = (req: IncomingMessage, res: ServerResponse): void => { + const p = this.handle(req, res).finally(() => this.inflight.delete(p)); + this.inflight.add(p); + }; + + let server: Server; + if (https) { + // Fail CLOSED on certificate problems - never silently downgrade to plain HTTP when + // the user asked for TLS. Files exist here (generated above or on a prior run). + try { + const material = loadTlsMaterial(this.tlsDir); + server = createHttpsServer({ key: material.key, cert: material.cert }, onRequest); + } catch (e) { + this.lastError = `HTTPS setup failed: ${e instanceof Error ? e.message : String(e)}`; + logger.warn('[mcp]', this.lastError); + this.emitStatus(); + return; + } + } else { + server = createServer(onRequest); + } + this.server = server; + this.serverPort = port; + this.serverHttps = https; + server.headersTimeout = HEADERS_TIMEOUT_MS; + server.maxConnections = MAX_CONNECTIONS; + server.on('error', (err: NodeJS.ErrnoException) => { + if (this.server !== server) return; // a newer server owns the state now + this.running = false; + this.server = null; + this.serverPort = null; + this.serverHttps = null; + this.lastError = + err.code === 'EADDRINUSE' + ? `Port ${port} is already in use. Choose another port in Settings.` + : err.message; + logger.warn('[mcp] listen error', this.lastError); + this.emitStatus(); + }); + server.listen(port, LOOPBACK, () => { + if (this.server !== server) return; // stopped/replaced during the bind + this.running = true; + this.lastError = null; + logger.info(`[mcp] listening on ${https ? 'https' : 'http'}://${LOOPBACK}:${port}/mcp`); + this.emitStatus(); + }); + } + + /** Stop the listener and destroy in-flight sockets. */ + async stop(): Promise { + const server = this.server; + this.server = null; + this.serverPort = null; + this.serverHttps = null; + this.running = false; + if (!server) { + this.emitStatus(); + return; + } + // Bounded close so a stuck socket can never hang app quit: force-close connections, then + // resolve on close-callback OR a short timeout, whichever comes first. + await new Promise((resolve) => { + let done = false; + const finish = (): void => { + if (done) return; + done = true; + resolve(); + }; + server.close(() => finish()); + server.closeAllConnections?.(); + const t = setTimeout(finish, 2000); + t.unref?.(); + }); + logger.info('[mcp] stopped'); + this.emitStatus(); + } + + /** + * React to a settings change. Only port change and enable/disable bounce the listener; + * auth toggle and token rotation are evaluated per request, so they need no restart. + */ + async applySettings(): Promise { + // Serialise: two settings changes in quick succession must not interleave their + // stop/start (which is how two listeners could end up racing the same port). + this.applyChain = this.applyChain.then(() => this.doApplySettings()); + return this.applyChain; + } + + private async doApplySettings(): Promise { + if (this.disposed) return; + const enabled = this.settings.get('mcp_enabled'); + if (!enabled) { + await this.stop(); + return; + } + const port = this.settings.get('mcp_port'); + const https = this.settings.get('mcp_https'); + // Compare against the port/scheme the current server was told to bind (set synchronously + // in start), NOT server.address() which is null until the async bind resolves. + if (this.server && (this.serverPort !== port || this.serverHttps !== https)) { + await this.stop(); + this.start(); + return; + } + if (!this.server) { + this.start(); + return; + } + // Auth-only change: nothing to restart, just refresh status. + if (this.settings.get('mcp_require_auth')) this.tokens.getOrCreate(); + this.emitStatus(); + } + + /** Permanent teardown for the quit path - closes the listener and blocks late requests. */ + async dispose(): Promise { + this.disposed = true; + await this.stop(); + // Drain any request handlers still finishing (bounded) so their work settles before the + // caller proceeds to tear down tasks / close the DB. + if (this.inflight.size > 0) { + await Promise.race([ + Promise.allSettled([...this.inflight]), + new Promise((r) => setTimeout(r, 3000)) + ]); + } + } + + status(): McpStatus { + const port = this.settings.get('mcp_port'); + const https = this.settings.get('mcp_https'); + const certPath = join(this.tlsDir, 'mcp-cert.pem'); + return { + enabled: this.settings.get('mcp_enabled'), + running: this.running, + port, + url: this.running ? `${https ? 'https' : 'http'}://${LOOPBACK}:${port}/mcp` : null, + requireAuth: this.settings.get('mcp_require_auth'), + https, + certPath: https && existsSync(certPath) ? certPath : null, + lastError: this.lastError, + tokenStoredPlaintext: this.tokens.storedPlaintext + }; + } + + /** Current plaintext token (creating one if auth is on and none exists yet). */ + getToken(): string | null { + if (!this.settings.get('mcp_require_auth')) return this.tokens.load(); + return this.tokens.getOrCreate(); + } + + regenerateToken(): string { + const token = this.tokens.regenerate(); + this.emitStatus(); + return token; + } + + private emitStatus(): void { + try { + this.onStatus(this.status()); + } catch (e) { + logger.warn('[mcp] status emit failed', e); + } + } + + private send(res: ServerResponse, code: number, body: unknown, headers?: Record): void { + const text = typeof body === 'string' ? body : JSON.stringify(body); + res.writeHead(code, { 'content-type': 'application/json', ...headers }); + res.end(text); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + try { + if (this.disposed) { + this.send(res, 503, { error: 'DevHarbor is shutting down.' }); + return; + } + + // Host allowlist applies to EVERY path (blocks DNS-rebinding presence probes too). + if (!hostHeaderAllowed(req.headers.host)) { + this.send(res, 403, { error: 'Forbidden host.' }); + return; + } + if (!originAllowed(req.headers.origin)) { + this.send(res, 403, { error: 'Forbidden origin.' }); + return; + } + + const url = new URL(req.url ?? '/', `http://${LOOPBACK}`); + const path = url.pathname; + + if (path === '/healthz') { + this.send(res, 200, { ok: true }); + return; + } + if (path !== '/mcp') { + this.send(res, 404, { error: 'Not found.' }); + return; + } + + // Auth (per request; no listener restart on toggle). + if (this.settings.get('mcp_require_auth')) { + const token = this.tokens.getOrCreate(); + if (!bearerMatches(req.headers.authorization, token)) { + this.send(res, 401, { error: 'Unauthorized.' }, { 'www-authenticate': 'Bearer' }); + return; + } + } + + if (req.method !== 'POST') { + // Stateless mode has no server-push stream to offer on GET/DELETE. + this.send(res, 405, { error: 'Method not allowed.' }, { allow: 'POST' }); + return; + } + + const body = await this.readBody(req, res); + if (body === undefined) return; // readBody already responded (413/400) + + await this.dispatch(req, res, body); + } catch (e) { + logger.warn('[mcp] request error', e); + if (!res.headersSent) this.send(res, 500, { error: 'Internal error.' }); + else res.end(); + } + } + + /** + * Buffer the request body while counting bytes - never trust Content-Length. Responds 413 + * (and destroys the socket) on overflow, 400 on invalid JSON, and returns undefined in both + * cases so the caller stops. An empty body parses to undefined-JSON handled by the SDK. + */ + private async readBody(req: IncomingMessage, res: ServerResponse): Promise { + const chunks: Buffer[] = []; + let total = 0; + let overflow = false; + try { + for await (const chunk of req) { + const buf = chunk as Buffer; + total += buf.length; + if (total > MAX_BODY_BYTES) { + overflow = true; + break; + } + chunks.push(buf); + } + } catch { + this.send(res, 400, { error: 'Malformed request body.' }); + return undefined; + } + if (overflow) { + this.send(res, 413, { error: 'Request body too large.' }); + req.destroy(); + return undefined; + } + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw.trim()) return {}; + try { + return JSON.parse(raw); + } catch { + this.send(res, 400, { error: 'Invalid JSON.' }); + return undefined; + } + } + + /** Build a fresh stateless McpServer + transport and hand off the pre-parsed body. */ + private async dispatch(req: IncomingMessage, res: ServerResponse, body: unknown): Promise { + const mcp = new McpServer( + { name: 'devharbor', version: this.ctx.appVersion }, + { instructions: SERVER_INSTRUCTIONS } + ); + registerTools(mcp, this.ctx); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + res.on('close', () => { + void transport.close(); + void mcp.close(); + }); + await mcp.connect(transport); + await transport.handleRequest(req, res, body); + } +} diff --git a/src/main/mcp/__tests__/fakeContext.ts b/src/main/mcp/__tests__/fakeContext.ts new file mode 100644 index 0000000..7d25c43 --- /dev/null +++ b/src/main/mcp/__tests__/fakeContext.ts @@ -0,0 +1,282 @@ +import type { App, AppId, EnvVar, RunningTask, Task, TaskId, WorkspaceCandidate } from '@shared/types'; +import type { CreateAppInput, GlobalLogMatch } from '@shared/ipc'; +import type { McpToolContext } from '../context'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * In-memory McpToolContext for unit-testing the tool catalog without electron/db. Only the + * surface the tools touch is implemented; everything is a plain object cast to the service + * types, matching the repo's existing fake-by-cast test convention. + */ +export interface FakeState { + apps: App[]; + tasks: Record; + appEnv: Record; + globalEnv: EnvVar[]; + running: RunningTask[]; + buffers: Record; + states: Record; + searchMatches: GlobalLogMatch[]; + removed: string[]; + setAppCalls: Array<{ appId: string; vars: EnvVar[] }>; + startedApps: string[]; + notifyChangedCalls: number; + createAppCalls: CreateAppInput[]; + detection: { + scripts: Record; + suggestedDefaultScript: string | null; + workspaces: WorkspaceCandidate[]; + }; +} + +export function makeApp(partial: Omit, 'id'> & { id: string; name: string; path: string }): App { + return { + color: '#fff', + nodeVersionPref: { kind: 'auto' }, + packageManager: 'npm', + defaultScript: 'dev', + customCommand: null, + workingDir: partial.path, + autoRestartOnChange: false, + autoStart: false, + watchGlobs: [], + portHint: null, + tags: [], + folder: null, + lastStartedAt: null, + lastExitCode: null, + createdAt: 1, + updatedAt: 1, + ...partial, + id: partial.id as AppId + } as App; +} + +export function makeTask( + partial: Omit, 'id' | 'appId'> & { id: string; appId: string; name: string } +): Task { + return { + position: 0, + commandKind: 'script', + script: 'dev', + customCommand: null, + workingDirOverride: null, + packageManagerOverride: null, + nodeVersionPrefOverride: null, + dependsOn: [], + readiness: { kind: 'none' }, + oneShot: false, + enabled: true, + envOverrides: {}, + createdAt: 1, + updatedAt: 1, + ...partial, + id: partial.id as TaskId, + appId: partial.appId as AppId + } as Task; +} + +export function makeContext(state: Partial = {}): { ctx: McpToolContext; state: FakeState } { + const s: FakeState = { + apps: [], + tasks: {}, + appEnv: {}, + globalEnv: [], + running: [], + buffers: {}, + states: {}, + searchMatches: [], + removed: [], + setAppCalls: [], + startedApps: [], + notifyChangedCalls: 0, + createAppCalls: [], + detection: { scripts: {}, suggestedDefaultScript: null, workspaces: [] }, + ...state + }; + + const runner = { + list: () => s.running, + // Port-ownership labels (PortDetector passthrough). Tests can extend per-case. + ports: { portOwners: (_id: TaskId) => [] as Array<{ port: number; pid: number | null; process: string | null }> }, + get: (id: TaskId) => s.running.find((r) => r.taskId === id) ?? null, + isRunning: (id: TaskId) => + s.running.some((r) => r.taskId === id && (r.state === 'running' || r.state === 'starting')), + tailBuffer: (id: TaskId, n = 200) => { + const lines = (s.buffers[id] ?? '').split('\n'); + return lines.slice(Math.max(0, lines.length - n)).join('\n'); + }, + readBuffer: (id: TaskId) => s.buffers[id] ?? '', + bufferedTaskIds: () => Object.keys(s.buffers) as TaskId[], + start: async (task: Task) => ({ + snapshot: s.running.find((r) => r.taskId === task.id) ?? { + taskId: task.id, + appId: task.appId, + pid: 1, + state: 'starting', + ready: false, + startedAt: 1, + command: 'x', + nodeVersion: '20', + packageManager: 'npm', + cpu: 0, + memMB: 0, + ports: [], + exitCode: null, + exitSignal: null + }, + awaitReady: Promise.resolve(true) + }) + }; + + const orchestrator = { + runner, + appState: (id: AppId) => s.states[id] ?? 'idle', + startApp: async (id: AppId) => { + s.startedApps.push(id); + }, + stopApp: async () => {}, + restartApp: async (id: AppId) => { + s.startedApps.push(id); + }, + startTask: async () => {}, + stopTask: async () => {}, + clearOutcome: () => {} + }; + + const registry = { + list: () => s.apps, + get: (id: AppId) => s.apps.find((a) => a.id === id) ?? null, + getByPath: (p: string) => s.apps.find((a) => a.path === p) ?? null, + remove: (id: AppId) => { + s.removed.push(id); + s.apps = s.apps.filter((a) => a.id !== id); + }, + update: (id: AppId, patch: Partial) => { + const a = s.apps.find((x) => x.id === id)!; + Object.assign(a, patch); + return a; + } + }; + + const taskRegistry = { + list: (appId: AppId) => s.tasks[appId] ?? [], + get: (id: TaskId) => { + for (const arr of Object.values(s.tasks)) { + const t = arr.find((x) => x.id === id); + if (t) return t; + } + return null; + }, + add: (appId: AppId, patch: Partial) => { + const t = makeTask({ ...patch, id: `task-${String(patch.name)}`, appId, name: String(patch.name) }); + (s.tasks[appId] ??= []).push(t); + return t; + }, + // Mirrors the real registry's dependent guard so remove_task error paths are testable. + remove: (id: TaskId) => { + for (const [appId, arr] of Object.entries(s.tasks)) { + const t = arr.find((x) => x.id === id); + if (!t) continue; + const dependents = arr.filter((x) => x.dependsOn.includes(id)); + if (dependents.length) { + throw new Error( + `Can't remove "${t.name}" - these tasks depend on it: ${dependents.map((d) => d.name).join(', ')}` + ); + } + s.tasks[appId] = arr.filter((x) => x.id !== id); + return; + } + throw new Error(`Task not found: ${id}`); + }, + update: (id: TaskId, patch: Partial) => { + for (const arr of Object.values(s.tasks)) { + const t = arr.find((x) => x.id === id); + if (t) { + Object.assign(t, patch); + return t; + } + } + throw new Error(`Task not found: ${id}`); + } + }; + + const envStore = { + getGlobal: () => s.globalEnv, + getApp: (id: AppId) => s.appEnv[id] ?? [], + getTask: () => [], + setApp: (id: AppId, vars: EnvVar[]) => { + s.setAppCalls.push({ appId: id, vars }); + s.appEnv[id] = vars; + } + }; + + const appOps = { + searchLogs: () => s.searchMatches, + scanFolder: async () => [], + removeApp: (id: AppId) => { + const st = orchestrator.appState(id); + if (st === 'running' || st === 'starting' || st === 'exiting') { + throw new Error('Stop the app before removing it.'); + } + registry.remove(id); + }, + updateApp: (id: AppId, patch: Partial) => registry.update(id, patch), + // Captures the atomic-create input and materialises firstTask/tasks so add_app's + // post-create taskRegistry.list reflects what would have been created. + createApp: async (input: CreateAppInput) => { + s.createAppCalls.push(input); + const app = s.apps[0]!; + const specs = [...(input.firstTask ? [input.firstTask] : []), ...(input.tasks ?? [])]; + for (const spec of specs) { + taskRegistry.add(app.id, { + name: spec.name, + commandKind: spec.commandKind, + script: spec.script ?? null, + customCommand: spec.customCommand ?? null, + workingDirOverride: spec.workingDirOverride ?? null + }); + } + return app; + } + }; + + const ctx: McpToolContext = { + appVersion: '9.9.9', + registry: registry as any, + taskRegistry: taskRegistry as any, + orchestrator: orchestrator as any, + runner: runner as any, + envStore: envStore as any, + detector: { detect: async () => s.detection } as any, + nodes: { list: () => [{ source: 'system', version: '20.0.0', binDir: '/usr/bin' }] } as any, + runHistory: { list: () => [] } as any, + settings: { get: () => 0 } as any, + appOps: appOps as any, + envFiles: () => [], + notifyChanged: () => { + s.notifyChangedCalls++; + } + }; + + return { ctx, state: s }; +} + +/** Capture registered tools so a test can invoke a handler directly. */ +export interface CapturedTool { + name: string; + config: any; + handler: (args: any) => any; +} + +export function captureTools(register: (server: any, ctx: McpToolContext) => void, ctx: McpToolContext): Map { + const tools = new Map(); + const fakeServer = { + registerTool: (name: string, config: any, handler: (args: any) => any) => { + tools.set(name, { name, config, handler }); + } + }; + register(fakeServer as any, ctx); + return tools; +} diff --git a/src/main/mcp/__tests__/gate.test.ts b/src/main/mcp/__tests__/gate.test.ts new file mode 100644 index 0000000..c56074f --- /dev/null +++ b/src/main/mcp/__tests__/gate.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { bearerMatches, hostHeaderAllowed, originAllowed } from '../gate'; + +describe('hostHeaderAllowed', () => { + it('accepts exact loopback hosts with or without a port', () => { + for (const h of ['127.0.0.1', '127.0.0.1:6872', 'localhost', 'localhost:6872', '[::1]', '[::1]:6872']) { + expect(hostHeaderAllowed(h)).toBe(true); + } + }); + + it('rejects rebinding-style and remote hosts', () => { + for (const h of ['localhost.evil.com', '127.0.0.1.evil.com', 'evil.com', 'example.com:6872', '10.0.0.5']) { + expect(hostHeaderAllowed(h)).toBe(false); + } + }); + + it('fails closed on a missing Host header', () => { + expect(hostHeaderAllowed(undefined)).toBe(false); + }); +}); + +describe('originAllowed', () => { + it('allows a missing Origin (non-browser client)', () => { + expect(originAllowed(undefined)).toBe(true); + }); + + it('allows loopback origins', () => { + expect(originAllowed('http://127.0.0.1:6274')).toBe(true); + expect(originAllowed('http://localhost:3000')).toBe(true); + }); + + it('rejects remote origins and the literal "null"', () => { + expect(originAllowed('https://evil.com')).toBe(false); + expect(originAllowed('null')).toBe(false); + expect(originAllowed('not a url')).toBe(false); + }); +}); + +describe('bearerMatches', () => { + const token = randomBytes(32).toString('hex'); + + it('matches the correct bearer token', () => { + expect(bearerMatches(`Bearer ${token}`, token)).toBe(true); + expect(bearerMatches(`bearer ${token}`, token)).toBe(true); + }); + + it('rejects a wrong token WITHOUT throwing on length mismatch', () => { + expect(bearerMatches('Bearer short', token)).toBe(false); + expect(bearerMatches(`Bearer ${randomBytes(32).toString('hex')}`, token)).toBe(false); + expect(bearerMatches(`Bearer ${token}extra`, token)).toBe(false); + }); + + it('rejects malformed or missing headers', () => { + expect(bearerMatches(undefined, token)).toBe(false); + expect(bearerMatches('', token)).toBe(false); + expect(bearerMatches(token, token)).toBe(false); // no "Bearer " prefix + expect(bearerMatches('Basic abc', token)).toBe(false); + }); + + it('never matches against an empty expected token', () => { + expect(bearerMatches('Bearer anything', '')).toBe(false); + }); +}); diff --git a/src/main/mcp/__tests__/hygiene.test.ts b/src/main/mcp/__tests__/hygiene.test.ts new file mode 100644 index 0000000..2921c8a --- /dev/null +++ b/src/main/mcp/__tests__/hygiene.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { cleanLogText, redactSecrets, stripAnsi, tailLines } from '../hygiene'; + +describe('stripAnsi', () => { + it('removes CSI color/cursor sequences', () => { + expect(stripAnsi('\x1b[32mready\x1b[0m')).toBe('ready'); + expect(stripAnsi('a\x1b[2Kb')).toBe('ab'); + }); + + it('removes OSC sequences (terminal hyperlinks/titles), BEL- or ST-terminated', () => { + expect(stripAnsi('\x1b]8;;http://x\x07link\x1b]8;;\x07')).toBe('link'); + expect(stripAnsi('\x1b]0;title\x1b\\text')).toBe('text'); + }); + + it('leaves plain text untouched', () => { + expect(stripAnsi('no escapes here')).toBe('no escapes here'); + }); +}); + +describe('redactSecrets', () => { + it('replaces every occurrence of a known secret value', () => { + const out = redactSecrets('url=postgres://u:sup3rsecret@h/db sup3rsecret', ['sup3rsecret']); + expect(out).toBe('url=postgres://u:[redacted]@h/db [redacted]'); + }); + + it('skips values shorter than 4 chars to avoid shredding output', () => { + expect(redactSecrets('a=1 b=1 c=1', ['1'])).toBe('a=1 b=1 c=1'); + }); + + it('treats secret values literally, not as regex', () => { + // As a regex, "a.bc" would also match "axbc" (dot = any char); literally it must not. + expect(redactSecrets('a.bc.d and axbc', ['a.bc'])).toBe('[redacted].d and axbc'); + }); + + it('no-ops when the secret does not appear', () => { + expect(redactSecrets('nothing here', ['absent-value'])).toBe('nothing here'); + }); +}); + +describe('cleanLogText', () => { + it('strips ANSI then redacts (order matters for split secrets)', () => { + const out = cleanLogText('\x1b[32mtoken=abcd1234\x1b[0m', ['abcd1234']); + expect(out).toBe('token=[redacted]'); + }); +}); + +describe('tailLines', () => { + it('returns the last N newline-delimited lines', () => { + expect(tailLines('a\nb\nc\nd', 2)).toBe('c\nd'); + expect(tailLines('only', 5)).toBe('only'); + }); +}); diff --git a/src/main/mcp/__tests__/resolve.test.ts b/src/main/mcp/__tests__/resolve.test.ts new file mode 100644 index 0000000..91e4924 --- /dev/null +++ b/src/main/mcp/__tests__/resolve.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { resolveApp, resolveTask, ResolutionError } from '../context'; +import { makeApp, makeContext, makeTask } from './fakeContext'; + +describe('resolveApp', () => { + const apps = [ + makeApp({ id: 'id-web', name: 'web', path: '/proj/web' }), + makeApp({ id: 'id-api', name: 'api', path: '/proj/api' }), + makeApp({ id: 'id-dupe1', name: 'dupe', path: '/proj/d1' }), + makeApp({ id: 'id-dupe2', name: 'dupe', path: '/proj/d2' }) + ]; + + it('resolves by id', () => { + const { ctx } = makeContext({ apps }); + expect(resolveApp(ctx, 'id-api').name).toBe('api'); + }); + + it('resolves by exact name, case-insensitively', () => { + const { ctx } = makeContext({ apps }); + expect(resolveApp(ctx, 'WEB').id).toBe('id-web'); + }); + + it('resolves by absolute path', () => { + const { ctx } = makeContext({ apps }); + expect(resolveApp(ctx, '/proj/web').id).toBe('id-web'); + // trailing slash tolerated + expect(resolveApp(ctx, '/proj/web/').id).toBe('id-web'); + }); + + it('errors on an ambiguous name and lists candidates', () => { + const { ctx } = makeContext({ apps }); + try { + resolveApp(ctx, 'dupe'); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ResolutionError); + expect((e as Error).message).toContain('ambiguous'); + expect((e as Error).message).toContain('id-dupe1'); + } + }); + + it('errors on unknown with nearest-match hint', () => { + const { ctx } = makeContext({ apps }); + expect(() => resolveApp(ctx, 'webby')).toThrow(/No app matches "webby"/); + // substring hint points at "web" + try { + resolveApp(ctx, 'we'); + } catch (e) { + expect((e as Error).message).toContain('web'); + } + }); + + it('errors clearly for an unregistered absolute path', () => { + const { ctx } = makeContext({ apps }); + expect(() => resolveApp(ctx, '/nope/here')).toThrow(/No app is registered at path/); + }); +}); + +describe('resolveTask', () => { + const apps = [makeApp({ id: 'id-web', name: 'web', path: '/proj/web' })]; + const tasks = { + 'id-web': [ + makeTask({ id: 'task-dev', appId: 'id-web', name: 'dev' }), + makeTask({ id: 'task-worker', appId: 'id-web', name: 'worker' }) + ] + }; + + it('resolves by task id', () => { + const { ctx } = makeContext({ apps, tasks }); + expect(resolveTask(ctx, 'task-worker').name).toBe('worker'); + }); + + it('resolves a globally-unique task name without app scope', () => { + const { ctx } = makeContext({ apps, tasks }); + expect(resolveTask(ctx, 'worker').id).toBe('task-worker'); + }); + + it('resolves a name scoped by app', () => { + const { ctx } = makeContext({ apps, tasks }); + expect(resolveTask(ctx, 'dev', 'web').id).toBe('task-dev'); + }); + + it('errors when the name is ambiguous across apps', () => { + const apps2 = [...apps, makeApp({ id: 'id-api', name: 'api', path: '/proj/api' })]; + const tasks2 = { + ...tasks, + 'id-api': [makeTask({ id: 'task-dev2', appId: 'id-api', name: 'dev' })] + }; + const { ctx } = makeContext({ apps: apps2, tasks: tasks2 }); + expect(() => resolveTask(ctx, 'dev')).toThrow(/ambiguous/); + }); + + it('errors on an unknown task name', () => { + const { ctx } = makeContext({ apps, tasks }); + expect(() => resolveTask(ctx, 'nope')).toThrow(/No task named/); + }); +}); diff --git a/src/main/mcp/__tests__/tls.test.ts b/src/main/mcp/__tests__/tls.test.ts new file mode 100644 index 0000000..dcdd0c2 --- /dev/null +++ b/src/main/mcp/__tests__/tls.test.ts @@ -0,0 +1,42 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ensureTlsMaterial } from '../tls'; + +describe('ensureTlsMaterial (uses the system openssl)', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'devharbor-tls-')); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('generates a key + self-signed cert on first use', () => { + const m = ensureTlsMaterial(dir); + expect(m.key).toContain('PRIVATE KEY'); + expect(m.cert).toContain('BEGIN CERTIFICATE'); + expect(m.certPath).toBe(join(dir, 'mcp-cert.pem')); + // Key is user-only (0600) - same posture as an ssh key. + const mode = statSync(join(dir, 'mcp-key.pem')).mode & 0o777; + expect(mode).toBe(0o600); + }, 30_000); + + it('the certificate carries loopback SANs', () => { + ensureTlsMaterial(dir); + const text = execFileSync('openssl', ['x509', '-in', join(dir, 'mcp-cert.pem'), '-noout', '-text']).toString(); + expect(text).toContain('DNS:localhost'); + expect(text).toContain('127.0.0.1'); + }, 30_000); + + it('reuses existing material instead of regenerating', () => { + const first = ensureTlsMaterial(dir); + const second = ensureTlsMaterial(dir); + expect(second.cert).toBe(first.cert); + expect(second.key).toBe(first.key); + }); +}); diff --git a/src/main/mcp/__tests__/tools.test.ts b/src/main/mcp/__tests__/tools.test.ts new file mode 100644 index 0000000..6faf452 --- /dev/null +++ b/src/main/mcp/__tests__/tools.test.ts @@ -0,0 +1,637 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { EnvVar, RunningTask } from '@shared/types'; +import { registerTools } from '../tools'; +import { captureTools, makeApp, makeContext, makeTask, type CapturedTool } from './fakeContext'; + +/** Parse the first content block of a tool result as JSON. */ +function firstJson(result: { content: Array<{ text: string }> }): any { + return JSON.parse(result.content[0]!.text); +} + +async function call(tool: CapturedTool, args: Record = {}) { + return await tool.handler(args); +} + +const secretEnv = (key: string, value: string): EnvVar => ({ + id: key, + appId: null, + key, + value, + enabled: true, + isSecret: true +}); + +const runningTask = (taskId: string, appId: string, over: Partial = {}): RunningTask => ({ + taskId: taskId as RunningTask['taskId'], + appId: appId as RunningTask['appId'], + pid: 100, + state: 'running', + ready: true, + readinessKind: 'none', + startedAt: 1, + command: 'npm run dev', + nodeVersion: '20', + packageManager: 'npm', + cpu: 1, + memMB: 10, + ports: [3000], + exitCode: null, + exitSignal: null, + ...over +}); + +function setup(stateOver = {}) { + const apps = [makeApp({ id: 'id-web', name: 'web', path: '/proj/web' })]; + const tasks = { 'id-web': [makeTask({ id: 'task-dev', appId: 'id-web', name: 'dev' })] }; + const { ctx, state } = makeContext({ apps, tasks, ...stateOver }); + const tools = captureTools(registerTools, ctx); + return { ctx, state, tools }; +} + +describe('tool catalog registration', () => { + it('registers the full 22-tool catalog with read-only hints on reads', () => { + const { tools } = setup(); + expect(tools.size).toBe(22); + expect(tools.get('list_apps')!.config.annotations.readOnlyHint).toBe(true); + expect(tools.get('remove_app')!.config.annotations.destructiveHint).toBe(true); + expect(tools.get('remove_task')!.config.annotations.destructiveHint).toBe(true); + // A mutating tool must NOT be flagged read-only. + expect(tools.get('start_app')!.config.annotations.readOnlyHint).toBeUndefined(); + expect(tools.get('add_task')!.config.annotations.readOnlyHint).toBeUndefined(); + }); +}); + +describe('get_overview / list_apps', () => { + it('summarises counts and per-app state', async () => { + const { tools } = setup({ states: { 'id-web': 'running' }, running: [runningTask('task-dev', 'id-web')] }); + const overview = firstJson(await call(tools.get('get_overview')!)); + expect(overview.appCount).toBe(1); + expect(overview.running).toBe(1); + expect(overview.activePorts).toEqual([3000]); + expect(overview.devharborVersion).toBe('9.9.9'); + }); + + it('filters list_apps by state', async () => { + const { tools } = setup({ states: { 'id-web': 'running' } }); + const running = firstJson(await call(tools.get('list_apps')!, { state: 'running' })); + expect(running.count).toBe(1); + const idle = firstJson(await call(tools.get('list_apps')!, { state: 'idle' })); + expect(idle.count).toBe(0); + }); +}); + +describe('list_env_vars masking', () => { + it('returns non-secret values but masks secret ones', async () => { + const appEnv = { + 'id-web': [ + { id: '1', appId: 'id-web', key: 'PORT', value: '3000', enabled: true, isSecret: false } as EnvVar, + secretEnv('API_KEY', 'super-secret-value') + ] + }; + const { tools } = setup({ appEnv }); + const res = firstJson(await call(tools.get('list_env_vars')!, { app: 'web' })); + const port = res.vars.find((v: any) => v.key === 'PORT'); + const apiKey = res.vars.find((v: any) => v.key === 'API_KEY'); + expect(port.value).toBe('3000'); + expect(apiKey.value).toBeNull(); + expect(apiKey.isSecret).toBe(true); + }); +}); + +describe('get_logs hygiene', () => { + it('strips ANSI and redacts known secret values from the log text block', async () => { + const buffers = { 'task-dev': '\x1b[32mstarting\x1b[0m\nDATABASE_URL=postgres://u:hunter2xyz@h/db' }; + const appEnv = { 'id-web': [secretEnv('DATABASE_URL', 'hunter2xyz')] }; + const running = [runningTask('task-dev', 'id-web')]; + const { tools } = setup({ buffers, appEnv, running }); + const res = await call(tools.get('get_logs')!, { app: 'web' }); + const logText = res.content[1].text as string; + expect(logText).toContain('starting'); + expect(logText).not.toContain('\x1b['); + expect(logText).not.toContain('hunter2xyz'); + expect(logText).toContain('[redacted]'); + }); +}); + +describe('search_logs', () => { + it('reports interpretation mode and redacts matched lines', async () => { + const searchMatches = [ + { appId: 'id-web' as any, taskId: 'task-dev' as any, appName: 'web', taskName: 'dev', line: 'key=topsecretval' } + ]; + const appEnv = { 'id-web': [secretEnv('KEY', 'topsecretval')] }; + const { tools } = setup({ searchMatches, appEnv }); + const res = firstJson(await call(tools.get('search_logs')!, { query: 'key', app: 'web' })); + expect(res.interpretedAs).toBe('literal'); + expect(res.matches[0].line).toContain('[redacted]'); + expect(res.matches[0].line).not.toContain('topsecretval'); + }); + + it('errors on an invalid pattern when regex is true (no silent literal downgrade)', async () => { + const { tools } = setup(); + const res = await call(tools.get('search_logs')!, { query: 'foo(', regex: true }); + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/[Ii]nvalid regular expression/); + }); + + it('treats the same invalid string as a fine literal query when regex is false', async () => { + const { tools } = setup(); + const res = firstJson(await call(tools.get('search_logs')!, { query: 'foo(' })); + expect(res.interpretedAs).toBe('literal'); + expect(res.count).toBe(0); + }); +}); + +describe('set_env_var', () => { + it('applies the name-based secret heuristic and marks the value write-only', async () => { + const { tools, state } = setup(); + const res = firstJson(await call(tools.get('set_env_var')!, { app: 'web', key: 'API_TOKEN', value: 'abc' })); + expect(res.isSecret).toBe(true); + expect(res.note).toMatch(/write-only/); + expect(state.setAppCalls).toHaveLength(1); + const saved = state.setAppCalls[0]!.vars.find((v) => v.key === 'API_TOKEN'); + expect(saved!.isSecret).toBe(true); + }); + + it('honours an explicit secret: false override', async () => { + const { tools } = setup(); + const res = firstJson(await call(tools.get('set_env_var')!, { app: 'web', key: 'API_TOKEN', value: 'abc', secret: false })); + expect(res.isSecret).toBe(false); + }); +}); + +describe('remove_app guard', () => { + it('refuses without confirm: true', async () => { + const { tools, state } = setup(); + const res = await call(tools.get('remove_app')!, { app: 'web' }); + expect(res.isError).toBe(true); + expect(state.removed).toHaveLength(0); + }); + + it('refuses while the app is running', async () => { + const { tools, state } = setup({ states: { 'id-web': 'running' } }); + const res = await call(tools.get('remove_app')!, { app: 'web', confirm: true }); + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/Stop the app/); + expect(state.removed).toHaveLength(0); + }); + + it('removes when confirmed and idle', async () => { + const { tools, state } = setup(); + const res = firstJson(await call(tools.get('remove_app')!, { app: 'web', confirm: true })); + expect(res.removed).toBe('web'); + expect(state.removed).toEqual(['id-web']); + }); +}); + +describe('readiness honesty (feedback fix)', () => { + it('reports ready: null for a running task with no readiness probe', async () => { + // Default makeTask readiness is { kind: 'none' } - the runner would say ready: true. + const running = [runningTask('task-dev', 'id-web', { ready: true })]; + const { tools } = setup({ running, states: { 'id-web': 'running' } }); + const res = firstJson(await call(tools.get('get_app')!, { app: 'web' })); + expect(res.tasks[0].ready).toBeNull(); + const lr = firstJson(await call(tools.get('list_running')!)); + expect(lr.running[0].ready).toBeNull(); + }); + + it('start_app reports readinessVerified: null + explanatory note for unprobed tasks', async () => { + const running = [runningTask('task-dev', 'id-web', { ready: true })]; + const buffers = { 'task-dev': 'listening on 3000' }; + const { ctx, tools, state } = setup({ running, buffers }); + (ctx.orchestrator as any).startApp = async () => { + state.states['id-web'] = 'running'; + }; + const res = await call(tools.get('start_app')!, { app: 'web' }); + const header = JSON.parse(res.content[0].text); + expect(header.readinessVerified).toBeNull(); + expect(header.note).toMatch(/no readiness probe/i); + // The unprobed running task's tail is included so the agent can judge. + expect(res.content[1].text).toContain('listening on 3000'); + }); + + it('reports true ready for a PROBED task that reached readiness', async () => { + const tasks = { + 'id-web': [makeTask({ id: 'task-dev', appId: 'id-web', name: 'dev', readiness: { kind: 'port', port: 3000 } })] + }; + // The LIVE run was spawned WITH the port probe (readinessKind reflects that). + const running = [runningTask('task-dev', 'id-web', { ready: true, readinessKind: 'port' })]; + const { tools } = setup({ tasks, running, states: { 'id-web': 'running' } }); + const res = firstJson(await call(tools.get('get_app')!, { app: 'web' })); + expect(res.tasks[0].ready).toBe(true); + }); +}); + +describe('port labels (feedback fix)', () => { + it('get_ports includes per-port process ownership', async () => { + const running = [runningTask('task-dev', 'id-web', { ports: [3000, 4000] })]; + const { ctx, tools } = setup({ running }); + (ctx.runner as any).ports.portOwners = () => [ + { port: 3000, pid: 111, process: 'next-server (v15)' }, + { port: 4000, pid: 222, process: 'node index.js' } + ]; + const all = firstJson(await call(tools.get('get_ports')!)); + expect(all.apps[0].portDetails).toHaveLength(2); + expect(all.apps[0].portDetails[0]).toMatchObject({ port: 3000, process: 'next-server (v15)', task: 'dev' }); + const one = firstJson(await call(tools.get('get_ports')!, { port: 4000 })); + expect(one.process).toBe('node index.js'); + expect(one.task).toBe('dev'); + }); + + it('get_ports prefers the confirmed (pid set) owner over a log-hinted claim', async () => { + const apps = [ + makeApp({ id: 'id-a', name: 'appA', path: '/proj/a' }), + makeApp({ id: 'id-b', name: 'appB', path: '/proj/b' }) + ]; + const tasks = { + 'id-a': [makeTask({ id: 'task-a', appId: 'id-a', name: 'dev' })], + 'id-b': [makeTask({ id: 'task-b', appId: 'id-b', name: 'dev' })] + }; + // Both apps "have" port 3000, but only appB actually listens (pid set); appA only hinted it. + const running = [runningTask('task-a', 'id-a', { ports: [3000] }), runningTask('task-b', 'id-b', { ports: [3000] })]; + const { ctx, tools } = setup({ apps, tasks, running }); + (ctx.runner as any).ports.portOwners = (id: string) => + id === 'task-b' ? [{ port: 3000, pid: 999, process: 'real-server' }] : [{ port: 3000, pid: null, process: null }]; + const res = firstJson(await call(tools.get('get_ports')!, { port: 3000 })); + expect(res.owner).toBe('appB'); + expect(res.process).toBe('real-server'); + }); +}); + +describe('listening signal (feedback fix)', () => { + it('reports listening: true for a running task with ports, ready still null (no probe)', async () => { + const running = [runningTask('task-dev', 'id-web', { ready: true, ports: [3000] })]; + const { tools } = setup({ running, states: { 'id-web': 'running' } }); + const res = firstJson(await call(tools.get('get_app')!, { app: 'web' })); + expect(res.tasks[0].listening).toBe(true); + expect(res.tasks[0].ready).toBeNull(); + const lr = firstJson(await call(tools.get('list_running')!)); + expect(lr.running[0].listening).toBe(true); + }); + + it('reports listening: false when running but no ports yet', async () => { + const running = [runningTask('task-dev', 'id-web', { ready: true, ports: [] })]; + const { tools } = setup({ running, states: { 'id-web': 'running' } }); + const res = firstJson(await call(tools.get('get_app')!, { app: 'web' })); + expect(res.tasks[0].listening).toBe(false); + }); +}); + +describe('set_readiness (feedback fix)', () => { + it('sets an http readiness probe on the task', async () => { + const { tools, ctx } = setup(); + const res = firstJson( + await call(tools.get('set_readiness')!, { app: 'web', task: 'dev', kind: 'http', url: 'http://localhost:4000/ready' }) + ); + expect(res.readiness).toEqual({ kind: 'http', url: 'http://localhost:4000/ready', status: undefined }); + // Persisted on the task. + expect((ctx.taskRegistry.get('task-dev' as any) as any).readiness.kind).toBe('http'); + }); + + it('validates required fields per kind', async () => { + const { tools } = setup(); + expect((await call(tools.get('set_readiness')!, { app: 'web', task: 'dev', kind: 'port' })).isError).toBe(true); + expect((await call(tools.get('set_readiness')!, { app: 'web', task: 'dev', kind: 'http', url: 'not a url' })).isError).toBe(true); + expect((await call(tools.get('set_readiness')!, { app: 'web', task: 'dev', kind: 'log', regex: '(' })).isError).toBe(true); + }); + + it('sets a port probe', async () => { + const { tools } = setup(); + const res = firstJson(await call(tools.get('set_readiness')!, { app: 'web', task: 'dev', kind: 'port', port: 4000 })); + expect(res.readiness).toEqual({ kind: 'port', port: 4000 }); + }); + + it('rejects a non-loopback http url (SSRF guard)', async () => { + const { tools } = setup(); + const bad = await call(tools.get('set_readiness')!, { + app: 'web', + task: 'dev', + kind: 'http', + url: 'http://169.254.169.254/latest/meta-data' + }); + expect(bad.isError).toBe(true); + expect(bad.content[0].text).toMatch(/localhost|loopback/i); + const ok = firstJson( + await call(tools.get('set_readiness')!, { app: 'web', task: 'dev', kind: 'http', url: 'http://localhost:4000/ready' }) + ); + expect(ok.readiness.kind).toBe('http'); + }); + + it('does NOT flip a running task ready:null -> true until restart (edited-config honesty)', async () => { + // A running task whose LIVE watcher is kind none (readinessKind 'none'), ready vacuously true. + const running = [runningTask('task-dev', 'id-web', { ready: true, readinessKind: 'none' })]; + const { tools } = setup({ running, states: { 'id-web': 'running' } }); + // Configure an http probe - takes effect on next start, not now. + await call(tools.get('set_readiness')!, { app: 'web', task: 'dev', kind: 'http', url: 'http://localhost:4000/ready' }); + // The stored config is now http, but the live run still has no probe -> ready stays null. + const res = firstJson(await call(tools.get('get_app')!, { app: 'web' })); + expect(res.tasks[0].ready).toBeNull(); + const lr = firstJson(await call(tools.get('list_running')!)); + expect(lr.running[0].ready).toBeNull(); + }); +}); + +describe('list_running unresolvable task (feedback fix)', () => { + it('reports ready: null when the task row is gone (not the vacuous raw flag)', async () => { + // A running entry whose task is NOT in the registry (tasks: {} => taskRegistry.get null). + const running = [runningTask('ghost-task', 'id-web', { ready: true })]; + const { ctx, tools } = setup({ running }); + (ctx.taskRegistry as any).get = () => null; + const lr = firstJson(await call(tools.get('list_running')!)); + expect(lr.running[0].ready).toBeNull(); + }); +}); + +describe('start_app honesty', () => { + it('re-reads app state after start rather than trusting resolution', async () => { + // Orchestrator flips the app to crashed; a crash tail should be attached. + const buffers = { 'task-dev': 'Error: boom\nstack line' }; + const { ctx, tools, state } = setup({ buffers, states: { 'id-web': 'idle' } }); + // make startApp set state to crashed + (ctx.orchestrator as any).startApp = async () => { + state.states['id-web'] = 'crashed'; + }; + const res = await call(tools.get('start_app')!, { app: 'web' }); + const header = JSON.parse(res.content[0].text); + expect(header.state).toBe('crashed'); + expect(header.readinessVerified).toBe(false); + // failure tail present as a second block + expect(res.content[1].text).toContain('boom'); + }); + + it('reports unknown apps as an actionable error', async () => { + const { tools } = setup(); + const res = await call(tools.get('start_app')!, { app: 'ghost' }); + expect(res.isError).toBe(true); + }); +}); + +describe('desktop auto-reflect (notifyChanged)', () => { + it('a mutating tool calls notifyChanged on success; a read-only tool does not', async () => { + const { tools, state } = setup(); + await call(tools.get('list_apps')!); // read-only + expect(state.notifyChangedCalls).toBe(0); + await call(tools.get('set_env_var')!, { app: 'web', key: 'X', value: '1', secret: false }); // mutating + expect(state.notifyChangedCalls).toBe(1); + await call(tools.get('remove_app')!, { app: 'web', confirm: true }); // mutating + expect(state.notifyChangedCalls).toBe(2); + }); + + it('does NOT notify when a mutating tool returns an error', async () => { + const { tools, state } = setup(); + await call(tools.get('remove_app')!, { app: 'web' }); // refused (no confirm) -> isError + expect(state.notifyChangedCalls).toBe(0); + }); +}); + +describe('add_app dedupe', () => { + it('returns the existing app when the path is already registered', async () => { + const { tools } = setup(); + const res = firstJson(await call(tools.get('add_app')!, { path: '/proj/web' })); + expect(res.note).toMatch(/already registered/); + expect(res.app.name).toBe('web'); + }); +}); + +describe('add_app workspaceTasks (monorepo)', () => { + const monoDetection = { + scripts: { dev: 'turbo dev' }, + suggestedDefaultScript: 'dev', + workspaces: [ + { name: '@acme/api', relPath: 'apps/api', scripts: ['dev'], suggestedScript: 'dev' }, + { name: '@acme/web', relPath: 'apps/web', scripts: ['dev', 'build'], suggestedScript: 'dev' }, + // No runnable script: must be skipped, like the UI does. + { name: '@acme/tsconfig', relPath: 'packages/tsconfig', scripts: [], suggestedScript: null } + ] + }; + + it('creates one task per runnable workspace package, mirroring the UI flow', async () => { + const { tools, state } = setup({ tasks: {}, detection: monoDetection }); + const res = firstJson(await call(tools.get('add_app')!, { path: '/repo/mono', workspaceTasks: true })); + const input = state.createAppCalls[0]!; + expect(input.firstTask).toBeNull(); + expect(input.tasks).toEqual([ + { name: '@acme/api', commandKind: 'script', script: 'dev', workingDirOverride: 'apps/api' }, + { name: '@acme/web', commandKind: 'script', script: 'dev', workingDirOverride: 'apps/web' } + ]); + expect(res.tasks).toEqual(['@acme/api', '@acme/web']); + expect(res.workspaces).toHaveLength(3); + }); + + it('falls back to the default single task with an honest note when nothing is runnable', async () => { + const { tools, state } = setup({ + tasks: {}, + detection: { scripts: { dev: 'vite' }, suggestedDefaultScript: 'dev', workspaces: [] } + }); + const res = firstJson(await call(tools.get('add_app')!, { path: '/repo/plain', workspaceTasks: true })); + expect(state.createAppCalls[0]!.tasks).toBeUndefined(); + expect(state.createAppCalls[0]!.firstTask).toEqual({ name: 'dev', commandKind: 'script', script: 'dev' }); + expect(res.note).toMatch(/no workspace packages with runnable scripts/); + }); + + it('flags a detected monorepo when workspaceTasks was not passed', async () => { + const { tools, state } = setup({ tasks: {}, detection: monoDetection }); + const res = firstJson(await call(tools.get('add_app')!, { path: '/repo/mono' })); + expect(state.createAppCalls[0]!.tasks).toBeUndefined(); + expect(res.note).toMatch(/Monorepo detected: 2 workspace package/); + expect(res.workspaces).toHaveLength(3); + }); + + it('points an already-registered monorepo at add_task instead', async () => { + const { tools } = setup({ detection: monoDetection }); + const res = firstJson(await call(tools.get('add_app')!, { path: '/proj/web', workspaceTasks: true })); + expect(res.note).toMatch(/already registered/); + expect(res.note).toMatch(/add_task/); + }); +}); + +describe('add_task', () => { + function monoSetup() { + const root = mkdtempSync(join(tmpdir(), 'dh-addtask-')); + mkdirSync(join(root, 'apps', 'api'), { recursive: true }); + const apps = [makeApp({ id: 'id-mono', name: 'mono', path: root })]; + const tasks = { 'id-mono': [makeTask({ id: 'task-migrate', appId: 'id-mono', name: 'migrate' })] }; + const { ctx, state } = makeContext({ apps, tasks }); + return { tools: captureTools(registerTools, ctx), state, root }; + } + + it('creates a script task with workingDir and dependsOn resolved by name', async () => { + const { tools, state } = monoSetup(); + const res = firstJson( + await call(tools.get('add_task')!, { + app: 'mono', + name: 'api', + script: 'dev', + workingDir: 'apps/api', + dependsOn: ['migrate'] + }) + ); + expect(res.task.name).toBe('api'); + expect(res.task.workingDir).toBe('apps/api'); + expect(res.task.dependsOn).toEqual(['task-migrate']); + expect(res.task.readiness).toEqual({ kind: 'none' }); + expect(state.tasks['id-mono']!.map((t) => t.name)).toEqual(['migrate', 'api']); + expect(state.notifyChangedCalls).toBe(1); + }); + + it('defaults a oneShot task to exit-code-0 readiness', async () => { + const { tools } = monoSetup(); + const res = firstJson( + await call(tools.get('add_task')!, { app: 'mono', name: 'build', command: 'pnpm build', oneShot: true }) + ); + expect(res.task.oneShot).toBe(true); + expect(res.task.readiness).toEqual({ kind: 'exit', code: 0 }); + }); + + it('requires exactly one of script or command', async () => { + const { tools } = monoSetup(); + const both = await call(tools.get('add_task')!, { app: 'mono', name: 'x', script: 'dev', command: 'ls' }); + expect(both.isError).toBe(true); + const neither = await call(tools.get('add_task')!, { app: 'mono', name: 'x' }); + expect(neither.isError).toBe(true); + }); + + it('rejects a duplicate task name (case-insensitive)', async () => { + const { tools } = monoSetup(); + const res = await call(tools.get('add_task')!, { app: 'mono', name: 'MIGRATE', script: 'dev' }); + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/already exists/); + }); + + it('rejects absolute, escaping, and missing workingDir values', async () => { + const { tools } = monoSetup(); + for (const workingDir of ['/etc', '../outside', 'apps/nope']) { + const res = await call(tools.get('add_task')!, { app: 'mono', name: `t-${workingDir}`, script: 'dev', workingDir }); + expect(res.isError).toBe(true); + } + }); + + it('errors when a dependsOn task cannot be resolved, creating nothing', async () => { + const { tools, state } = monoSetup(); + const res = await call(tools.get('add_task')!, { app: 'mono', name: 'api', script: 'dev', dependsOn: ['ghost'] }); + expect(res.isError).toBe(true); + expect(state.tasks['id-mono']!).toHaveLength(1); + expect(state.notifyChangedCalls).toBe(0); + }); +}); + +describe('remove_task', () => { + it('refuses without confirm: true', async () => { + const { tools, state } = setup(); + const res = await call(tools.get('remove_task')!, { app: 'web', task: 'dev' }); + expect(res.isError).toBe(true); + expect(state.tasks['id-web']!).toHaveLength(1); + }); + + it('refuses while the task is running', async () => { + const { tools } = setup({ running: [runningTask('task-dev', 'id-web')] }); + const res = await call(tools.get('remove_task')!, { app: 'web', task: 'dev', confirm: true }); + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/Stop the task/); + }); + + it('surfaces the dependent-tasks guard as a clear error', async () => { + const tasks = { + 'id-web': [ + makeTask({ id: 'task-db', appId: 'id-web', name: 'db' }), + makeTask({ id: 'task-dev', appId: 'id-web', name: 'dev', dependsOn: ['task-db' as any] }) + ] + }; + const { ctx } = makeContext({ apps: [makeApp({ id: 'id-web', name: 'web', path: '/proj/web' })], tasks }); + const tools = captureTools(registerTools, ctx); + const res = await call(tools.get('remove_task')!, { app: 'web', task: 'db', confirm: true }); + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/depend on it/); + }); + + it('removes an idle task when confirmed and notifies the desktop', async () => { + const { tools, state } = setup(); + const res = firstJson(await call(tools.get('remove_task')!, { app: 'web', task: 'dev', confirm: true })); + expect(res.removed).toBe('dev'); + expect(state.tasks['id-web']!).toHaveLength(0); + expect(state.notifyChangedCalls).toBe(1); + }); +}); + +describe('monorepo review fixes', () => { + it('workspaceTasks dedupes duplicate package names instead of failing the create', async () => { + const { tools, state } = setup({ + tasks: {}, + detection: { + scripts: {}, + suggestedDefaultScript: null, + workspaces: [ + { name: 'service', relPath: 'packages/a', scripts: ['dev'], suggestedScript: 'dev' }, + { name: 'service', relPath: 'packages/b', scripts: ['dev'], suggestedScript: 'dev' } + ] + } + }); + const res = firstJson(await call(tools.get('add_app')!, { path: '/repo/dup', workspaceTasks: true })); + expect(state.createAppCalls[0]!.tasks!.map((t) => t.name)).toEqual(['service', 'service (packages/b)']); + expect(res.tasks).toEqual(['service', 'service (packages/b)']); + }); + + it('workspaceTasks drops a candidate whose relPath escapes the app root', async () => { + const { state, tools } = setup({ + tasks: {}, + detection: { + scripts: {}, + suggestedDefaultScript: null, + workspaces: [ + { name: 'inside', relPath: 'apps/inside', scripts: ['dev'], suggestedScript: 'dev' }, + { name: 'escape', relPath: '../sibling', scripts: ['dev'], suggestedScript: 'dev' } + ] + } + }); + await call(tools.get('add_app')!, { path: '/repo/esc', workspaceTasks: true }); + expect(state.createAppCalls[0]!.tasks!.map((t) => t.name)).toEqual(['inside']); + }); + + it('add_task rejects a workingDir that is a file, not a directory', async () => { + const root = mkdtempSync(join(tmpdir(), 'dh-file-')); + mkdirSync(join(root, 'apps'), { recursive: true }); + writeFileSync(join(root, 'apps', 'notes.txt'), 'x'); + const { ctx } = makeContext({ apps: [makeApp({ id: 'id-f', name: 'f', path: root })], tasks: { 'id-f': [] } }); + const tools = captureTools(registerTools, ctx); + const res = await call(tools.get('add_task')!, { app: 'f', name: 't', script: 'dev', workingDir: 'apps/notes.txt' }); + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/not an existing directory/); + }); + + it('add_task rejects a dependency on a disabled task', async () => { + const tasks = { 'id-web': [makeTask({ id: 'task-db', appId: 'id-web', name: 'db', enabled: false })] }; + const { ctx } = makeContext({ apps: [makeApp({ id: 'id-web', name: 'web', path: '/proj/web' })], tasks }); + const tools = captureTools(registerTools, ctx); + const res = await call(tools.get('add_task')!, { app: 'web', name: 'api', script: 'dev', dependsOn: ['db'] }); + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/disabled/); + }); + + it('remove_task refuses a task id that belongs to a different app', async () => { + const apps = [ + makeApp({ id: 'id-a', name: 'frontend', path: '/proj/a' }), + makeApp({ id: 'id-b', name: 'backend', path: '/proj/b' }) + ]; + const tasks = { + 'id-a': [makeTask({ id: 'task-a-dev', appId: 'id-a', name: 'dev' })], + 'id-b': [makeTask({ id: 'task-b-dev', appId: 'id-b', name: 'dev' })] + }; + const { ctx, state } = makeContext({ apps, tasks }); + const tools = captureTools(registerTools, ctx); + const res = await call(tools.get('remove_task')!, { app: 'frontend', task: 'task-b-dev', confirm: true }); + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/different app/); + expect(state.tasks['id-b']!).toHaveLength(1); + }); + + it('remove_task refuses while the app is starting (in-flight orchestration)', async () => { + const { tools, state } = setup({ states: { 'id-web': 'starting' } }); + const res = await call(tools.get('remove_task')!, { app: 'web', task: 'dev', confirm: true }); + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/starting/); + expect(state.tasks['id-web']!).toHaveLength(1); + }); +}); diff --git a/src/main/mcp/context.ts b/src/main/mcp/context.ts new file mode 100644 index 0000000..1c80735 --- /dev/null +++ b/src/main/mcp/context.ts @@ -0,0 +1,150 @@ +import { realpathSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +import type { App, AppId, EnvVar, Task, TaskId } from '@shared/types'; +import type { EnvFileInfo } from '@shared/ipc'; +import type { AppRegistry } from '../services/AppRegistry'; +import type { TaskRegistry } from '../services/TaskRegistry'; +import type { AppOrchestrator } from '../services/AppOrchestrator'; +import type { TaskRunner } from '../services/TaskRunner'; +import type { EnvStore } from '../services/EnvStore'; +import type { DetectionService } from '../services/DetectionService'; +import type { NodeResolver } from '../services/NodeResolver'; +import type { RunHistory } from '../services/RunHistory'; +import type { Settings } from '../services/Settings'; +import type { AppOps } from '../services/AppOps'; + +/** + * Everything the MCP tools operate on - the same singletons the IPC layer uses, plus AppOps + * for the shared create/update/remove/scan/search flows. Injected so the tool layer stays + * free of electron/db imports and can be exercised with fakes. + */ +export interface McpToolContext { + appVersion: string; + registry: AppRegistry; + taskRegistry: TaskRegistry; + orchestrator: AppOrchestrator; + runner: TaskRunner; + envStore: EnvStore; + detector: DetectionService; + nodes: NodeResolver; + runHistory: RunHistory; + settings: Settings; + appOps: AppOps; + /** Env files discovered in an app's project dir (read-only). */ + envFiles: (appId: AppId) => EnvFileInfo[]; + /** + * Called by every MUTATING tool after it changes app/task/env/lifecycle state, so the + * desktop UI (which caches its view and normally only updates on its own actions) can + * re-sync. No-op-safe; the renderer debounces. + */ + notifyChanged: () => void; +} + +/** An actionable resolution failure - the tool layer turns this into an isError result. */ +export class ResolutionError extends Error {} + +/** Strip trailing slashes then realpath (best-effort) so path addressing matches app.path. */ +function canonicalizePath(p: string): string { + const trimmed = p.replace(/\/+$/, '') || '/'; + try { + return realpathSync(trimmed); + } catch { + return trimmed; + } +} + +/** + * Resolve an `app` argument that may be an app id, an exact (case-insensitive) name, or an + * absolute path. Ambiguous names and unknown references throw ResolutionError with the + * candidate/nearest list so the agent can correct itself in one step. + */ +export function resolveApp(ctx: McpToolContext, ref: string): App { + const needle = ref.trim(); + if (!needle) throw new ResolutionError('An app id, name, or absolute path is required.'); + const all = ctx.registry.list(); + + const byId = all.find((a) => a.id === needle); + if (byId) return byId; + + if (isAbsolute(needle)) { + const real = canonicalizePath(needle); + const byPath = all.find((a) => a.path === real || a.path === needle.replace(/\/+$/, '')); + if (byPath) return byPath; + throw new ResolutionError( + `No app is registered at path "${needle}". Use add_app to register it, or pass an app name/id.` + ); + } + + const lower = needle.toLowerCase(); + const byName = all.filter((a) => a.name.toLowerCase() === lower); + if (byName.length === 1) return byName[0]!; + if (byName.length > 1) { + const list = byName.map((a) => `${a.name} (${a.id})`).join(', '); + throw new ResolutionError(`"${needle}" is ambiguous - matches: ${list}. Pass the id.`); + } + + const nearest = all + .filter((a) => a.name.toLowerCase().includes(lower)) + .slice(0, 5) + .map((a) => a.name); + const hint = nearest.length ? ` Did you mean: ${nearest.join(', ')}?` : ''; + throw new ResolutionError(`No app matches "${needle}".${hint} Use list_apps to see them all.`); +} + +/** + * Resolve a `task` argument (task id, or task name scoped by an optional `app`). A bare name + * resolves only if globally unique; otherwise the error lists the app/task candidates. + */ +export function resolveTask(ctx: McpToolContext, ref: string, appRef?: string): Task { + const needle = ref.trim(); + if (!needle) throw new ResolutionError('A task id or name is required.'); + + const byId = ctx.taskRegistry.get(needle as TaskId); + if (byId) return byId; + + const scopeApp = appRef ? resolveApp(ctx, appRef) : null; + const searchApps = scopeApp ? [scopeApp] : ctx.registry.list(); + const matches: Array<{ task: Task; app: App }> = []; + for (const app of searchApps) { + for (const t of ctx.taskRegistry.list(app.id)) { + if (t.name.toLowerCase() === needle.toLowerCase()) matches.push({ task: t, app }); + } + } + if (matches.length === 1) return matches[0]!.task; + if (matches.length > 1) { + const list = matches.map((m) => `${m.app.name}/${m.task.name} (${m.task.id})`).join(', '); + throw new ResolutionError( + `Task "${needle}" is ambiguous - matches: ${list}. Pass the task id or scope with app.` + ); + } + const scopeMsg = scopeApp ? ` in app "${scopeApp.name}"` : ''; + throw new ResolutionError(`No task named "${needle}"${scopeMsg}. Use get_app to list tasks.`); +} + +/** + * Plaintext values of every secret env var in scope (global + app + the app's tasks), for + * log redaction. Values < 4 chars are the caller's problem to skip; this returns them all. + */ +export function secretValuesForApp(ctx: McpToolContext, appId: AppId): string[] { + const out: string[] = []; + const push = (vars: EnvVar[]): void => { + for (const v of vars) if (v.isSecret && v.value) out.push(v.value); + }; + push(ctx.envStore.getGlobal()); + push(ctx.envStore.getApp(appId)); + for (const t of ctx.taskRegistry.list(appId)) push(ctx.envStore.getTask(t.id)); + return out; +} + +/** Every secret value across every scope - for global searches that span apps. */ +export function allSecretValues(ctx: McpToolContext): string[] { + const out: string[] = []; + for (const v of ctx.envStore.getGlobal()) if (v.isSecret && v.value) out.push(v.value); + for (const app of ctx.registry.list()) { + for (const v of ctx.envStore.getApp(app.id)) if (v.isSecret && v.value) out.push(v.value); + for (const t of ctx.taskRegistry.list(app.id)) { + for (const v of ctx.envStore.getTask(t.id)) if (v.isSecret && v.value) out.push(v.value); + } + } + return out; +} diff --git a/src/main/mcp/gate.ts b/src/main/mcp/gate.ts new file mode 100644 index 0000000..4040783 --- /dev/null +++ b/src/main/mcp/gate.ts @@ -0,0 +1,68 @@ +/** + * Request gatekeeping for the MCP HTTP listener (see specs/07-mcp-server.md). + * + * Pure functions over header strings - no electron/db imports - so the DNS-rebinding and + * auth logic unit-tests under plain node. + */ + +import { createHash, timingSafeEqual } from 'node:crypto'; + +// Exact hostname equality only. A suffix or substring test would wave through +// `localhost.evil.com` / `127.0.0.1.evil.com`, which is precisely the DNS-rebinding +// trick the check exists to stop. +const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']); + +/** + * Strip a trailing `:port` from a Host header value, keeping IPv6 brackets intact: + * `127.0.0.1:6872` → `127.0.0.1`, `[::1]:6872` → `[::1]`, `[::1]` → `[::1]`. + */ +function hostWithoutPort(host: string): string { + if (host.startsWith('[')) { + const close = host.indexOf(']'); + return close === -1 ? host : host.slice(0, close + 1); + } + const colon = host.indexOf(':'); + return colon === -1 ? host : host.slice(0, colon); +} + +/** Is the Host header's hostname exactly a loopback name? Missing header fails closed. */ +export function hostHeaderAllowed(host: string | undefined): boolean { + if (!host) return false; + return LOOPBACK_HOSTNAMES.has(hostWithoutPort(host.trim().toLowerCase())); +} + +/** Exact-match loopback hostname check (for a URL's `hostname`, already port-stripped). */ +export function isLoopbackHostname(hostname: string): boolean { + return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); +} + +/** + * Origin policy: no Origin header (non-browser client) passes; a present Origin must + * parse and have a loopback hostname (MCP Inspector runs on localhost and sends one). + * Anything else - including the literal string "null" a sandboxed iframe sends - fails. + */ +export function originAllowed(origin: string | undefined): boolean { + if (origin == null) return true; + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + return LOOPBACK_HOSTNAMES.has(url.hostname.toLowerCase()); +} + +/** + * Constant-time bearer check. Both sides are sha256-hashed to equal-length buffers first: + * `timingSafeEqual` THROWS on length mismatch (and the client controls the presented + * length), while a length pre-check would leak the token's length. Hash-then-compare + * sidesteps both. + */ +export function bearerMatches(header: string | undefined, expectedToken: string): boolean { + if (!header || !expectedToken) return false; + const m = /^Bearer\s+(\S+)$/i.exec(header.trim()); + if (!m || !m[1]) return false; + const presented = createHash('sha256').update(m[1]).digest(); + const expected = createHash('sha256').update(expectedToken).digest(); + return timingSafeEqual(presented, expected); +} diff --git a/src/main/mcp/hygiene.ts b/src/main/mcp/hygiene.ts new file mode 100644 index 0000000..bde68ad --- /dev/null +++ b/src/main/mcp/hygiene.ts @@ -0,0 +1,47 @@ +/** + * Log hygiene for MCP responses (see specs/07-mcp-server.md). + * + * Task buffers hold raw PTY output: ANSI escapes and, potentially, secret values the dev + * server printed itself (`console.log(process.env)`, connection strings in stack traces). + * Everything log-shaped leaves the process through these two passes. + * + * Pure functions - no electron/db imports - so they unit-test under plain node. + */ + +// CSI sequences (colors, cursor movement), OSC sequences (terminal title, hyperlinks - +// terminated by BEL or ST), and lone two-byte escapes. The readiness watcher's private +// regex only handles CSI; OSC is common in modern dev servers (iTerm links), so both +// are covered here. +const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_RE, ''); +} + +/** + * Replace every occurrence of a known secret value with a mask. Literal string + * replacement (split/join), never regex - secret values are untrusted input. + * Values shorter than 4 chars are skipped: masking "1" would shred ordinary output, + * and a 1-3 char secret is not meaningfully protected by masking anyway. + */ +export function redactSecrets(text: string, secretValues: string[]): string { + let out = text; + for (const value of secretValues) { + if (value.length < 4) continue; + if (out.includes(value)) out = out.split(value).join('[redacted]'); + } + return out; +} + +/** Last `n` newline-delimited lines of `text` (handles \r\n). */ +export function tailLines(text: string, n: number): string { + const lines = text.split(/\r?\n/); + return lines.slice(Math.max(0, lines.length - n)).join('\n'); +} + +/** One shot: ANSI strip, drop trailing carriage returns (progress-spinner residue that + * survives ANSI stripping and clutters agent-facing text), then redact known secrets. */ +export function cleanLogText(text: string, secretValues: string[]): string { + const stripped = stripAnsi(text).replace(/\r+(?=\n|$)/g, ''); + return redactSecrets(stripped, secretValues); +} diff --git a/src/main/mcp/tls.ts b/src/main/mcp/tls.ts new file mode 100644 index 0000000..535fc1f --- /dev/null +++ b/src/main/mcp/tls.ts @@ -0,0 +1,159 @@ +import { execFile, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const execFileP = promisify(execFile); + +/** + * Self-signed TLS material for the MCP server's HTTPS mode (see specs/07-mcp-server.md). + * + * Generated once with the system openssl (always present on macOS) into the given + * directory, then reused. SANs cover localhost / 127.0.0.1 / ::1 so the certificate is + * valid for every way a loopback client addresses us. The extension config lives in a file + * rather than `-addext` because older LibreSSL builds lack that flag. + * + * The directory is injected (the caller passes userData/mcp-tls) so this module stays free + * of electron imports and unit-tests under plain node. Key generation can be async so it + * never blocks the Electron main process; a sync entry point exists for tests. + */ + +export interface TlsMaterial { + key: string; + cert: string; + certPath: string; +} + +const OPENSSL_CNF = `[req] +distinguished_name = dn +x509_extensions = v3_req +prompt = no + +[dn] +CN = DevHarbor MCP (localhost) + +[v3_req] +basicConstraints = CA:FALSE +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 +IP.2 = ::1 +`; + +function paths(dir: string): { keyPath: string; certPath: string; cnfPath: string } { + return { + keyPath: join(dir, 'mcp-key.pem'), + certPath: join(dir, 'mcp-cert.pem'), + cnfPath: join(dir, 'openssl.cnf') + }; +} + +const OPENSSL_ARGS = (keyPath: string, certPath: string, cnfPath: string): string[] => [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-sha256', + '-days', + '3650', + '-nodes', + '-keyout', + keyPath, + '-out', + certPath, + '-config', + cnfPath +]; + +/** True when both PEMs are already present. */ +export function tlsFilesExist(dir: string): boolean { + const { keyPath, certPath } = paths(dir); + return existsSync(keyPath) && existsSync(certPath); +} + +/** + * Prepare the directory + a 0600 key file BEFORE openssl runs, so the private key never + * exists group/world-readable even for a moment: openssl's `-keyout` truncates an existing + * file in place (BIO fopen "w") and keeps its mode, so pre-creating it 0600 closes the + * window race-free (no reliance on a process-global umask, which would be unsafe around an + * async spawn). + */ +function prep(dir: string): { keyPath: string; certPath: string; cnfPath: string } { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + try { + chmodSync(dir, 0o700); // re-assert in case the dir pre-existed with looser perms + } catch { + /* best-effort */ + } + const p = paths(dir); + writeFileSync(p.keyPath, '', { mode: 0o600 }); + writeFileSync(p.cnfPath, OPENSSL_CNF); + return p; +} + +function cleanupAfterFailure(keyPath: string, certPath: string, cnfPath: string, e: unknown): never { + rmSync(keyPath, { force: true }); + rmSync(certPath, { force: true }); + rmSync(cnfPath, { force: true }); + const detail = e instanceof Error ? e.message : String(e); + throw new Error(`could not generate a self-signed certificate with openssl (${detail})`); +} + +/** Async generation - does NOT block the Electron main process. Throws (fails closed). */ +export async function generateTlsMaterial(dir: string): Promise { + const { keyPath, certPath, cnfPath } = prep(dir); + try { + await execFileP('openssl', OPENSSL_ARGS(keyPath, certPath, cnfPath), { + timeout: 30_000 + }); + } catch (e) { + cleanupAfterFailure(keyPath, certPath, cnfPath, e); + } + rmSync(cnfPath, { force: true }); +} + +/** Sync generation - only for the unit test / non-hot paths. */ +function generateTlsMaterialSync(dir: string): void { + const { keyPath, certPath, cnfPath } = prep(dir); + try { + execFileSync('openssl', OPENSSL_ARGS(keyPath, certPath, cnfPath), { + stdio: ['ignore', 'ignore', 'pipe'], + timeout: 30_000 + }); + } catch (e) { + cleanupAfterFailure(keyPath, certPath, cnfPath, e); + } + rmSync(cnfPath, { force: true }); +} + +/** + * Load an existing key/cert pair (must already exist). Re-asserts 0600 on the key each load + * so a pair generated by an older build - or left loose by a crash between generation and + * chmod - is tightened before use. Throws if the files are missing or malformed. + */ +export function loadTlsMaterial(dir: string): TlsMaterial { + const { keyPath, certPath } = paths(dir); + const key = readFileSync(keyPath, 'utf8'); + const cert = readFileSync(certPath, 'utf8'); + if (!key.includes('PRIVATE KEY') || !cert.includes('BEGIN CERTIFICATE')) { + throw new Error( + `TLS files at ${dir} look invalid. Delete the mcp-key.pem/mcp-cert.pem pair to regenerate.` + ); + } + try { + chmodSync(keyPath, 0o600); + } catch { + /* best-effort */ + } + return { key, cert, certPath }; +} + +/** Sync generate-if-missing + load. Blocks; used by tests and any non-hot caller. */ +export function ensureTlsMaterial(dir: string): TlsMaterial { + if (!tlsFilesExist(dir)) generateTlsMaterialSync(dir); + return loadTlsMaterial(dir); +} diff --git a/src/main/mcp/token.ts b/src/main/mcp/token.ts new file mode 100644 index 0000000..1a1697d --- /dev/null +++ b/src/main/mcp/token.ts @@ -0,0 +1,80 @@ +import { randomBytes } from 'node:crypto'; +import { safeStorage } from 'electron'; +import { db } from '../db/index.js'; + +/** + * Persistence for the MCP bearer token (see specs/07-mcp-server.md). + * + * The token is a control-plane credential: holding it lets a client register a path and run + * its scripts, i.e. run commands as the user. So unlike EnvStore's silent plaintext fallback + * for env secrets, when the OS keychain is unavailable we still persist (clients must survive + * restarts) but flag `storedPlaintext` so Settings can warn. + * + * Comparison logic deliberately lives in gate.ts (pure, unit-testable). This module only + * creates/loads/rotates and returns the plaintext. + */ + +const ENC_PREFIX = 'enc1:'; +const SETTINGS_KEY = 'mcp_token'; + +export class TokenStore { + /** True when the last load/generate had to store the token unencrypted. */ + storedPlaintext = false; + + /** Generate + persist a fresh 32-byte hex token, replacing any existing one. */ + regenerate(): string { + const token = randomBytes(32).toString('hex'); + this.persist(token); + return token; + } + + /** + * Current plaintext token, generating and persisting one on first use. Returns null only + * if generation itself fails (should not happen - randomBytes + a DB write). + */ + getOrCreate(): string { + const existing = this.load(); + if (existing != null) return existing; + return this.regenerate(); + } + + /** Current plaintext token if one exists, else null. Does NOT create one. */ + load(): string | null { + const row = db() + .prepare(`SELECT value FROM settings WHERE key = ?`) + .get(SETTINGS_KEY); + if (!row) return null; + const raw = row.value; + if (raw.startsWith(ENC_PREFIX)) { + if (!safeStorage.isEncryptionAvailable()) { + // Stored encrypted, but the keychain is now unavailable - we cannot recover it. + return null; + } + try { + return safeStorage.decryptString(Buffer.from(raw.slice(ENC_PREFIX.length), 'base64')); + } catch { + return null; + } + } + // Stored as plaintext (keychain was unavailable when written). + this.storedPlaintext = true; + return raw; + } + + private persist(token: string): void { + let stored: string; + if (safeStorage.isEncryptionAvailable()) { + stored = ENC_PREFIX + safeStorage.encryptString(token).toString('base64'); + this.storedPlaintext = false; + } else { + stored = token; + this.storedPlaintext = true; + } + db() + .prepare( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ) + .run(SETTINGS_KEY, stored); + } +} diff --git a/src/main/mcp/tools.ts b/src/main/mcp/tools.ts new file mode 100644 index 0000000..455f5db --- /dev/null +++ b/src/main/mcp/tools.ts @@ -0,0 +1,1299 @@ +import { z } from 'zod'; +import { realpathSync, statSync } from 'node:fs'; +import { isAbsolute, join } from 'node:path'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { + App, + AppId, + NodeVersionPref, + ReadinessSignal, + Task, + TaskId, + WorkspaceCandidate +} from '@shared/types'; +import type { CreateAppInput, CreateTaskSpec } from '@shared/ipc'; +import { isSecretKey } from '@shared/dotenv'; +import { + allSecretValues, + resolveApp, + resolveTask, + ResolutionError, + secretValuesForApp, + type McpToolContext +} from './context.js'; +import { isLoopbackHostname } from './gate.js'; +import { cleanLogText, redactSecrets } from './hygiene.js'; + +/** SDK CallToolResult subset we produce. */ +type ToolResult = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +/** How long the start/restart tools await readiness before returning "still starting" so + they never race the MCP client's default 60s request timeout (see spec). */ +const START_BUDGET_MS = 45_000; + +function jsonResult(obj: unknown): ToolResult { + return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] }; +} + +function errorResult(message: string): ToolResult { + return { content: [{ type: 'text', text: message }], isError: true }; +} + +/** JSON header block + a raw log-text block (avoids double-escaping many log lines). */ +function logResult(header: unknown, logText: string): ToolResult { + return { + content: [ + { type: 'text', text: JSON.stringify(header, null, 2) }, + { type: 'text', text: logText || '(no buffered output)' } + ] + }; +} + +function cleanMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function aggregatePorts(ctx: McpToolContext, appId: AppId): number[] { + const ports = new Set(); + for (const rt of ctx.runner.list()) if (rt.appId === appId) for (const p of rt.ports) ports.add(p); + return [...ports].sort((a, b) => a - b); +} + +/** + * Port-ownership labels for one task, with known secret values redacted - command lines + * can carry secrets as CLI args (--api-key=...), the same leak class as log output. + */ +function portOwnersFor( + ctx: McpToolContext, + taskId: TaskId, + secrets: string[] +): Array<{ port: number; pid: number | null; process: string | null }> { + return ctx.runner.ports.portOwners(taskId).map((o) => ({ + ...o, + process: o.process ? redactSecrets(o.process, secrets) : o.process + })); +} + +/** Per-port ownership labels across an app's live tasks (which process serves which port). */ +function aggregatePortDetails( + ctx: McpToolContext, + appId: AppId, + secrets: string[] +): Array<{ port: number; pid: number | null; process: string | null; task: string }> { + const out: Array<{ port: number; pid: number | null; process: string | null; task: string }> = []; + for (const t of ctx.taskRegistry.list(appId)) { + for (const o of portOwnersFor(ctx, t.id, secrets)) { + out.push({ ...o, task: t.name }); + } + } + return out.sort((a, b) => a.port - b.port); +} + +/** + * Wait for port detection to catch up so a start result includes the ports (discovery is an + * async lsof/ps tick, ~2s). Two phases: + * + * 1. Wait up to FIRST_PORT_MS for the FIRST port. If none appears, the task is probably + * portless (a worker/builder) - return rather than stall the whole budget on it. + * 2. Once a port is seen, keep polling until the port SET stops growing for STABLE_MS + * (bounded by HARD_CAP_MS). This catches a sibling whose detection lands within one + * stability window of the previous port - e.g. a monorepo web+API where the API binds + * a beat behind the web server (the old "first port + fixed settle" missed even that). + * It is NOT a guarantee: a sibling that binds more than STABLE_MS after the last + * detection (an API stuck several seconds on DB/cache connects) is still missed here + * and surfaces in get_app/get_ports on a later detection tick. The reliable fix for + * those is a readiness probe (set_readiness): start then waits for readiness first, + * so the late port is already bound by the time this wait runs. + * + * The port source is injected, NOT hardcoded to the app: for start_task we poll the ONE + * task's ports, so a sibling task already listening can't satisfy the check before the + * just-started task's own port is detected. + */ +const FIRST_PORT_MS = 3000; +// Must exceed PortDetector's poll interval (2000ms): a port that binds during the wait is +// only SURFACED on the next detection tick, so a stability window shorter than one tick +// would declare "no more ports" before a late sibling was ever detected. +const STABLE_MS = 2400; +const HARD_CAP_MS = 12_000; + +async function waitForPorts(portsFn: () => number[], isLive: () => boolean): Promise { + const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + const start = Date.now(); + + // Phase 1: first port (or give up - likely portless). + while (Date.now() - start < FIRST_PORT_MS && portsFn().length === 0) { + if (!isLive()) return; + await sleep(250); + } + if (portsFn().length === 0) return; + + // Phase 2: wait until the port set stops growing. + let known = portsFn().length; + let lastGrowth = Date.now(); + while (Date.now() - start < HARD_CAP_MS) { + await sleep(300); + if (!isLive()) return; + const n = portsFn().length; + if (n > known) { + known = n; + lastGrowth = Date.now(); + } else if (Date.now() - lastGrowth >= STABLE_MS) { + return; + } + } +} + +/** Wait for an app's ports (any of its tasks). */ +function waitForAppPorts(ctx: McpToolContext, appId: AppId): Promise { + return waitForPorts( + () => aggregatePorts(ctx, appId), + () => ctx.runner.list().some((rt) => rt.appId === appId && (rt.state === 'running' || rt.state === 'starting')) + ); +} + +/** Wait for a single task's own ports (not its siblings'). */ +function waitForTaskPorts(ctx: McpToolContext, taskId: TaskId): Promise { + return waitForPorts( + () => ctx.runner.get(taskId)?.ports ?? [], + () => { + const st = ctx.runner.get(taskId)?.state; + return st === 'running' || st === 'starting'; + } + ); +} + +function appBrief(ctx: McpToolContext, app: App): Record { + return { + id: app.id, + name: app.name, + path: app.path, + state: ctx.orchestrator.appState(app.id), + ports: aggregatePorts(ctx, app.id), + tags: app.tags, + folder: app.folder, + defaultScript: app.defaultScript, + packageManager: app.packageManager, + autoStart: app.autoStart, + lastStartedAt: app.lastStartedAt + }; +} + +function taskInfo(ctx: McpToolContext, task: Task, secrets: string[]): Record { + const rt = ctx.runner.get(task.id); + // `ready` is a verified answer only when the LIVE run has a real probe. Deriving "probed" + // from rt.readinessKind (captured at spawn), NOT task.readiness.kind (the stored config), + // is what stops set_readiness on a RUNNING task from flipping ready:null -> true for a + // probe that has not executed yet (it takes effect on the next start). `listening` is the + // factual port-derived signal - NOT the same as "serving" (an API opens its port before + // its DB connects) - so it is reported separately. + const probed = rt ? rt.readinessKind !== 'none' : task.readiness.kind !== 'none'; + const ports = rt?.ports ?? []; + return { + id: task.id, + name: task.name, + commandKind: task.commandKind, + script: task.script, + customCommand: task.customCommand, + workingDir: task.workingDirOverride, + oneShot: task.oneShot, + dependsOn: task.dependsOn, + readiness: task.readiness, + enabled: task.enabled, + state: rt?.state ?? 'idle', + ready: rt ? (probed ? rt.ready : null) : null, + listening: rt ? ports.length > 0 : null, + ports, + portDetails: portOwnersFor(ctx, task.id, secrets) + }; +} + +/** + * Let the task state machine settle one macrotask. TaskRunner flips a task from 'starting' to + * 'running' on a setTimeout(0), while 'none'-readiness resolves on a microtask - so startApp + * can resolve a tick before the state flips. Without this, start_app would report "starting" + * for an app that is actually up. + */ +function settle(): Promise { + return new Promise((r) => setTimeout(r, 40)); +} + +/** Await a readiness promise, but never longer than `budget` ms. */ +async function raceReadiness( + awaitReady: Promise, + budget: number +): Promise<'ready' | 'not-ready' | 'timeout'> { + let timer: ReturnType | undefined; + const timeout = new Promise<'timeout'>((r) => { + timer = setTimeout(() => r('timeout'), budget); + timer.unref?.(); + }); + const outcome = await Promise.race([ + awaitReady.then((ok): 'ready' | 'not-ready' => (ok ? 'ready' : 'not-ready')), + timeout + ]); + if (timer) clearTimeout(timer); + return outcome; +} + +/** Build the shared start/restart result: final state, per-task info, ports, failure tails. */ +function startResultFor(ctx: McpToolContext, app: App, startError: string | null): ToolResult { + const state = ctx.orchestrator.appState(app.id); + const tasks = ctx.taskRegistry.list(app.id).filter((t) => t.enabled); + const secrets = secretValuesForApp(ctx, app.id); + const infos = tasks.map((t) => taskInfo(ctx, t, secrets)); + const stillStarting = !startError && state === 'starting'; + + // Readiness is only "verified" when every running task has a real probe that fired. Uses + // the LIVE run's readinessKind (not the stored config), so an edited-but-not-restarted + // probe can't fake a verified answer. + let readinessVerified: boolean | null = false; + if (state === 'running') { + const runningRts = tasks + .map((t) => ctx.runner.get(t.id)) + .filter((rt): rt is NonNullable => rt?.state === 'running'); + if (runningRts.some((rt) => rt.readinessKind === 'none')) { + readinessVerified = null; + } else { + readinessVerified = runningRts.every((rt) => rt.ready === true); + } + } + + const tails: string[] = []; + for (const t of tasks) { + const rt = ctx.runner.get(t.id); + const probed = rt ? rt.readinessKind !== 'none' : t.readiness.kind !== 'none'; + if (rt?.state === 'running' && rt.ready && probed) continue; // verified ready - no tail needed + const raw = ctx.runner.tailBuffer(t.id, 30); + const cleaned = cleanLogText(raw, secrets); + if (!cleaned.trim()) continue; + const label = + rt?.state === 'running' && !probed + ? `${t.name} (running, no readiness probe - recent output so you can judge readiness yourself)` + : `${t.name} (${rt?.state ?? 'idle'})`; + tails.push(`=== ${label} ===\n${cleaned}`); + } + + const notes: string[] = []; + if (stillStarting) { + notes.push( + 'Still starting after 45s; the start continues in the background. Poll get_app to see when it is ready.' + ); + } + if (readinessVerified === null) { + const anyListening = tasks.some((t) => (ctx.runner.get(t.id)?.ports.length ?? 0) > 0); + notes.push( + anyListening + ? 'No readiness probe is configured (readiness kind "none"). The task is listening on a port, which for many dev servers means ready - but DevHarbor has NOT verified it is serving (an API can open its port before its DB/cache connect). Judge from the log tail below, or call set_readiness (e.g. an http probe on a /health or /ready URL) so a future start returns readinessVerified: true and waits until it is actually serving.' + : 'No readiness probe is configured (readiness kind "none"), so "running" means the processes are up, not that they are serving. Judge from the log tail below, or call set_readiness to configure a port/log/http probe for a verified answer.' + ); + } + + const header = { + app: app.name, + state, + readinessVerified, + stillStarting, + startError, + ports: aggregatePorts(ctx, app.id), + portDetails: aggregatePortDetails(ctx, app.id, secrets), + tasks: infos, + note: notes.length ? notes.join(' ') : undefined + }; + return tails.length ? logResult(header, tails.join('\n\n')) : jsonResult(header); +} + +/** Canonicalize an absolute path the way app.path is stored (realpath, trailing slash off). */ +function canonicalize(p: string): string { + const trimmed = p.replace(/\/+$/, '') || '/'; + try { + return realpathSync(trimmed); + } catch { + return trimmed; + } +} + +function nodeVersionPrefFrom(value: string): NodeVersionPref { + if (value === 'auto') return { kind: 'auto' }; + if (value === 'system') return { kind: 'system' }; + return { kind: 'explicit', version: value }; +} + +const READ_ONLY: Record = { readOnlyHint: true }; + +/** + * Register the full DevHarbor tool catalog on `server` (see specs/07-mcp-server.md). Each + * handler is wrapped so a thrown ResolutionError (or any error) becomes a clean isError + * result rather than a transport fault. + */ +export function registerTools(server: McpServer, ctx: McpToolContext): void { + const tool = ( + name: string, + config: { + title: string; + description: string; + inputSchema?: z.ZodRawShape; + annotations?: Record; + /** When true, a successful (non-error) call nudges the desktop UI to re-sync. */ + mutates?: boolean; + }, + handler: (args: Record) => ToolResult | Promise + ): void => { + server.registerTool( + name, + { + title: config.title, + description: config.description, + inputSchema: config.inputSchema ?? {}, + annotations: { title: config.title, ...config.annotations } + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (async (args: any) => { + try { + const result = await handler(args ?? {}); + // Tell the desktop UI to re-sync after a state change (the renderer only tracks its + // own actions otherwise). Only on a non-error result. + if (config.mutates && !result.isError) ctx.notifyChanged(); + return result; + } catch (e) { + if (e instanceof ResolutionError) return errorResult(e.message); + return errorResult(cleanMessage(e)); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + ); + }; + + // --- Read-only ----------------------------------------------------------------------------- + + tool( + 'get_overview', + { + title: 'Overview', + description: + 'Snapshot of everything DevHarbor manages: app count, how many are running or crashed, and which ports are active. Call this first to orient yourself. DevHarbor runs local dev servers on the user\'s behalf - to run a project, register it with add_app and start it with start_app instead of running npm/yarn/pnpm in your own shell.', + annotations: READ_ONLY + }, + () => { + const apps = ctx.registry.list(); + let running = 0; + let crashed = 0; + const ports = new Set(); + for (const a of apps) { + const st = ctx.orchestrator.appState(a.id); + if (st === 'running' || st === 'starting') running++; + if (st === 'crashed') crashed++; + for (const p of aggregatePorts(ctx, a.id)) ports.add(p); + } + return jsonResult({ + devharborVersion: ctx.appVersion, + appCount: apps.length, + running, + crashed, + activePorts: [...ports].sort((a, b) => a - b), + apps: apps.map((a) => ({ name: a.name, state: ctx.orchestrator.appState(a.id) })) + }); + } + ); + + tool( + 'list_apps', + { + title: 'List apps', + description: + 'List registered apps with their current state, ports, tags, and folder. Optional `state` filters to just apps in that lifecycle state.', + inputSchema: { + state: z + .enum(['idle', 'starting', 'running', 'exiting', 'exited', 'crashed']) + .optional() + .describe('Only return apps currently in this state.') + }, + annotations: READ_ONLY + }, + (args) => { + const filter = args.state as string | undefined; + const apps = ctx.registry + .list() + .map((a) => appBrief(ctx, a)) + .filter((a) => !filter || a.state === filter); + return jsonResult({ count: apps.length, apps }); + } + ); + + tool( + 'get_app', + { + title: 'Get app details', + description: + 'Full detail for one app: its config, every task (with live state/ready/ports), discovered .env files, and recent run history. `app` is an app id, exact name, or absolute path.', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + runs_limit: z.number().int().min(0).max(50).optional().describe('Recent runs to include (default 5).') + }, + annotations: READ_ONLY + }, + (args) => { + const app = resolveApp(ctx, String(args.app)); + const secrets = secretValuesForApp(ctx, app.id); + const tasks = ctx.taskRegistry.list(app.id).map((t) => taskInfo(ctx, t, secrets)); + const runs = ctx.runHistory.list(app.id, (args.runs_limit as number | undefined) ?? 5); + return jsonResult({ + ...appBrief(ctx, app), + nodeVersionPref: app.nodeVersionPref, + autoRestartOnChange: app.autoRestartOnChange, + watchGlobs: app.watchGlobs, + portHint: app.portHint, + tasks, + envFiles: ctx.envFiles(app.id).map((f) => f.name), + recentRuns: runs.map((r) => ({ + task: r.taskName, + script: r.script, + customCommand: r.customCommand, + startedAt: r.startedAt, + endedAt: r.endedAt, + exitCode: r.exitCode, + wasKilledByUser: r.wasKilledByUser + })) + }); + } + ); + + tool( + 'list_running', + { + title: 'List running tasks', + description: 'Every currently-live task across all apps with pid, uptime, cpu, memory, and ports.', + annotations: READ_ONLY + }, + () => { + const now = Date.now(); + const allSecrets = allSecretValues(ctx); + const running = ctx.runner + .list() + .filter((rt) => rt.state === 'starting' || rt.state === 'running' || rt.state === 'exiting') + .map((rt) => { + // Base "probed" on the LIVE run's readinessKind (captured at spawn), so a + // set_readiness that hasn't been restarted into effect can't fake a verified ready. + const probed = rt.readinessKind !== 'none'; + return { + app: ctx.registry.get(rt.appId)?.name ?? rt.appId, + appId: rt.appId, + taskId: rt.taskId, + pid: rt.pid, + state: rt.state, + // null = this task has no readiness probe (or is unresolvable); "up" is not "serving". + ready: probed ? rt.ready : null, + listening: rt.ports.length > 0, + uptimeSec: Math.max(0, Math.round((now - rt.startedAt) / 1000)), + cpu: rt.cpu, + memMB: rt.memMB, + ports: rt.ports, + portDetails: portOwnersFor(ctx, rt.taskId, allSecrets) + }; + }); + return jsonResult({ count: running.length, running }); + } + ); + + tool( + 'get_logs', + { + title: 'Get logs', + description: + 'Recent log output for an app (or one task of it). Returns a JSON header plus the raw log text. Buffers are in-memory and expire ~10 minutes after a task exits, so recently-stopped tasks may return nothing. Known secret env values are redacted; secrets the dev server prints on its own cannot be.', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + task: z.string().optional().describe('Limit to this task (id or name); omit for all of the app\'s tasks.'), + lines: z.number().int().min(1).max(1000).optional().describe('Lines per task (default 100, max 1000).') + }, + annotations: READ_ONLY + }, + (args) => { + const app = resolveApp(ctx, String(args.app)); + const lines = (args.lines as number | undefined) ?? 100; + const secrets = secretValuesForApp(ctx, app.id); + const targetTasks = args.task + ? [resolveTask(ctx, String(args.task), String(args.app))] + : ctx.taskRegistry.list(app.id); + + const sections: string[] = []; + const meta: Array> = []; + for (const t of targetTasks) { + const rt = ctx.runner.get(t.id); + const raw = ctx.runner.tailBuffer(t.id, lines); + const cleaned = cleanLogText(raw, secrets); + meta.push({ task: t.name, live: !!rt, state: rt?.state ?? 'idle', hasOutput: cleaned.trim().length > 0 }); + if (targetTasks.length > 1) { + sections.push(`=== ${t.name} (${rt?.state ?? 'idle'}) ===\n${cleaned}`); + } else { + sections.push(cleaned); + } + } + return logResult( + { app: app.name, linesPerTask: lines, tasks: meta }, + sections.join('\n\n') + ); + } + ); + + tool( + 'search_logs', + { + title: 'Search logs', + description: + 'Search buffered log output across tasks, including tasks that exited in the last ~10 minutes. Literal substring by default; set `regex: true` for a pattern. Known secret values are redacted from results.', + inputSchema: { + query: z.string().describe('Text to find (literal unless regex is true).'), + regex: z.boolean().optional().describe('Treat query as a regular expression (default false).'), + app: z.string().optional().describe('Limit the search to this app.'), + limit: z.number().int().min(1).max(1000).optional().describe('Max matches (default 100).') + }, + annotations: READ_ONLY + }, + (args) => { + const appId = args.app ? resolveApp(ctx, String(args.app)).id : undefined; + const useRegex = (args.regex as boolean | undefined) ?? false; + // When the caller asked for regex, an invalid pattern is an error, not a silent + // downgrade to literal (AppOps keeps that fallback only for the renderer's search). + if (useRegex) { + try { + new RegExp(String(args.query)); + } catch (e) { + return errorResult(`Invalid regular expression: ${cleanMessage(e)}`); + } + } + const matches = ctx.appOps.searchLogs({ + query: String(args.query), + regex: useRegex, + appId, + limit: (args.limit as number | undefined) ?? 100 + }); + const secrets = appId ? secretValuesForApp(ctx, appId) : allSecretValues(ctx); + const rows = matches.map((m) => ({ + app: m.appName, + task: m.taskName, + line: cleanLogText(m.line, secrets) + })); + return jsonResult({ + interpretedAs: useRegex ? 'regex' : 'literal', + count: rows.length, + matches: rows + }); + } + ); + + tool( + 'list_env_vars', + { + title: 'List env vars', + description: + 'Env variables for an app (or one of its tasks). Non-secret values are shown; secret values are masked and never returned through MCP.', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + task: z.string().optional().describe('List this task\'s overrides instead of the app scope.') + }, + annotations: READ_ONLY + }, + (args) => { + const app = resolveApp(ctx, String(args.app)); + const vars = args.task + ? ctx.envStore.getTask(resolveTask(ctx, String(args.task), String(args.app)).id) + : ctx.envStore.getApp(app.id); + return jsonResult({ + app: app.name, + scope: args.task ? 'task' : 'app', + vars: vars.map((v) => ({ + key: v.key, + value: v.isSecret ? null : v.value, + isSecret: v.isSecret, + enabled: v.enabled + })) + }); + } + ); + + tool( + 'get_ports', + { + title: 'Get ports', + description: + 'Listening ports DevHarbor has detected, grouped by app and labeled with the owning process (so in a monorepo task that fans out into several servers you can tell which port is which service). Pass `port` to find who owns a specific port.', + inputSchema: { + port: z.number().int().min(1).max(65535).optional().describe('Look up the owner of just this port.') + }, + annotations: READ_ONLY + }, + (args) => { + const wanted = args.port as number | undefined; + const byApp: Array<{ + app: string; + ports: number[]; + portDetails: Array<{ port: number; pid: number | null; process: string | null; task: string }>; + }> = []; + for (const app of ctx.registry.list()) { + const ports = aggregatePorts(ctx, app.id); + if (ports.length === 0) continue; + if (wanted && !ports.includes(wanted)) continue; + byApp.push({ + app: app.name, + ports, + portDetails: aggregatePortDetails(ctx, app.id, secretValuesForApp(ctx, app.id)) + }); + } + if (wanted) { + // Prefer a CONFIRMED owner (pid set) over a log-hinted claim (pid null): a task that + // printed "Port X in use, trying Y" pins X as a hint for a few seconds, and another + // app may actually be the one listening on X. Search every matching app, not just + // byApp[0], and pick the entry with a real pid. + const claimants = byApp + .flatMap((a) => a.portDetails.filter((d) => d.port === wanted).map((d) => ({ ...d, app: a.app }))) + .sort((a, b) => (a.pid === null ? 1 : 0) - (b.pid === null ? 1 : 0)); + const best = claimants[0]; + return jsonResult({ + port: wanted, + owner: best?.app ?? byApp[0]?.app ?? null, + task: best?.task ?? null, + process: best?.process ?? null, + note: + !byApp.length + ? 'No task tracked by DevHarbor is listening on that port.' + : claimants.length > 1 + ? 'Multiple tasks reference this port (one may be a transient log hint); the confirmed owner is reported.' + : undefined + }); + } + return jsonResult({ apps: byApp }); + } + ); + + tool( + 'scan_folder', + { + title: 'Scan folder', + description: + 'Shallow-scan a directory (one level deep) for Node projects to import. Returns each candidate\'s path, package manager, available scripts, and whether it is already registered. Use before add_app to discover projects.', + inputSchema: { + dir: z.string().describe('Absolute path of the parent folder to scan.') + }, + annotations: READ_ONLY + }, + async (args) => { + const candidates = await ctx.appOps.scanFolder(String(args.dir)); + return jsonResult({ count: candidates.length, candidates }); + } + ); + + tool( + 'list_node_versions', + { + title: 'List Node versions', + description: + 'Node.js versions installed on this machine and where they came from (nvm/fnm/volta/asdf/system). Set an app\'s version with update_app nodeVersion.', + annotations: READ_ONLY + }, + () => jsonResult({ installations: ctx.nodes.list(true) }) + ); + + // --- Mutating ------------------------------------------------------------------------------ + + tool( + 'add_app', + { + title: 'Add app', + mutates: true, + description: + 'Register a local Node project so DevHarbor can run it - use this instead of running its dev server yourself. Give the project\'s absolute `path`. For a monorepo, pass `workspaceTasks: true` to create one task per workspace package (pnpm/yarn/npm workspaces), each running its own dev script from its own directory - start_app then runs them all, start_task runs one. Otherwise the default task is chosen in this order: the `script` you name, else a raw `command` you pass, else the project\'s detected default script. Returns the app, its tasks, the detected scripts, and any detected workspace packages.', + inputSchema: { + path: z.string().describe('Absolute path of the project directory.'), + name: z.string().optional().describe('Display name (defaults to the folder name).'), + script: z.string().optional().describe('package.json script for the default task (e.g. "dev"). Takes precedence over command and detection.'), + command: z.string().optional().describe('Raw shell command for the default task. Used when you do not pass a script; wins over the auto-detected script.'), + workspaceTasks: z.boolean().optional().describe('Monorepo: create one task per detected workspace package instead of a single root task. Ignores script/command when packages are found.'), + autoStart: z.boolean().optional().describe('Start this app automatically when DevHarbor launches.') + } + }, + async (args) => { + const path = String(args.path); + const wantWorkspaceTasks = args.workspaceTasks === true; + // Duplicate path → return the existing registration rather than erroring. + if (isAbsolute(path)) { + const existing = ctx.registry.getByPath(canonicalize(path)); + if (existing) { + let existingWs: WorkspaceCandidate[] = []; + try { + existingWs = (await ctx.detector.detect(path)).workspaces ?? []; + } catch { + // best-effort + } + return jsonResult({ + app: appBrief(ctx, existing), + workspaces: existingWs.length ? existingWs : undefined, + note: + 'This folder was already registered; returning the existing app.' + + (wantWorkspaceTasks && existingWs.length + ? ' workspaceTasks only applies to a new registration - use add_task (one per package, workingDir = its relPath) to add per-workspace tasks to this app.' + : '') + }); + } + } + + const script = args.script as string | undefined; + const command = args.command as string | undefined; + let detectedScripts: string[] = []; + let workspaces: WorkspaceCandidate[] = []; + let firstTask: CreateTaskSpec | null = null; + let defaultScript: string | null = null; + + if (script) { + firstTask = { name: script, commandKind: 'script', script }; + defaultScript = script; + } else if (command) { + firstTask = { name: 'start', commandKind: 'custom', customCommand: command }; + } + + // Best-effort detection (also fills the scripts list in the response). Never fatal. + try { + const det = await ctx.detector.detect(path); + detectedScripts = Object.keys(det.scripts); + workspaces = det.workspaces ?? []; + if (!firstTask && det.suggestedDefaultScript) { + firstTask = { name: det.suggestedDefaultScript, commandKind: 'script', script: det.suggestedDefaultScript }; + defaultScript = det.suggestedDefaultScript; + } + } catch { + // path validated by createApp below + } + + // Monorepo: one task per workspace package, mirroring the UI's "create a task per + // workspace package" flow (same naming, same script pick, same workingDirOverride). + // Containment re-checked here (detection already filters) so a candidate can never + // point a task outside the app root; names deduped because two packages can share a + // package.json name and TaskRegistry would reject the whole atomic create otherwise. + const runnable = workspaces.filter( + (ws) => + (ws.suggestedScript ?? ws.scripts[0]) != null && + !isAbsolute(ws.relPath) && + !ws.relPath.split(/[\\/]/).includes('..') + ); + let tasks: CreateTaskSpec[] | undefined; + if (wantWorkspaceTasks && runnable.length > 0) { + const usedNames = new Set(); + tasks = runnable.map((ws) => { + let taskName = ws.name || ws.relPath; + if (usedNames.has(taskName.toLowerCase())) taskName = `${taskName} (${ws.relPath})`; + usedNames.add(taskName.toLowerCase()); + return { + name: taskName, + commandKind: 'script' as const, + script: ws.suggestedScript ?? ws.scripts[0], + workingDirOverride: ws.relPath + }; + }); + firstTask = null; + defaultScript = null; + } + + const input: CreateAppInput = { + path, + name: args.name as string | undefined, + defaultScript, + firstTask, + tasks + }; + const app = await ctx.appOps.createApp(input); + + // autoStart is not part of createApp's flow; apply as a follow-up patch. + let finalApp = app; + if (args.autoStart === true) finalApp = ctx.appOps.updateApp(app.id, { autoStart: true }); + + const createdTasks = ctx.taskRegistry.list(finalApp.id); + const notes: string[] = []; + if (createdTasks.length === 0) { + notes.push('No task was created (no script or command matched). Add one with add_task before starting.'); + } + if (wantWorkspaceTasks && runnable.length === 0) { + notes.push('workspaceTasks was requested, but no workspace packages with runnable scripts were detected - fell back to the default single task.'); + } + if (!wantWorkspaceTasks && runnable.length > 0) { + notes.push(`Monorepo detected: ${runnable.length} workspace package(s) with runnable scripts (see workspaces). This registration runs from the repo root; for per-service tasks, remove_app and re-add with workspaceTasks: true, or add_task each package with its workingDir.`); + } + if (workspaces.length >= 50) { + notes.push('Workspace detection lists at most 50 packages; some may be missing from workspaces.'); + } + return jsonResult({ + app: appBrief(ctx, finalApp), + tasks: createdTasks.map((t) => t.name), + detectedScripts, + workspaces: workspaces.length ? workspaces : undefined, + note: notes.length ? notes.join(' ') : undefined + }); + } + ); + + tool( + 'update_app', + { + title: 'Update app', + mutates: true, + description: + 'Change an app\'s configuration. Only the fields you pass are modified. `nodeVersion` accepts "auto", "system", or an explicit version (see list_node_versions).', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + name: z.string().optional(), + defaultScript: z.string().nullable().optional(), + autoStart: z.boolean().optional(), + autoRestartOnChange: z.boolean().optional(), + watchGlobs: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + folder: z.string().nullable().optional(), + portHint: z.number().int().min(1).max(65535).nullable().optional(), + nodeVersion: z.string().optional().describe('"auto", "system", or an explicit version like "20.11.0".') + } + }, + (args) => { + const app = resolveApp(ctx, String(args.app)); + const patch: Partial = {}; + if (args.name !== undefined) patch.name = String(args.name); + if (args.defaultScript !== undefined) patch.defaultScript = args.defaultScript as string | null; + if (args.autoStart !== undefined) patch.autoStart = Boolean(args.autoStart); + if (args.autoRestartOnChange !== undefined) patch.autoRestartOnChange = Boolean(args.autoRestartOnChange); + if (args.watchGlobs !== undefined) patch.watchGlobs = args.watchGlobs as string[]; + if (args.tags !== undefined) patch.tags = args.tags as string[]; + if (args.folder !== undefined) patch.folder = args.folder as string | null; + if (args.portHint !== undefined) patch.portHint = args.portHint as number | null; + if (args.nodeVersion !== undefined) patch.nodeVersionPref = nodeVersionPrefFrom(String(args.nodeVersion)); + const updated = ctx.appOps.updateApp(app.id, patch); + return jsonResult({ app: appBrief(ctx, updated) }); + } + ); + + tool( + 'remove_app', + { + title: 'Remove app', + mutates: true, + description: + 'Unregister an app from DevHarbor. Does NOT delete any files on disk - only the registration. Requires confirm: true and refuses while the app is running.', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + confirm: z.boolean().describe('Must be true to actually remove.') + }, + annotations: { destructiveHint: true } + }, + (args) => { + const app = resolveApp(ctx, String(args.app)); + if (args.confirm !== true) { + return errorResult(`Refusing to remove "${app.name}" without confirm: true.`); + } + ctx.appOps.removeApp(app.id); + return jsonResult({ removed: app.name, note: 'Registration removed. Files on disk are untouched.' }); + } + ); + + tool( + 'add_task', + { + title: 'Add task', + mutates: true, + description: + 'Add a task to an app. Tasks are the per-service unit of a monorepo registration: each runs its own script or command, optionally from a subdirectory (`workingDir`, e.g. "apps/api"), and `dependsOn` other tasks so start_app brings services up in order (migrate, then api, then web). Pass exactly one of `script` or `command`. `oneShot` marks a run-to-completion task (migration, build) and defaults its readiness to exit code 0 so dependents wait for it to finish. For long-running services, configure a readiness probe with set_readiness after creating.', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + name: z.string().min(1).describe('Task name, unique within the app (e.g. the workspace package name).'), + script: z.string().optional().describe('package.json script to run (resolved from workingDir if set).'), + command: z.string().optional().describe('Raw shell command to run instead of a script.'), + workingDir: z.string().optional().describe('Subdirectory to run from, relative to the app root (e.g. "apps/api").'), + dependsOn: z.array(z.string()).optional().describe('Tasks (id or name) that must be ready before this one starts.'), + oneShot: z.boolean().optional().describe('Task runs to completion instead of serving (migrations, builds). Defaults readiness to exit code 0.'), + enabled: z.boolean().optional().describe('Disabled tasks are skipped by start_app (default true).') + } + }, + (args) => { + const app = resolveApp(ctx, String(args.app)); + const name = String(args.name).trim(); + if (!name) return errorResult('A non-empty task `name` is required.'); + if (ctx.taskRegistry.list(app.id).some((t) => t.name.toLowerCase() === name.toLowerCase())) { + return errorResult(`A task named "${name}" already exists in "${app.name}". Task names must be unique so they stay addressable.`); + } + + const script = args.script as string | undefined; + const command = args.command as string | undefined; + if (Boolean(script) === Boolean(command)) { + return errorResult('Pass exactly one of `script` or `command`.'); + } + + let workingDirOverride: string | null = null; + const rawDir = (args.workingDir as string | undefined)?.trim(); + if (rawDir) { + if (isAbsolute(rawDir) || rawDir.split(/[\\/]/).includes('..')) { + return errorResult('workingDir must be a subdirectory relative to the app root (no absolute paths or "..").'); + } + let isDir = false; + try { + isDir = statSync(join(app.path, rawDir)).isDirectory(); + } catch { + // missing - handled below + } + if (!isDir) { + return errorResult(`workingDir is not an existing directory under the app root: ${rawDir}`); + } + workingDirOverride = rawDir; + } + + const deps: TaskId[] = []; + for (const ref of (args.dependsOn as string[] | undefined) ?? []) { + const dep = resolveTask(ctx, String(ref), app.id); + if (dep.appId !== app.id) { + return errorResult(`Task "${ref}" belongs to a different app - dependencies must be within "${app.name}".`); + } + if (!dep.enabled) { + return errorResult(`Task "${dep.name}" is disabled - start_app skips it, so the dependency would silently never order anything. Enable it first or depend on an enabled task.`); + } + deps.push(dep.id); + } + + const oneShot = args.oneShot === true; + // TaskRegistry.add re-runs cycle detection on the new graph and throws with the cycle + // path; the tool wrapper surfaces that as an isError result. + const task = ctx.taskRegistry.add(app.id, { + name, + commandKind: script ? 'script' : 'custom', + script: script ?? null, + customCommand: command ?? null, + workingDirOverride, + dependsOn: deps, + oneShot, + readiness: oneShot ? { kind: 'exit', code: 0 } : { kind: 'none' }, + enabled: args.enabled === undefined ? true : Boolean(args.enabled) + }); + return jsonResult({ + app: app.name, + task: taskInfo(ctx, task, secretValuesForApp(ctx, app.id)), + note: 'Task created. Start it with start_task, or start_app to run the whole dependency order.' + }); + } + ); + + tool( + 'remove_task', + { + title: 'Remove task', + mutates: true, + description: + 'Remove a task from an app. Deletes its task-scoped env vars too; files on disk are untouched. Refuses while the task is running or while other tasks depend on it. Requires confirm: true.', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + task: z.string().describe('Task id, or task name (scoped by app).'), + confirm: z.boolean().describe('Must be true to actually remove.') + }, + annotations: { destructiveHint: true } + }, + (args) => { + const app = resolveApp(ctx, String(args.app)); + const task = resolveTask(ctx, String(args.task), String(args.app)); + // A task id resolves globally before app scoping applies - never delete across apps. + if (task.appId !== app.id) { + return errorResult(`Task "${task.name}" belongs to a different app than "${app.name}".`); + } + if (args.confirm !== true) { + return errorResult(`Refusing to remove task "${task.name}" without confirm: true.`); + } + // An in-flight start_app spawns later dependency levels from its own snapshot, so a + // task removed while the app is starting could still be spawned - as a process with + // no registry row that nothing can stop. Refuse until the app settles. + const appState = ctx.orchestrator.appState(task.appId); + if (appState === 'starting' || appState === 'exiting') { + return errorResult(`"${app.name}" is ${appState} - wait for it to settle before removing tasks.`); + } + if (ctx.runner.isRunning(task.id)) { + return errorResult('Stop the task before removing it.'); + } + // TaskRegistry.remove refuses while other tasks depend on this one (clear error + // naming the dependents); task-scoped env rows go with it (FK cascade). + ctx.taskRegistry.remove(task.id); + return jsonResult({ removed: task.name, note: 'Task removed. Its task-scoped env vars were deleted; files on disk are untouched.' }); + } + ); + + tool( + 'start_app', + { + title: 'Start app', + mutates: true, + description: + 'Start an app - runs its tasks in dependency order, waits for readiness, then waits briefly for port detection so the response includes the ports. This is how you run a project\'s dev server through DevHarbor; do not run npm/yarn/pnpm yourself. `readinessVerified` is null when the app has no readiness probe - "running" then means the processes are up, not that they are serving, and the response includes recent output so you can judge. Ports listed are those detected by return time; without a readiness probe, a service that binds well after its siblings can land later - check get_ports/get_app, or configure set_readiness so the start waits for it. If a task crashed or never became ready, its log tail is included so you can diagnose in one call. Long starts return "still starting" after 45s - poll get_app.', + inputSchema: { app: z.string().describe('App id, exact name, or absolute path.') } + }, + async (args) => { + const app = resolveApp(ctx, String(args.app)); + let startError: string | null = null; + const startP = ctx.orchestrator.startApp(app.id).then( + () => {}, + (e) => { + startError = cleanMessage(e); + } + ); + await Promise.race([startP, new Promise((r) => setTimeout(r, START_BUDGET_MS))]); + await settle(); + // Skip the port wait if the 45s readiness budget already elapsed (app still + // "starting") - stacking a ~12s port wait on top would push the tool past the SDK + // client's 60s request timeout. The "still starting" result already says to poll. + if (!startError && ctx.orchestrator.appState(app.id) !== 'starting') { + await waitForAppPorts(ctx, app.id); + } + return startResultFor(ctx, app, startError); + } + ); + + tool( + 'stop_app', + { + title: 'Stop app', + mutates: true, + description: 'Gracefully stop a running app (SIGTERM, grace period, then SIGKILL on the tree). Returns the final state.', + inputSchema: { app: z.string().describe('App id, exact name, or absolute path.') } + }, + async (args) => { + const app = resolveApp(ctx, String(args.app)); + await ctx.orchestrator.stopApp(app.id); + return jsonResult({ app: app.name, state: ctx.orchestrator.appState(app.id) }); + } + ); + + tool( + 'restart_app', + { + title: 'Restart app', + mutates: true, + description: + 'Stop then start an app under one lock. Idempotent: a stopped app is simply started. Same result shape as start_app, including a failure log tail.', + inputSchema: { app: z.string().describe('App id, exact name, or absolute path.') } + }, + async (args) => { + const app = resolveApp(ctx, String(args.app)); + let startError: string | null = null; + const p = ctx.orchestrator.restartApp(app.id).then( + () => {}, + (e) => { + startError = cleanMessage(e); + } + ); + await Promise.race([p, new Promise((r) => setTimeout(r, START_BUDGET_MS))]); + await settle(); + if (!startError) await waitForAppPorts(ctx, app.id); + return startResultFor(ctx, app, startError); + } + ); + + tool( + 'start_task', + { + title: 'Start task', + mutates: true, + description: + 'Start a single task (bypassing dependency ordering). By default waits for the task\'s readiness and briefly for port detection, then reports like start_app (ready is null when the task has no readiness probe); pass wait: false to return immediately after spawn.', + inputSchema: { + task: z.string().describe('Task id, or task name (scope with app).'), + app: z.string().optional().describe('App to scope the task name to.'), + wait: z.boolean().optional().describe('Wait for readiness before returning (default true).') + } + }, + async (args) => { + const task = resolveTask(ctx, String(args.task), args.app ? String(args.app) : undefined); + const app = ctx.registry.get(task.appId); + const wait = (args.wait as boolean | undefined) ?? true; + const { snapshot, awaitReady } = await ctx.orchestrator.runner.start(task); + if (!wait) { + return jsonResult({ + task: task.name, + app: app?.name, + state: snapshot.state, + ready: task.readiness.kind !== 'none' ? snapshot.ready : null, + ports: snapshot.ports, + note: 'Spawned; readiness not awaited (wait: false).' + }); + } + const outcome = await raceReadiness(awaitReady, START_BUDGET_MS); + await settle(); + const probed = (ctx.runner.get(task.id)?.readinessKind ?? task.readiness.kind) !== 'none'; + const rt0 = ctx.runner.get(task.id); + // Only wait for ports if readiness actually settled (not a budget timeout) and the task + // is live - otherwise we'd stack the port wait onto an already-45s call. + if (outcome !== 'timeout' && rt0 && (rt0.state === 'running' || rt0.state === 'starting')) { + await waitForTaskPorts(ctx, task.id); + } + const rt = ctx.runner.get(task.id); + const secrets = secretValuesForApp(ctx, task.appId); + const notes: string[] = []; + if (outcome === 'timeout') notes.push('Still starting after 45s; poll get_app.'); + const listening = (rt?.ports.length ?? 0) > 0; + if (!probed && rt?.state === 'running') { + notes.push( + listening + ? 'This task has no readiness probe (readiness kind "none") and is listening on a port - which usually means ready, but is not verified (a service can open its port before it can serve). Call set_readiness (e.g. an http probe) for a verified answer.' + : 'This task has no readiness probe (readiness kind "none") - "running" means the process is up, not that it is serving. Recent output is included so you can judge.' + ); + } + const header = { + task: task.name, + app: app?.name, + state: rt?.state ?? 'idle', + ready: probed ? (rt?.ready ?? false) : null, + listening: rt ? listening : null, + ports: rt?.ports ?? [], + portDetails: portOwnersFor(ctx, task.id, secrets), + readiness: outcome, + note: notes.length ? notes.join(' ') : undefined + }; + if (outcome === 'ready' && probed) return jsonResult(header); + const tail = cleanLogText(ctx.runner.tailBuffer(task.id, 30), secrets); + const label = !probed && rt?.state === 'running' ? ' (no readiness probe - judge from output)' : ''; + return tail.trim() ? logResult({ ...header, tailNote: label || undefined }, tail) : jsonResult(header); + } + ); + + tool( + 'stop_task', + { + title: 'Stop task', + mutates: true, + description: 'Stop a single running task.', + inputSchema: { + task: z.string().describe('Task id, or task name (scope with app).'), + app: z.string().optional().describe('App to scope the task name to.') + } + }, + async (args) => { + const task = resolveTask(ctx, String(args.task), args.app ? String(args.app) : undefined); + await ctx.orchestrator.stopTask(task.id); + return jsonResult({ task: task.name, state: ctx.runner.get(task.id)?.state ?? 'idle' }); + } + ); + + tool( + 'set_env_var', + { + title: 'Set env var', + mutates: true, + description: + 'Set (or update) an app-scoped environment variable. Takes effect on the next start. `secret` defaults to a name-based heuristic; secret values are write-only through MCP (list_env_vars masks them). Pass secret: false explicitly to keep a value readable.', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + key: z.string().describe('Variable name.'), + value: z.string().describe('Variable value.'), + secret: z.boolean().optional().describe('Mark as secret (default: heuristic on the key name).'), + enabled: z.boolean().optional().describe('Whether the variable is active (default true).') + } + }, + (args) => { + const app = resolveApp(ctx, String(args.app)); + const key = String(args.key).trim(); + if (!key) return errorResult('key cannot be empty.'); + const heuristic = isSecretKey(key); + const isSecret = (args.secret as boolean | undefined) ?? heuristic; + const enabled = (args.enabled as boolean | undefined) ?? true; + + const current = ctx.envStore.getApp(app.id).filter((v) => v.key !== key); + current.push({ id: '', appId: app.id, key, value: String(args.value), enabled, isSecret }); + ctx.envStore.setApp(app.id, current); + + return jsonResult({ + app: app.name, + key, + isSecret, + enabled, + note: + isSecret && args.secret === undefined + ? `Marked secret by name heuristic; the value is now write-only. Pass secret: false to keep it readable. Applies on next start.` + : 'Applies on the next start.' + }); + } + ); + + tool( + 'set_readiness', + { + title: 'Set readiness probe', + mutates: true, + description: + 'Configure how DevHarbor decides a task is READY (actually serving), so start/restart wait for it and report readinessVerified: true. This is the fix when a task reports ready: null / readinessVerified: null because it has no probe. kinds: "none" (no probe), "port" (a TCP port is listening - note a port can open before a service can serve), "log" (a log line matches regex), "http" (a GET to url returns success - best for a service that opens its port before its DB/cache connect; point it at /health or /ready), "delay" (fixed wait ms). Takes effect on the next start.', + inputSchema: { + app: z.string().describe('App id, exact name, or absolute path.'), + task: z.string().describe('Task id, or task name (scoped by app).'), + kind: z.enum(['none', 'port', 'log', 'http', 'delay']).describe('Readiness signal kind.'), + port: z.number().int().min(1).max(65535).optional().describe('For kind "port": the TCP port to wait for.'), + regex: z.string().optional().describe('For kind "log": a pattern to match in the task output.'), + flags: z.string().optional().describe('For kind "log": regex flags (default "i").'), + url: z.string().optional().describe('For kind "http": the URL to GET (e.g. http://localhost:4000/ready).'), + status: z.number().int().min(100).max(599).optional().describe('For kind "http": required status (default any 2xx/3xx).'), + ms: z.number().int().min(0).optional().describe('For kind "delay": milliseconds to wait.') + } + }, + (args) => { + const task = resolveTask(ctx, String(args.task), String(args.app)); + const kind = String(args.kind); + let readiness: ReadinessSignal; + switch (kind) { + case 'none': + readiness = { kind: 'none' }; + break; + case 'port': { + const port = args.port as number | undefined; + if (port == null) return errorResult('kind "port" requires a `port`.'); + readiness = { kind: 'port', port }; + break; + } + case 'log': { + const regex = args.regex as string | undefined; + if (!regex) return errorResult('kind "log" requires a `regex`.'); + try { + new RegExp(regex, (args.flags as string | undefined) ?? 'i'); + } catch (e) { + return errorResult(`Invalid regex: ${cleanMessage(e)}`); + } + readiness = { kind: 'log', regex, flags: (args.flags as string | undefined) ?? 'i' }; + break; + } + case 'http': { + const url = args.url as string | undefined; + if (!url) return errorResult('kind "http" requires a `url`.'); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return errorResult(`Invalid url: ${url}`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return errorResult('url must be http:// or https://.'); + } + // Loopback only: DevHarbor's main process polls this URL, so a non-loopback host + // would turn the readiness probe into an SSRF / egress primitive. A dev server's + // health endpoint is always local. + if (!isLoopbackHostname(parsed.hostname)) { + return errorResult('http readiness url must target localhost / 127.0.0.1 / [::1].'); + } + readiness = { kind: 'http', url, status: args.status as number | undefined }; + break; + } + case 'delay': { + const ms = args.ms as number | undefined; + if (ms == null) return errorResult('kind "delay" requires `ms`.'); + readiness = { kind: 'delay', ms }; + break; + } + default: + return errorResult(`Unsupported readiness kind: ${kind}`); + } + + const updated = ctx.taskRegistry.update(task.id, { readiness }); + return jsonResult({ + app: ctx.registry.get(task.appId)?.name, + task: updated.name, + readiness: updated.readiness, + note: 'Readiness updated. Takes effect on the next start/restart of this task.' + }); + } + ); +} diff --git a/src/main/services/AppOps.ts b/src/main/services/AppOps.ts new file mode 100644 index 0000000..045c182 --- /dev/null +++ b/src/main/services/AppOps.ts @@ -0,0 +1,237 @@ +import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { basename, isAbsolute, join } from 'node:path'; +import type { App, AppId, EnvVar } from '@shared/types'; +import type { CreateAppInput, GlobalLogMatch, ImportCandidate } from '@shared/ipc'; +import type { AppRegistry } from './AppRegistry'; +import type { TaskRegistry } from './TaskRegistry'; +import type { EnvStore } from './EnvStore'; +import type { DetectionService } from './DetectionService'; +import type { AppOrchestrator } from './AppOrchestrator'; + +/** + * Callbacks the composition root supplies so AppOps can keep watchers and the tray in sync + * after a mutation, without AppOps depending on those services directly. + */ +export interface AppOpsHooks { + watchEnvFiles: (appId: AppId, path: string) => void; + unwatchEnvFiles: (appId: AppId) => void; + syncRestartWatcher: (appId: AppId) => void; + unwatchRestart: (appId: AppId) => void; + refreshTray: () => void; +} + +/** + * The non-trivial app/task flows that BOTH the IPC handlers and the MCP tools need: + * atomic create-with-rollback, watcher-resyncing update, guarded remove, folder scan, and + * global log search. Extracted here so there is exactly one implementation of each and the + * two entry points cannot drift (see specs/07-mcp-server.md). Pure orchestration over the + * injected services; no electron imports, so it is unit-testable with fakes. + */ +export class AppOps { + constructor( + private readonly registry: AppRegistry, + private readonly tasks: TaskRegistry, + private readonly envStore: EnvStore, + private readonly detector: DetectionService, + private readonly orchestrator: AppOrchestrator, + private readonly hooks: AppOpsHooks + ) {} + + /** + * Atomic create: app + tasks + env vars, with best-effort rollback on failure. The path is + * validated to be an existing directory BEFORE realpath so a bad path reads as a friendly + * "Invalid path" rather than a raw ENOENT (a fix folded in during the AppOps extraction). + */ + async createApp(input: CreateAppInput): Promise { + const real = this.canonicalizeDir(input.path); + if (this.registry.getByPath(real)) { + throw new Error('This folder is already registered.'); + } + const app = await this.registry.add(input.path); + try { + const patched = this.registry.update(app.id, { + name: input.name?.trim() || app.name, + nodeVersionPref: input.nodeVersionPref ?? { kind: 'auto' }, + packageManager: input.packageManager ?? null, + defaultScript: input.defaultScript ?? null + }); + const taskSpecs = [...(input.firstTask ? [input.firstTask] : []), ...(input.tasks ?? [])]; + for (const spec of taskSpecs) { + this.tasks.add(app.id, { + name: spec.name, + commandKind: spec.commandKind, + script: spec.script ?? null, + customCommand: spec.customCommand ?? null, + workingDirOverride: spec.workingDirOverride ?? null, + enabled: true + }); + } + if (input.envVars && input.envVars.length > 0) { + const vars: EnvVar[] = input.envVars + .filter((v) => v.key.trim()) + .map((v) => ({ + id: '', + appId: app.id, + key: v.key.trim(), + value: v.value, + enabled: true, + isSecret: v.isSecret ?? false + })); + this.envStore.setApp(app.id, vars); + } + this.hooks.watchEnvFiles(app.id, app.path); + this.hooks.syncRestartWatcher(app.id); + this.hooks.refreshTray(); + return patched; + } catch (err) { + try { + this.registry.remove(app.id); + } catch { + /* best-effort rollback - FK cascade cleans tasks/env */ + } + throw err; + } + } + + /** Patch an app, re-syncing env-file + restart watchers when the relevant fields changed. */ + updateApp(id: AppId, patch: Partial): App { + const before = this.registry.get(id); + const app = this.registry.update(id, patch); + if (!before || before.path !== app.path) { + this.hooks.watchEnvFiles(app.id, app.path); + } + if ( + !before || + before.autoRestartOnChange !== app.autoRestartOnChange || + before.path !== app.path || + JSON.stringify(before.watchGlobs) !== JSON.stringify(app.watchGlobs) + ) { + this.hooks.syncRestartWatcher(app.id); + } + this.hooks.refreshTray(); + return app; + } + + /** + * Remove an app's registration (never its files on disk). Refuses while the app is live - + * the same guard the UI enforces, kept here so no caller (IPC or MCP) can bypass it. + */ + removeApp(id: AppId): void { + const st = this.orchestrator.appState(id); + if (st === 'running' || st === 'starting' || st === 'exiting') { + throw new Error('Stop the app before removing it.'); + } + this.hooks.unwatchEnvFiles(id); + this.hooks.unwatchRestart(id); + this.orchestrator.clearOutcome(id); + this.registry.remove(id); + this.hooks.refreshTray(); + } + + /** Shallow one-level scan of a folder for package.json projects (bulk import). */ + async scanFolder(dir: string): Promise { + const out: ImportCandidate[] = []; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return out; + } + for (const name of entries) { + if (name.startsWith('.')) continue; + const full = join(dir, name); + try { + if (!statSync(full).isDirectory()) continue; + if (!existsSync(join(full, 'package.json'))) continue; + } catch { + continue; + } + let real = full; + try { + real = realpathSync(full); + } catch { + // use raw + } + const detection = await this.detector.detect(full); + out.push({ + path: full, + name: basename(real), + alreadyRegistered: !!this.registry.getByPath(real), + packageManager: detection.packageManager, + suggestedScript: detection.suggestedDefaultScript, + scripts: Object.keys(detection.scripts) + }); + } + return out.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Global log search across every task with a retained buffer - including tasks that exited + * in the last ~10 minutes, not only live ones (a deliberate change during extraction; the + * old handler only iterated running tasks). Regex by default with a literal fallback on an + * invalid pattern; `g`/`y` flags are stripped so a stateful RegExp can't skip matches. + */ + searchLogs(opts: { + query: string; + regex?: boolean; + flags?: string; + appId?: AppId; + limit?: number; + }): GlobalLogMatch[] { + const out: GlobalLogMatch[] = []; + const query = opts.query.trim(); + if (!query) return out; + + const useRegex = opts.regex ?? true; + let re: RegExp; + if (useRegex) { + const flags = ((opts.flags ?? 'i').replace(/[gy]/g, '') || 'i'); + try { + re = new RegExp(query, flags); + } catch { + re = new RegExp(escapeRegex(query), 'i'); + } + } else { + re = new RegExp(escapeRegex(query), 'i'); + } + + const cap = opts.limit ?? 500; + const runner = this.orchestrator.runner; + for (const taskId of runner.bufferedTaskIds()) { + const task = this.tasks.get(taskId); + if (!task) continue; + if (opts.appId && task.appId !== opts.appId) continue; + const app = this.registry.get(task.appId); + const buf = runner.readBuffer(taskId); + for (const line of buf.split('\n')) { + if (re.test(line)) { + out.push({ + appId: task.appId, + taskId, + appName: app?.name ?? '', + taskName: task.name, + line: line.length > 2000 ? line.slice(0, 2000) : line + }); + if (out.length >= cap) return out; + } + } + } + return out; + } + + /** Resolve to a canonical, existing directory. Throws a friendly message otherwise. */ + private canonicalizeDir(p: string): string { + if (!isAbsolute(p)) throw new Error(`Path must be absolute: ${p}`); + try { + const real = realpathSync(p); + if (!statSync(real).isDirectory()) throw new Error(`Not a directory: ${p}`); + return real; + } catch (err) { + throw new Error(`Invalid path: ${p} (${(err as Error).message})`); + } + } +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/main/services/AppOrchestrator.ts b/src/main/services/AppOrchestrator.ts index 968b7b4..adf88ee 100644 --- a/src/main/services/AppOrchestrator.ts +++ b/src/main/services/AppOrchestrator.ts @@ -33,6 +33,12 @@ export class AppOrchestrator extends EventEmitter { // (IMPROVEMENT-PLAN 7.4). Different apps still run concurrently. private readonly ops = new Map>(); + // Set once teardown begins (stopAllRunning on quit). A start that was already ENQUEUED + // behind an in-flight op for the same app - e.g. an MCP start_app dispatched just before + // quit - would otherwise run runner.start() AFTER the teardown snapshot and spawn a PTY + // that outlives the app as an orphan. Queued starts check this flag and no-op instead. + private shuttingDown = false; + // Cancellation hooks for in-flight starts. The lock serialises a queued stop BEHIND the // start - and doStartApp can sit awaiting readiness for up to readiness_timeout_ms per // level, which is exactly when users reach for Stop. stopApp/restartApp invoke this @@ -125,6 +131,9 @@ export class AppOrchestrator extends EventEmitter { } private async doStartApp(appId: AppId): Promise { + // If teardown began while this start sat queued behind another op, do not spawn - the + // quit path has already (or is about to) snapshot running tasks and would miss us. + if (this.shuttingDown) return; const allTasks = this.tasks.list(appId).filter((t) => t.enabled); if (allTasks.length === 0) { throw new Error('No enabled tasks for this app. Add one via Manage tasks.'); @@ -277,6 +286,7 @@ export class AppOrchestrator extends EventEmitter { } async startTask(taskId: TaskId): Promise { + if (this.shuttingDown) throw new Error('DevHarbor is shutting down.'); const task = this.tasks.get(taskId); if (!task) throw new Error(`Task not found: ${taskId}`); const { snapshot } = await this.runner.start(task); @@ -303,7 +313,26 @@ export class AppOrchestrator extends EventEmitter { * as orphans when the PTY master closes (IMPROVEMENT-PLAN 5.9). Mid-spawn starts are * included - runner.stop() awaits the pending spawn before killing it. */ + /** + * Enter app-quit teardown: block new AND already-queued starts permanently (the process is + * going away), and CANCEL every in-flight start so a readiness wait can't hold the quit + * hostage for up to readiness_timeout_ms per level. Cancelled starts break out immediately + * (their partial tasks land in runner.list() and are stopped by the following + * stopAllRunning). Deliberately separate from stopAllRunning - the tray's "Stop all" uses + * that too, and a plain stop-all must NOT leave the orchestrator refusing future starts. + */ + beginShutdown(): void { + this.shuttingDown = true; + for (const cancel of this.startCancels.values()) cancel(); + } + async stopAllRunning(): Promise { + // During quit (beginShutdown called): let any op queued behind an in-flight one drain to + // its now-no-op completion BEFORE snapshotting - otherwise a start queued behind a prior + // stop/restart could reach runner.start() after the snapshot and orphan a fresh PTY. + if (this.shuttingDown) { + await Promise.allSettled([...this.ops.values()]); + } const ids = new Set([ ...this.runner.list().map((t) => t.taskId), ...this.runner.pendingStartIds() diff --git a/src/main/services/DetectionService.ts b/src/main/services/DetectionService.ts index be7da46..4d96611 100644 --- a/src/main/services/DetectionService.ts +++ b/src/main/services/DetectionService.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { isAbsolute, join, relative } from 'node:path'; import type { DetectionResult, WorkspaceCandidate } from '@shared/types'; import { PMDetector } from './PMDetector'; import { NodeResolver } from './NodeResolver'; @@ -58,6 +58,11 @@ export class DetectionService { } const out: WorkspaceCandidate[] = []; for (const dir of dirs) { + // Workspace globs can point outside the repo (`packages: ['../*']`). Never offer a + // candidate outside the root: a task created from it would run some unrelated + // sibling project's scripts under this app's name. + const rel = relative(root, dir); + if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue; const pkgPath = join(dir, 'package.json'); if (!existsSync(pkgPath)) continue; try { @@ -68,8 +73,8 @@ export class DetectionService { : []; if (scriptNames.length === 0) continue; out.push({ - name: typeof data?.name === 'string' ? data.name : relative(root, dir), - relPath: relative(root, dir), + name: typeof data?.name === 'string' ? data.name : rel, + relPath: rel, scripts: scriptNames, suggestedScript: pickSuggested(scriptNames) }); diff --git a/src/main/services/LogBuffer.ts b/src/main/services/LogBuffer.ts index ad1775b..d57c3b6 100644 --- a/src/main/services/LogBuffer.ts +++ b/src/main/services/LogBuffer.ts @@ -87,6 +87,15 @@ export class LogBuffer { return buf.chunks.join(''); } + /** + * Task ids that currently have a buffer - includes recently-exited tasks whose output is + * still retained (up to ~10 min). Enumerating here (rather than reading every task's + * buffer) lets a global search cover exited tasks without skewing LRU by touching them. + */ + bufferedTaskIds(): TaskId[] { + return [...this.buffers.keys()]; + } + tail(taskId: TaskId, maxLines = 200): string { const buf = this.buffers.get(taskId); if (!buf) return ''; diff --git a/src/main/services/PortDetector.ts b/src/main/services/PortDetector.ts index 6609322..fa8e8de 100644 --- a/src/main/services/PortDetector.ts +++ b/src/main/services/PortDetector.ts @@ -16,11 +16,22 @@ export interface PortsEvent { ports: number[]; } +/** Which process inside a task's tree owns a listening port (for labeling, e.g. one turbo + task fanning out into web + preview + api servers). `pid`/`process` are null for ports + only hinted from stdout that lsof has not confirmed yet. */ +export interface PortOwner { + port: number; + pid: number | null; + process: string | null; +} + interface Tracked { taskId: TaskId; appId: AppId; pid: number; knownPorts: Set; + /** Last confirmed owner (pid + shortened command) per port, from the lsof/ps tick. */ + owners: Map; /** Ports inferred from stdout (e.g. "localhost:3000"); merged with lsof results. */ hinted: Set; /** Per-hint count of consecutive polls where lsof did NOT confirm the port. */ @@ -48,6 +59,7 @@ export class PortDetector extends EventEmitter { appId, pid, knownPorts: new Set(), + owners: new Map(), hinted: new Set(), hintMisses: new Map() }); @@ -57,6 +69,21 @@ export class PortDetector extends EventEmitter { void this.tick(); } + /** + * Per-port ownership for a task: which descendant process listens on each known port. + * Hinted-but-unconfirmed ports come back with a null pid/process. + */ + portOwners(taskId: TaskId): PortOwner[] { + const t = this.tracked.get(taskId); + if (!t) return []; + const out: PortOwner[] = []; + for (const port of [...t.knownPorts].sort((a, b) => a - b)) { + const owner = t.owners.get(port); + out.push({ port, pid: owner?.pid ?? null, process: owner?.process ?? null }); + } + return out; + } + untrack(taskId: TaskId): void { this.tracked.delete(taskId); if (this.tracked.size === 0) this.stop(); @@ -99,8 +126,9 @@ export class PortDetector extends EventEmitter { try { // ONE `ps` snapshot for the whole machine instead of one `pgrep -P` per process in // every task's tree, every tick (IMPROVEMENT-PLAN 9.2). With N tasks this turns - // ~25-35 forks/sec into 2: one ps + one lsof. - const childrenByPpid = await snapshotProcessTree(); + // ~25-35 forks/sec into 2: one ps + one lsof. The same snapshot carries each pid's + // command line so listening ports can be labeled with their owning process. + const { childrenByPpid, commandByPid } = await snapshotProcessTree(); const descByTask = new Map>(); const allPids = new Set(); @@ -146,6 +174,17 @@ export class PortDetector extends EventEmitter { // instead of blanking every task's chips because one pid in the union was stale. if (lsofResult.failed && observed.size === 0) continue; + // Record which descendant owns each confirmed port (labels for the UI/MCP). Rebuilt + // per successful tick so a restarted child updates its pid/command. + const owners = new Map(); + for (const pid of desc) { + const ps = portsByPid.get(pid); + if (!ps) continue; + const command = commandByPid.get(pid); + for (const p of ps) owners.set(p, { pid, process: command ? shortCommand(command) : null }); + } + t.owners = owners; + const merged = new Set([...observed, ...t.hinted]); if (!setsEqual(t.knownPorts, merged)) { t.knownPorts = merged; @@ -165,28 +204,50 @@ export class PortDetector extends EventEmitter { } /** - * One `ps` for the whole process table → map of ppid → child pids. Zombies are excluded: - * a reaped-but-unwaited child would otherwise poison the batched lsof call below (lsof - * exits non-zero when ANY pid in `-p` can't be opened). + * One `ps` for the whole process table → ppid → child pids, plus each pid's command line + * (for port-ownership labels). Zombies are excluded: a reaped-but-unwaited child would + * otherwise poison the batched lsof call below (lsof exits non-zero when ANY pid in `-p` + * can't be opened). */ -async function snapshotProcessTree(): Promise> { - const byPpid = new Map(); +async function snapshotProcessTree(): Promise<{ + childrenByPpid: Map; + commandByPid: Map; +}> { + const childrenByPpid = new Map(); + const commandByPid = new Map(); try { - const { stdout } = await execFileP('ps', ['-axo', 'pid=,ppid=,stat=']); + // args= is last on purpose: it contains spaces, so everything after the third field + // belongs to it. + const { stdout } = await execFileP('ps', ['-axo', 'pid=,ppid=,stat=,args=']); for (const line of stdout.split(/\r?\n/)) { - const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)/); + const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); if (!m) continue; if (m[3]!.startsWith('Z')) continue; // zombie - skip const pid = Number(m[1]); const ppid = Number(m[2]); - const arr = byPpid.get(ppid); + const arr = childrenByPpid.get(ppid); if (arr) arr.push(pid); - else byPpid.set(ppid, [pid]); + else childrenByPpid.set(ppid, [pid]); + if (m[4]) commandByPid.set(pid, m[4]); } } catch { // ps failed - return what we have (empty), callers treat as "no descendants". } - return byPpid; + return { childrenByPpid, commandByPid }; +} + +/** + * Shorten a raw `ps args` command line into a human label: absolute path tokens reduced to + * their basename, capped length. Processes that retitle themselves (next-server (v15)) + * come through as-is. Exported for tests. + */ +export function shortCommand(args: string): string { + const shortened = args + .trim() + .split(/\s+/) + .map((tok) => (tok.startsWith('/') && tok.length > 1 ? (tok.split('/').pop() ?? tok) : tok)) + .join(' '); + return shortened.length > 80 ? `${shortened.slice(0, 77)}...` : shortened; } /** BFS the snapshot to collect a root pid and all its descendants (bounded depth). */ diff --git a/src/main/services/Settings.ts b/src/main/services/Settings.ts index 3f21bd5..cd58193 100644 --- a/src/main/services/Settings.ts +++ b/src/main/services/Settings.ts @@ -12,6 +12,10 @@ export type SettingsMap = { tray_enabled: boolean; run_history_limit: number; readiness_timeout_ms: number; + mcp_enabled: boolean; + mcp_port: number; + mcp_require_auth: boolean; + mcp_https: boolean; }; const DEFAULTS: SettingsMap = { @@ -25,7 +29,11 @@ const DEFAULTS: SettingsMap = { launch_at_login: false, tray_enabled: true, run_history_limit: 500, - readiness_timeout_ms: 60_000 + readiness_timeout_ms: 60_000, + mcp_enabled: false, + mcp_port: 6872, + mcp_require_auth: true, + mcp_https: false }; export class Settings { @@ -45,7 +53,13 @@ export class Settings { launch_at_login: parseBool(map.get('launch_at_login'), DEFAULTS.launch_at_login), tray_enabled: parseBool(map.get('tray_enabled'), DEFAULTS.tray_enabled), run_history_limit: parseNumber(map.get('run_history_limit'), DEFAULTS.run_history_limit), - readiness_timeout_ms: parseNumber(map.get('readiness_timeout_ms'), DEFAULTS.readiness_timeout_ms) + readiness_timeout_ms: parseNumber(map.get('readiness_timeout_ms'), DEFAULTS.readiness_timeout_ms), + mcp_enabled: parseBool(map.get('mcp_enabled'), DEFAULTS.mcp_enabled), + // Clamp on read as well as write: a hand-edited or corrupt row must not make the + // MCP server try to bind a privileged or nonsense port. + mcp_port: clampPort(parseNumber(map.get('mcp_port'), DEFAULTS.mcp_port)), + mcp_require_auth: parseBool(map.get('mcp_require_auth'), DEFAULTS.mcp_require_auth), + mcp_https: parseBool(map.get('mcp_https'), DEFAULTS.mcp_https) }; } @@ -75,7 +89,11 @@ export class Settings { 'launch_at_login', 'tray_enabled', 'run_history_limit', - 'readiness_timeout_ms' + 'readiness_timeout_ms', + 'mcp_enabled', + 'mcp_port', + 'mcp_require_auth', + 'mcp_https' ]); const tx = db().transaction(() => { for (const [k, v] of Object.entries(patch)) { @@ -105,3 +123,9 @@ function parseTheme(v: string | undefined): SettingsMap['theme'] { if (v === 'light' || v === 'dark' || v === 'system') return v; return 'system'; } + +/** Keep the MCP port in the unprivileged range; anything else falls back to the default. */ +function clampPort(n: number): number { + if (!Number.isInteger(n) || n < 1024 || n > 65535) return DEFAULTS.mcp_port; + return n; +} diff --git a/src/main/services/TaskRunner.ts b/src/main/services/TaskRunner.ts index 2b2518f..9ad4322 100644 --- a/src/main/services/TaskRunner.ts +++ b/src/main/services/TaskRunner.ts @@ -113,6 +113,11 @@ export class TaskRunner extends EventEmitter { this.logs.clear(taskId); } + /** Task ids with a retained log buffer (live or recently-exited). For global search. */ + bufferedTaskIds(): TaskId[] { + return this.logs.bufferedTaskIds(); + } + resize(taskId: TaskId, cols: number, rows: number): void { const t = this.tracked.get(taskId); if (!t) return; @@ -139,6 +144,9 @@ export class TaskRunner extends EventEmitter { } isRunning(taskId: TaskId): boolean { + // `pending` covers the mid-spawn window (env still building, PTY not yet tracked) so + // a concurrent remove cannot slip between "not tracked yet" and the actual spawn. + if (this.pending.has(taskId)) return true; const t = this.tracked.get(taskId); return !!t && (t.state === 'starting' || t.state === 'running'); } @@ -602,6 +610,9 @@ function toRunningTask(t: Tracked): RunningTask { pid: t.pid, state: t.state, ready: t.ready, + // The readiness kind the LIVE watcher was built from (captured at spawn), so readers can + // tell a verified `ready` from a vacuous one even after the stored config is edited. + readinessKind: t.task.readiness.kind, startedAt: t.startedAt, command: t.command, nodeVersion: t.nodeVersion, diff --git a/src/main/services/__tests__/AppOrchestrator.state.test.ts b/src/main/services/__tests__/AppOrchestrator.state.test.ts index 480ed7f..875f484 100644 --- a/src/main/services/__tests__/AppOrchestrator.state.test.ts +++ b/src/main/services/__tests__/AppOrchestrator.state.test.ts @@ -22,7 +22,10 @@ function makeFakes() { }; const tasks = { - list: (_appId: string) => [{ id: 't1', appId: 'a1', enabled: true }] + list: (_appId: string) => [ + { id: 't1', appId: 'a1', enabled: true, dependsOn: [], position: 0, readiness: { kind: 'none' } } + ], + get: (_id: string) => ({ id: 't1', appId: 'a1', enabled: true, dependsOn: [], position: 0, readiness: { kind: 'none' } }) }; const apps = { @@ -100,6 +103,40 @@ describe('AppOrchestrator app-state on stop', () => { expect(orch.appState('a1' as never)).toBe('idle'); }); + it('beginShutdown() cancels an in-flight start blocked on readiness (quit must not hang)', async () => { + // A start whose readiness never resolves: without cancellation, startApp would sit on + // the readiness wait for up to readiness_timeout_ms, hanging the quit teardown. + let started = 0; + const neverReady = new Promise(() => {}); // never resolves + const fakeRunner = runner as unknown as { + start: (t: unknown) => Promise<{ snapshot: unknown; awaitReady: Promise }>; + isRunning: (id: string) => boolean; + isReady: (id: string) => boolean; + }; + fakeRunner.isRunning = () => false; + fakeRunner.isReady = () => false; + fakeRunner.start = () => { + started++; + setRunList([{ taskId: 't1', appId: 'a1', state: 'running', ready: false }]); + return Promise.resolve({ snapshot: {}, awaitReady: neverReady }); + }; + + const startP = orch.startApp('a1' as never); + // Let doStartApp reach the readiness race + register its cancel hook. + await new Promise((r) => setTimeout(r, 20)); + expect(started).toBe(1); + + // Quit begins: this must make the blocked start bail promptly. + orch.beginShutdown(); + + // If cancellation works, startApp settles quickly; race it against a short timeout. + const settled = await Promise.race([ + startP.then(() => 'settled'), + new Promise((r) => setTimeout(() => r('hung'), 1000)) + ]); + expect(settled).toBe('settled'); + }); + it('primeOutcome() persists a Stopped/Crashed badge across restart (boot seeding)', () => { // Simulate a fresh boot: nothing running, but history says it last exited. setRunList([]); diff --git a/src/main/services/readiness/HttpReadiness.ts b/src/main/services/readiness/HttpReadiness.ts new file mode 100644 index 0000000..ce80c0d --- /dev/null +++ b/src/main/services/readiness/HttpReadiness.ts @@ -0,0 +1,83 @@ +import http from 'node:http'; +import https from 'node:https'; +import type { ReadinessHandle, ReadinessWatcher } from './index'; + +const POLL_MS = 700; +const REQUEST_TIMEOUT_MS = 2500; + +/** + * Ready when an HTTP GET to `url` returns the expected status (default: any 2xx or 3xx). + * + * This is the signal for a service that binds its port BEFORE it can actually serve - e.g. + * an API that opens :4000 while Postgres/Redis are still connecting. A port probe would call + * it ready too early; hitting its /ready endpoint does not. Loopback dev servers over HTTPS + * are typically self-signed, so TLS is not certificate-verified here (the readiness URL is + * operator-configured and points at localhost). + */ +export function makeHttpWatcher( + url: string, + expectStatus: number | undefined, + handle: ReadinessHandle +): ReadinessWatcher { + let disposed = false; + let resolveReady!: (v: boolean) => void; + const ready = new Promise((res) => { + resolveReady = res; + }); + + const offStatus = handle.onStatus((state) => { + if (state === 'exited' || state === 'crashed') { + if (!disposed) resolveReady(false); + } + }); + + let parsed: URL | null = null; + try { + parsed = new URL(url); + } catch { + parsed = null; // invalid URL: never fires; the readiness timeout will clear the spinner + } + // Only skip TLS verification for a loopback target (self-signed dev certs). The MCP + // set_readiness tool already restricts to loopback; a URL set via the UI could point + // elsewhere, and for those we verify certs normally rather than blanket-trusting. + const loopback = + !!parsed && ['127.0.0.1', '::1', 'localhost', '[::1]'].includes(parsed.hostname.toLowerCase()); + + const schedule = (): void => { + if (!disposed) setTimeout(check, POLL_MS); + }; + + const check = (): void => { + if (disposed || !parsed) { + if (!disposed) schedule(); + return; + } + const lib = parsed.protocol === 'https:' ? https : http; + const req = lib.get( + parsed, + { timeout: REQUEST_TIMEOUT_MS, rejectUnauthorized: !loopback } as https.RequestOptions, + (res) => { + res.resume(); // drain the body so the socket frees + const code = res.statusCode ?? 0; + const ok = expectStatus != null ? code === expectStatus : code >= 200 && code < 400; + if (ok) { + if (!disposed) resolveReady(true); + } else { + schedule(); + } + } + ); + req.on('error', () => schedule()); + req.on('timeout', () => req.destroy()); + }; + + setTimeout(check, POLL_MS); + + return { + ready, + dispose: () => { + disposed = true; + offStatus(); + } + }; +} diff --git a/src/main/services/readiness/__tests__/HttpReadiness.test.ts b/src/main/services/readiness/__tests__/HttpReadiness.test.ts new file mode 100644 index 0000000..e84d436 --- /dev/null +++ b/src/main/services/readiness/__tests__/HttpReadiness.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import http from 'node:http'; +import { makeHttpWatcher } from '../HttpReadiness'; +import type { ReadinessHandle } from '../index'; + +/** A ReadinessHandle whose onStatus we can fire; onLog is unused by the http watcher. */ +function fakeHandle(): { handle: ReadinessHandle; exit: () => void } { + let statusListener: ((state: 'exited' | 'crashed', code: number | null) => void) | null = null; + const handle: ReadinessHandle = { + pid: 1234, + onLog: () => () => {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onStatus: (l: any) => { + statusListener = l; + return () => { + statusListener = null; + }; + } + }; + return { handle, exit: () => statusListener?.('exited', 0) }; +} + +describe('makeHttpWatcher', () => { + const servers: http.Server[] = []; + afterEach(() => { + for (const s of servers) s.close(); + servers.length = 0; + }); + + function serverReturning(getStatus: () => number): Promise<{ url: string; setStatus: (n: number) => void }> { + let status = getStatus(); + const server = http.createServer((_req, res) => { + res.writeHead(status); + res.end('ok'); + }); + servers.push(server); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as { port: number }).port; + resolve({ url: `http://127.0.0.1:${port}/ready`, setStatus: (n) => (status = n) }); + }); + }); + } + + it('resolves true once the endpoint returns 2xx', async () => { + const { url } = await serverReturning(() => 200); + const { handle } = fakeHandle(); + const w = makeHttpWatcher(url, undefined, handle); + expect(await w.ready).toBe(true); + w.dispose(); + }, 10_000); + + it('waits for a 503-then-200 flip (service warming up)', async () => { + const { url, setStatus } = await serverReturning(() => 503); + const { handle } = fakeHandle(); + const w = makeHttpWatcher(url, undefined, handle); + setTimeout(() => setStatus(200), 900); + expect(await w.ready).toBe(true); + w.dispose(); + }, 10_000); + + it('honors an explicit expected status', async () => { + const { url } = await serverReturning(() => 204); + const { handle } = fakeHandle(); + const w = makeHttpWatcher(url, 204, handle); + expect(await w.ready).toBe(true); + w.dispose(); + // A 200 would NOT satisfy expectStatus 204. + const { url: url2 } = await serverReturning(() => 200); + const { handle: h2, exit } = fakeHandle(); + const w2 = makeHttpWatcher(url2, 204, h2); + exit(); // task dies before the (never-matching) probe succeeds + expect(await w2.ready).toBe(false); + w2.dispose(); + }, 10_000); + + it('resolves false when the task exits before readiness', async () => { + // Nothing listening on this port; the probe keeps failing until the task exits. + const { handle, exit } = fakeHandle(); + const w = makeHttpWatcher('http://127.0.0.1:1/never', undefined, handle); + setTimeout(exit, 300); + expect(await w.ready).toBe(false); + w.dispose(); + }, 10_000); +}); diff --git a/src/main/services/readiness/index.ts b/src/main/services/readiness/index.ts index 88e714c..0ddc1ef 100644 --- a/src/main/services/readiness/index.ts +++ b/src/main/services/readiness/index.ts @@ -20,6 +20,7 @@ export interface ReadinessHandle { import { makeNoneWatcher } from './NoneReadiness'; import { makePortWatcher } from './PortReadiness'; import { makeLogWatcher } from './LogReadiness'; +import { makeHttpWatcher } from './HttpReadiness'; import { makeExitWatcher } from './ExitReadiness'; import { makeDelayWatcher } from './DelayReadiness'; @@ -34,6 +35,8 @@ export function createReadinessWatcher( return makePortWatcher(signal.port, handle); case 'log': return makeLogWatcher(signal.regex, signal.flags, handle); + case 'http': + return makeHttpWatcher(signal.url, signal.status, handle); case 'exit': return makeExitWatcher(signal.code ?? 0, handle); case 'delay': diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b38d913..7fe2fa4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -49,8 +49,11 @@ export function App(): JSX.Element { // Apply persisted theme to . useTheme(); - useEffect(() => { - void (async () => { + // Re-sync the cached view (apps + running snapshot + tasks) from the main process. Used on + // cold boot (resetView: land on Dashboard) and on `state:invalidate` from MCP mutations + // (resetView false: keep the user where they are). + const syncAll = useCallback( + async (resetView: boolean): Promise => { const [list, running, runningTasks] = await Promise.all([ window.api.invoke('apps:list', undefined), window.api.invoke('proc:list', undefined), @@ -59,21 +62,36 @@ export function App(): JSX.Element { setApps(list); setRunningApps(running); setRunningTasks(runningTasks); - // Pre-load tasks for every registered app so the Dashboard knows their task counts + - // port chips without waiting for the user to open each AppDetail. One tasks:listAll - // round-trip instead of the old per-app N+1 loop (IMPROVEMENT-PLAN 9.7). + // Pre-load tasks for every app so the Dashboard has task counts + port chips without a + // per-app N+1 (IMPROVEMENT-PLAN 9.7). Skip the app whose task editor is open: its + // drawer holds optimistic edits behind a debounced save, and a snapshot taken before + // that save flushes would revert the inputs mid-typing. try { const all = await window.api.invoke('tasks:listAll', undefined); + const locked = useStore.getState().taskEditLockAppId; for (const a of list) { + if (a.id === locked) continue; useStore.getState().setTasksForApp(a.id, all[a.id] ?? []); } } catch { // best-effort } - // Always land on Dashboard on cold boot / Cmd+R reload. App detail is - // reached by clicking an app in the sidebar. - setView('dashboard'); - })(); + if (resetView) setView('dashboard'); + }, + [setApps, setRunningApps, setRunningTasks, setView] + ); + + useEffect(() => { + void syncAll(true); + + // The MCP server (or any non-renderer path) can add/update/remove/start/stop apps; it + // fires `state:invalidate` so the desktop reflects those changes without a manual reload. + // Debounced to coalesce a burst of agent calls, and it never resets the current view. + let invalidateTimer: ReturnType | null = null; + const offInvalidate = window.api.on('state:invalidate', () => { + if (invalidateTimer) clearTimeout(invalidateTimer); + invalidateTimer = setTimeout(() => void syncAll(false), 200); + }); const offTaskStatus = window.api.on( 'task:status', @@ -128,6 +146,8 @@ export function App(): JSX.Element { void window.api.invoke('logs:openFolder', undefined); }); return () => { + if (invalidateTimer) clearTimeout(invalidateTimer); + offInvalidate(); offTaskStatus(); offAppStatus(); offStats(); @@ -143,10 +163,7 @@ export function App(): JSX.Element { offMenuOpenLogs(); }; }, [ - setApps, - setRunningApps, - setRunningTasks, - setView, + syncAll, setSelected, applyAppStatus, applyTaskStatus, diff --git a/src/renderer/components/EnvEditor.tsx b/src/renderer/components/EnvEditor.tsx index 71ee5f9..3bffbaa 100644 --- a/src/renderer/components/EnvEditor.tsx +++ b/src/renderer/components/EnvEditor.tsx @@ -137,6 +137,30 @@ export function EnvEditor({ void refreshTask(); }, [refreshTask]); + // An MCP client can change env vars while this editor is open (set_env_var), and + // saving a scope writes the FULL row list back (replace semantics) - a stale panel + // would silently delete the agent's rows on the next Save. On invalidate, refresh + // every scope the user has NOT edited; a dirty scope keeps its in-progress edits + // (same trade-off as the .env disk-change banner). Read dirtiness through a ref so + // keystrokes don't churn the subscription. + const dirtyRef = useRef(dirty); + dirtyRef.current = dirty; + useEffect(() => { + let timer: number | null = null; + const off = window.api.on('state:invalidate', () => { + if (timer != null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + if (!dirtyRef.current.global) void refreshGlobal(); + if (!dirtyRef.current.app) void refreshApp(); + if (!dirtyRef.current.task) void refreshTask(); + }, 200); + }); + return () => { + if (timer != null) window.clearTimeout(timer); + off(); + }; + }, [refreshGlobal, refreshApp, refreshTask]); + const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null; const current = tab === 'app' ? appVars : tab === 'global' ? globalVars : taskVars; diff --git a/src/renderer/components/SettingsDrawer.tsx b/src/renderer/components/SettingsDrawer.tsx index 36d5dc2..02f3679 100644 --- a/src/renderer/components/SettingsDrawer.tsx +++ b/src/renderer/components/SettingsDrawer.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; -import { X, Download, AlertTriangle } from 'lucide-react'; +import { X, Download, AlertTriangle, Copy, RefreshCw, Check, Eye, EyeOff, Info } from 'lucide-react'; import type { NodeInstallation } from '@shared/types'; -import type { SettingsState } from '@shared/ipc'; +import type { McpStatus, SettingsState } from '@shared/ipc'; import { ErrorBanner } from './ErrorBanner'; import { openConfirm } from './PromptModal'; import { useDialog } from '../hooks/useDialog'; @@ -217,6 +217,8 @@ export function SettingsDrawer({ onClose }: { onClose: () => void }): JSX.Elemen + +
Database lives at {dbPath} @@ -385,3 +387,215 @@ function NumberInput({ function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, v)); } + +/** A small copy-to-clipboard button that flips to a check for a moment after copying. */ +function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }): JSX.Element { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +/** + * MCP server controls: enable toggle, live status, port, auth token, and copy-paste client + * config. Status arrives via the `mcp:statusChanged` event so a port-in-use error (or a + * successful start) shows without a manual refresh. + */ +function McpSection({ + settings, + update +}: { + settings: SettingsState; + update: (patch: Partial) => Promise; +}): JSX.Element { + const [status, setStatus] = useState(null); + const [token, setToken] = useState(null); + const [reveal, setReveal] = useState(false); + + const refreshToken = (): void => { + void window.api.invoke('mcp:token', undefined).then(setToken); + }; + + useEffect(() => { + void window.api.invoke('mcp:status', undefined).then(setStatus); + if (settings.mcp_require_auth) refreshToken(); + const off = window.api.on('mcp:statusChanged', (s) => setStatus(s)); + return off; + // Re-subscribe when auth flips so the token field appears/updates. + }, [settings.mcp_require_auth]); + + const url = + status?.url ?? `${settings.mcp_https ? 'https' : 'http'}://127.0.0.1:${settings.mcp_port}/mcp`; + const clientConfig = JSON.stringify( + { + mcpServers: { + devharbor: { + type: 'http', + url, + ...(settings.mcp_require_auth + ? { headers: { Authorization: `Bearer ${token ?? ''}` } } + : {}) + } + } + }, + null, + 2 + ); + + return ( +
+
+ Let coding agents manage your dev servers through DevHarbor over local HTTP. The server + listens on 127.0.0.1 only. Holding the token lets a client register a folder and run its + scripts, so treat it like a password. +
+ + void update({ mcp_enabled: v })} + /> + + {settings.mcp_enabled && ( + <> + +
+ + {status?.running ? ( + {status.url} + ) : status?.lastError ? ( + {status.lastError} + ) : ( + Stopped + )} +
+
+ + + void update({ mcp_port: clamp(Math.round(v), 1024, 65535) })} + step={1} + min={1024} + max={65535} + /> + + + void update({ mcp_https: v })} + /> + + {settings.mcp_https && status?.certPath && ( + +
+ + {status.certPath} + + +
+
+ )} + + void update({ mcp_require_auth: v })} + /> + + {!settings.mcp_require_auth && ( +
+ + + Without a token, any program running on this Mac can control your apps through + DevHarbor. Fine on a personal machine; turn it back on if other people log in + here or you run software you do not trust. + +
+ )} + + {settings.mcp_require_auth && ( + +
+ + + {token && } + +
+
+ )} + + {status?.tokenStoredPlaintext && ( +
+ The macOS keychain was unavailable, so the token is stored unencrypted. File + permissions are its only protection. +
+ )} + +
+
+ Client config + +
+
+              {clientConfig}
+            
+
+ + )} +
+ ); +} diff --git a/src/renderer/components/TaskEditor.tsx b/src/renderer/components/TaskEditor.tsx index 9c73333..9cb1c07 100644 --- a/src/renderer/components/TaskEditor.tsx +++ b/src/renderer/components/TaskEditor.tsx @@ -20,6 +20,18 @@ const TASK_EDITOR_TITLE_ID = 'task-editor-title'; const EMPTY_TASKS: Task[] = []; +// Two edits queued within one debounce window must UNION their patches. A plain shallow +// merge of the wrapper object would let a later { patch: { name } } replace an earlier +// { patch: { readiness } } wholesale, silently dropping the first field. +function mergeTaskSaves( + pending: { id: TaskId; patch: Partial }, + next: { id: TaskId; patch: Partial } +): { id: TaskId; patch: Partial } { + return pending.id === next.id + ? { id: next.id, patch: { ...pending.patch, ...next.patch } } + : next; +} + export function TaskEditor({ appId, appPath, @@ -32,6 +44,23 @@ export function TaskEditor({ const tasks = useStore((s) => s.tasksByApp[appId] ?? EMPTY_TASKS); const upsertTask = useStore((s) => s.upsertTask); const removeTask = useStore((s) => s.removeTask); + const setTaskEditLock = useStore((s) => s.setTaskEditLock); + + // While this drawer is open, background resyncs (state:invalidate after an MCP + // mutation) skip this app's task list - a snapshot taken before the debounced save + // below flushes would revert the inputs mid-typing. Save responses keep it fresh. + // On close, refetch once so anything an MCP client changed meanwhile lands (the + // skipped resyncs will not fire again on their own). + useEffect(() => { + setTaskEditLock(appId); + return () => { + setTaskEditLock(null); + void window.api + .invoke('tasks:list', { appId }) + .then((ts) => useStore.getState().setTasksForApp(appId, ts)) + .catch(() => {}); + }; + }, [appId, setTaskEditLock]); const [editingId, setEditingId] = useState(tasks[0]?.id ?? null); const [error, setError] = useState(null); const [detection, setDetection] = useState(null); @@ -78,7 +107,7 @@ export function TaskEditor({ } catch (e) { setError((e as Error).message); } - }, 300); + }, 300, mergeTaskSaves); // If the user switches the edited task before pending changes flush, save them first. useEffect(() => { @@ -259,7 +288,16 @@ function TaskForm({ onChange: (patch: Partial) => void; onRemove: () => void; }): JSX.Element { - const scriptChoices = useMemo(() => Object.keys(detection?.scripts ?? {}), [detection]); + const scriptChoices = useMemo(() => { + const keys = Object.keys(detection?.scripts ?? {}); + // A workspace task's script lives in the workspace package's own package.json, not + // the root one this detection covers - keep the saved value selectable instead of + // rendering the dropdown blank. + if (task.commandKind === 'script' && task.script && !keys.includes(task.script)) { + keys.unshift(task.script); + } + return keys; + }, [detection, task.commandKind, task.script]); const isOneShot = task.oneShot; const setReadinessKind = (kind: ReadinessSignal['kind']): void => { @@ -274,6 +312,9 @@ function TaskForm({ case 'log': next = { kind: 'log', regex: 'ready' }; break; + case 'http': + next = { kind: 'http', url: 'http://localhost:3000/health' }; + break; case 'exit': next = { kind: 'exit', code: 0 }; break; @@ -393,7 +434,7 @@ function TaskForm({
- {(['none', 'port', 'log', 'exit', 'delay'] as const).map((k) => ( + {(['none', 'port', 'log', 'http', 'exit', 'delay'] as const).map((k) => ( )} + {task.readiness.kind === 'http' && ( + + void onChange({ + readiness: { + kind: 'http', + url: e.target.value, + status: task.readiness.kind === 'http' ? task.readiness.status : undefined + } + }) + } + placeholder="http://localhost:3000/health" + className="w-full rounded-md border border-border bg-surface px-2 py-1 font-mono text-xs text-fg" + /> + )} {task.readiness.kind === 'exit' && ( ( save: (patch: P) => Promise | void, - delay = 300 + delay = 300, + merge?: (pending: P, next: P) => P ): { queue: (patch: P) => void; flush: () => void } { const pendingRef = useRef

({} as P); const timerRef = useRef(null); @@ -32,11 +35,15 @@ export function useDebouncedSave

( const queue = useCallback( (patch: P): void => { - Object.assign(pendingRef.current as object, patch); + if (merge && Object.keys(pendingRef.current as object).length > 0) { + pendingRef.current = merge(pendingRef.current, patch); + } else { + Object.assign(pendingRef.current as object, patch); + } if (timerRef.current != null) window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(flush, delay); }, - [flush, delay] + [flush, delay, merge] ); useEffect(() => { diff --git a/src/renderer/store/store.ts b/src/renderer/store/store.ts index 1e180d2..b81b32f 100644 --- a/src/renderer/store/store.ts +++ b/src/renderer/store/store.ts @@ -61,6 +61,13 @@ interface State { // Pending env-file-change banners, keyed by appId. Most recent first. envFileChanges: Record; + // While the task editor drawer is open for an app, background resyncs + // (`state:invalidate` after an MCP mutation) skip that app's task list so a + // snapshot taken before the editor's debounced save flushes can't revert the + // fields mid-typing. The editor keeps its own list fresh from save responses. + taskEditLockAppId: AppId | null; + setTaskEditLock: (appId: AppId | null) => void; + setApps: (apps: App[]) => void; upsertApp: (app: App) => void; removeApp: (id: AppId) => void; @@ -116,12 +123,36 @@ export const useStore = create((set) => ({ envFileChanges: {}, + taskEditLockAppId: null, + setTaskEditLock: (appId) => set({ taskEditLockAppId: appId }), + setApps: (apps) => - set((s) => ({ - apps, - loaded: true, - selectedAppId: s.selectedAppId ?? apps[0]?.id ?? null - })), + set((s) => { + const alive = new Set(apps.map((a) => a.id)); + const selectionAlive = s.selectedAppId != null && alive.has(s.selectedAppId); + const out: Partial = { + apps, + loaded: true, + selectedAppId: selectionAlive ? s.selectedAppId : apps[0]?.id ?? null + }; + // The selected app can vanish underneath us (an MCP client called remove_app while + // the user was viewing it). Reselect like the renderer's own remove path does, and + // land on the dashboard instead of stranding the user on the add-an-app screen. + if (s.selectedAppId != null && !selectionAlive && s.view === 'app') { + out.view = 'dashboard'; + } + // Drop per-app task state for apps that no longer exist. Only rebuild the maps when + // something actually died, so the common no-change resync keeps stable references. + if (Object.keys(s.tasksByApp).some((id) => !alive.has(id))) { + out.tasksByApp = Object.fromEntries( + Object.entries(s.tasksByApp).filter(([id]) => alive.has(id)) + ); + out.selectedTaskByApp = Object.fromEntries( + Object.entries(s.selectedTaskByApp).filter(([id]) => alive.has(id)) + ); + } + return out; + }), upsertApp: (app) => set((s) => { @@ -162,13 +193,19 @@ export const useStore = create((set) => ({ }, setTasksForApp: (appId, tasks) => - set((s) => ({ - tasksByApp: { ...s.tasksByApp, [appId]: tasks }, - selectedTaskByApp: { - ...s.selectedTaskByApp, - [appId]: s.selectedTaskByApp[appId] ?? tasks[0]?.id ?? null - } - })), + set((s) => { + // Keep the selected tab only if that task still exists - it can vanish underneath + // us (MCP remove_task) and a dangling selection renders an empty task pane. + const cur = s.selectedTaskByApp[appId]; + const curAlive = cur != null && tasks.some((t) => t.id === cur); + return { + tasksByApp: { ...s.tasksByApp, [appId]: tasks }, + selectedTaskByApp: { + ...s.selectedTaskByApp, + [appId]: curAlive ? cur : tasks[0]?.id ?? null + } + }; + }), upsertTask: (task) => set((s) => { @@ -232,14 +269,20 @@ export const useStore = create((set) => ({ }), setRunningTasks: (list) => - set(() => { - const runningTasks: Record = {}; - const taskState: Record = {}; - const taskReady: Record = {}; - const taskCpu: Record = {}; - const taskMemMB: Record = {}; - const taskPorts: Record = {}; - // Hydrate ALL per-task derived state from the IPC snapshot. Critical: the + set((s) => { + // Merge, never replace. This runs mid-session too (`state:invalidate` after an MCP + // mutation), and the snapshot only contains LIVE tasks - main reaps exited ones + // after ~1.5s. A wholesale replace would wipe the event-driven terminal state + // (crashed dot, crash pin, one-shot "completed" accent) or an optimistic 'starting' + // record that raced the snapshot. Entries absent from the snapshot keep their last + // event-driven values; `applyTaskStatus` already handles every live transition. + const runningTasks = { ...s.runningTasks }; + const taskState = { ...s.taskState }; + const taskReady = { ...s.taskReady }; + const taskCpu = { ...s.taskCpu }; + const taskMemMB = { ...s.taskMemMB }; + const taskPorts = { ...s.taskPorts }; + // Hydrate ALL per-task derived state for tasks in the IPC snapshot. Critical: the // `task:ports` event only fires when the port set CHANGES, so on a renderer // refresh (Cmd+R) we'd otherwise have empty `taskPorts` for already-running // tasks until they happen to discover a new port. diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index d506992..164a429 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -173,6 +173,13 @@ export type InvokeChannels = { 'node:resolve': { req: { id: AppId }; res: NodeInstallation }; 'dialog:browse': { req: void; res: string | null }; + + // MCP server (F22): status snapshot + token management for the Settings UI. + 'mcp:status': { req: void; res: McpStatus }; + /** Plaintext token for display/copy. Null when none has been generated yet. */ + 'mcp:token': { req: void; res: string | null }; + /** Rotate the token. The old token stops working on the next request. */ + 'mcp:regenerateToken': { req: void; res: string }; }; export type EventChannels = { @@ -256,6 +263,16 @@ export type EventChannels = { 'menu:checkUpdates': Record; /** Help → Open Logs Folder - renderer invokes logs:openFolder. */ 'menu:openLogs': Record; + + /** MCP server started/stopped/errored or its settings changed. */ + 'mcp:statusChanged': McpStatus; + + /** + * A non-renderer path (the MCP server) mutated app/task/env/lifecycle state, so the + * renderer's cached view is stale. The renderer re-syncs (apps + running + tasks) on this, + * debounced. Renderer-initiated changes update the store directly and do not need it. + */ + 'state:invalidate': Record; }; export interface EnvFileInfo { @@ -292,6 +309,32 @@ export interface SettingsState { run_history_limit: number; /** Abort a task's start if its readiness signal hasn't fired within this many ms. */ readiness_timeout_ms: number; + /** Run the local MCP server so coding agents can manage apps through DevHarbor. */ + mcp_enabled: boolean; + /** MCP server listen port (loopback only). */ + mcp_port: number; + /** Require the bearer token on every MCP request. Off = any local process can connect. */ + mcp_require_auth: boolean; + /** Serve MCP over TLS using a self-signed localhost certificate. */ + mcp_https: boolean; +} + +/** Live state of the MCP server, for the Settings UI. */ +export interface McpStatus { + enabled: boolean; + running: boolean; + port: number; + /** Endpoint URL while running, else null. */ + url: string | null; + requireAuth: boolean; + /** Serving over TLS. */ + https: boolean; + /** Path to the self-signed certificate clients must trust; null while HTTPS is off. */ + certPath: string | null; + /** Why the server is not running despite being enabled (e.g. port in use), else null. */ + lastError: string | null; + /** True when the OS keychain was unavailable and the token had to be stored unencrypted. */ + tokenStoredPlaintext: boolean; } export type InvokeChannelName = keyof InvokeChannels; @@ -360,7 +403,10 @@ const INVOKE_CHANNEL_FLAGS = { 'folders:clear': true, 'node:list': true, 'node:resolve': true, - 'dialog:browse': true + 'dialog:browse': true, + 'mcp:status': true, + 'mcp:token': true, + 'mcp:regenerateToken': true } satisfies Record; export const INVOKE_CHANNELS = Object.keys(INVOKE_CHANNEL_FLAGS) as InvokeChannelName[]; @@ -384,7 +430,9 @@ const EVENT_CHANNEL_FLAGS = { 'menu:addApp': true, 'menu:newFolder': true, 'menu:checkUpdates': true, - 'menu:openLogs': true + 'menu:openLogs': true, + 'mcp:statusChanged': true, + 'state:invalidate': true } satisfies Record; export const EVENT_CHANNELS = Object.keys(EVENT_CHANNEL_FLAGS) as EventChannelName[]; diff --git a/src/shared/types.ts b/src/shared/types.ts index 96bb591..846faae 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -72,6 +72,10 @@ export type ReadinessSignal = | { kind: 'none' } | { kind: 'port'; port: number; host?: string } | { kind: 'log'; regex: string; flags?: string } + /** Ready when an HTTP GET to `url` returns `status` (default any 2xx/3xx). Best fit for a + * service that opens its port before it can actually serve (DB/cache still connecting): + * point it at a /health or /ready endpoint. TLS is not certificate-verified (loopback). */ + | { kind: 'http'; url: string; status?: number } | { kind: 'exit'; code?: number } | { kind: 'delay'; ms: number }; @@ -103,6 +107,10 @@ export interface RunningTask { pid: number; state: ProcessState; ready: boolean; + /** Readiness kind the LIVE watcher was created with at spawn. Distinct from the task's + * stored readiness config, which can be edited (set_readiness) without restarting - so a + * reader must use THIS to decide whether `ready` is a verified answer or a vacuous one. */ + readinessKind: ReadinessSignal['kind']; startedAt: number; command: string; nodeVersion: string;