feat: HermesHub MVP — full web app for self-hosted Hermes AI agent deployment#1
Conversation
- STACK.md (90 lines) - Technologies and dependencies - ARCHITECTURE.md (134 lines) - System design and patterns - STRUCTURE.md (176 lines) - Directory layout and organization - CONVENTIONS.md (91 lines) - Code style and patterns - TESTING.md (161 lines) - Test structure and practices - INTEGRATIONS.md (132 lines) - External services and APIs - CONCERNS.md (71 lines) - Technical debt and issues
- Rewrite README status from "Scaffolded" to "MVP Complete" with feature table - Add full architecture directory structure and key design decisions - Document credential security (AES-256-GCM) and planned stack implementation - Add docs/api-reference.md covering all 14 API endpoints with schemas, errors, and tables - Add .env.example documenting required environment variables
…ctions Add integration tests for getDashboardStatusSnapshot covering all edge cases (empty server, SSH errors, warning thresholds, missing credentials). Cover install error paths (missing credential, unsupported auth, SSH failures). Cover server action flows (update, rollback with/without version, unknown action, audit logging of failures). Also add a justfile with convenient aliases for common dev tasks.
Document current test landscape (58 tests, 16 files) with per-module coverage, API endpoint coverage table, and prioritized gap analysis. Highlights security- critical crypto.ts and ai-providers.ts as high-priority untested modules, and flags SSH, install, and server-actions for partial-coverage paths needing attention. Serves as a living reference for test debt triage.
Security & correctness: - Remove BETTER_AUTH_SECRET/URL dev defaults; lazy failure in production - Add getClientIp() helper with TRUSTED_PROXY_COUNT for audit IP tracking - Replace all raw x-forwarded-for reads with getClientIp() across 5 server files - Add session credential TTL eviction (30 min cleanup) Naming & schema: - Rename telegram_configs.chat_id -> bot_username; update all references - Rename ephemeral -> session credentials (store/getSessionCredential) Performance: - DB pool max: 1 -> parseInt(process.env.DB_POOL_MAX ?? '5') - Parallelize independent dashboard queries with Promise.all Reliability: - Add 90s idle timeout + 30s heartbeat to SSE install stream - Remove duplicate auth routes (/verify-magic-link, /callback) - Pin 6 TanStack deps from 'latest' to resolved versions Infrastructure: - Add sendMagicLinkEmail() with Resend support + console.log fallback - Regenerate single clean migration after all schema changes
Capture key HermesHub terms (Quick Win, Lazy Failure, Session/Stored Credentials, etc.) to establish a shared vocabulary across the codebase and documentation.
Add rate-limiter-flexible v11.1.0 for magic link rate limiting (3 req/5min per email).
…oints Rate limiting: magic link endpoint limited to 3 requests per 5 minutes per email using rate-limiter-flexible with its in-memory store. HTTPS enforcement: credential-bearing endpoints (/servers/connect, /servers/:id/install, /servers/:id/actions) reject plain HTTP in production by checking x-forwarded-proto header.
Move SSE plumbing (stream state map, heartbeat, idle timeout, event hydration, DB persistence) from the 647-line server/install.ts monolith into server/install/sse-stream.ts. The install workflow orchestration and step definitions stay in server/install.ts, which now imports the SSE functions from the new module.
During server-side rendering, read BETTER_AUTH_URL from environment variables instead of hardcoding localhost:3000. On the client side, window.location.origin is still used as before. The env var is already enforced in production by the lazy failure pattern.
OS validation: parseAndValidateOs returns supportLevel 'supported' | 'untested' instead of throwing for non-Ubuntu/Debian Linux distros. Only non-Linux or missing /etc/os-release still throws. Dashboard and server-detail UI show amber warning for untested OS. Dashboard caching: two-tier in-memory cache with 60s TTL for static data (server, provider, telegram, install) and 15s TTL for live SSH metrics. clearDashboardCache() exported for test isolation.
CONCERNS.md: move 6 items from 'Design Decisions' to '✅ Resolved (Second-Round Implementation)' section with file references. CONTEXT.md: add glossary entries for all 6 implemented design decisions (rate limiting, HTTPS enforcement, dashboard caching, OS validation, install decomposition, SSR auth URL).
|
|
Warning Review limit reached
More reviews will be available in 56 minutes and 3 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (103)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request implements the core MVP features for HermesHub, establishing a full-stack application with TanStack Start, Hono API routing, Drizzle ORM, and Better Auth magic-link authentication. It introduces VPS SSH connection management, automated Hermes agent installation with real-time SSE progress streaming, dashboard status aggregation with live VPS metrics, AI provider and Telegram bot integrations, and an aggregated logs viewer. The review feedback highlights several critical security and stability improvements: validating the user-controlled targetVersion parameter in server actions to prevent command injection, sanitizing the callbackURL in the login route to mitigate open redirect vulnerabilities, replacing fragile string parsing of install logs with JSON lines, ensuring the server detail page re-fetches data when the server ID changes by replacing useMountEffect with useEffect, and handling NaN fallbacks when parsing DB_POOL_MAX.
| const versionTarget = | ||
| action === "rollback" | ||
| ? payload.targetVersion?.trim() || | ||
| (await getRollbackTarget(serverId)) || | ||
| "latest" | ||
| : null; |
There was a problem hiding this comment.
The targetVersion parameter is retrieved directly from the user-controlled request payload and interpolated into shell commands executed over SSH without any validation. This allows an authenticated attacker to perform remote command execution (RCE) on the target VPS via command injection. Validate versionTarget against a strict whitelist regex (e.g., /^[a-zA-Z0-9_.-]+$/) before using it.
const versionTarget =
action === "rollback"
? payload.targetVersion?.trim() ||
(await getRollbackTarget(serverId)) ||
"latest"
: null;
if (versionTarget && !/^[a-zA-Z0-9_.-]+$/.test(versionTarget)) {
return context.json({ error: "Invalid target version format" }, 400);
}| useMountEffect(() => { | ||
| let isActive = true; | ||
|
|
||
| void (async () => { | ||
| try { | ||
| const response = await fetch(`/api/servers/${id}`); | ||
| const payload = (await response.json().catch(() => null)) as { | ||
| error?: string; | ||
| serverDetail?: ServerDetailSnapshot; | ||
| } | null; | ||
|
|
||
| if (!isActive) { | ||
| return; | ||
| } | ||
|
|
||
| if (!response.ok || !payload?.serverDetail) { | ||
| setError(payload?.error ?? "Unable to load this server."); | ||
| setIsLoading(false); | ||
| return; | ||
| } | ||
|
|
||
| setServerDetail(payload.serverDetail); | ||
| setError(null); | ||
| } catch { | ||
| if (!isActive) { | ||
| return; | ||
| } | ||
|
|
||
| setError("Unable to load this server."); | ||
| } finally { | ||
| if (isActive) { | ||
| setIsLoading(false); | ||
| } | ||
| } | ||
| })(); | ||
|
|
||
| return () => { | ||
| isActive = false; | ||
| }; | ||
| }); |
There was a problem hiding this comment.
The useMountEffect hook is used to fetch server details on mount. However, because it has an empty dependency array ([]), the effect will not re-run when the id parameter changes (e.g., when navigating directly between different server detail pages). This causes the page to display stale data from the previously loaded server. Use a standard useEffect hook with [id] as a dependency to ensure details are re-fetched whenever the server ID changes. Make sure to import useEffect from 'react' as well.
useEffect(() => {
let isActive = true;
setIsLoading(true);
void (async () => {
try {
const response = await fetch(`/api/servers/${id}`);
const payload = (await response.json().catch(() => null)) as {
error?: string;
serverDetail?: ServerDetailSnapshot;
} | null;
if (!isActive) {
return;
}
if (!response.ok || !payload?.serverDetail) {
setError(payload?.error ?? "Unable to load this server.");
setIsLoading(false);
return;
}
setServerDetail(payload.serverDetail);
setError(null);
} catch {
if (!isActive) {
return;
}
setError("Unable to load this server.");
} finally {
if (isActive) {
setIsLoading(false);
}
}
})();
return () => {
isActive = false;
};
}, [id]);
| const callbackURL = search.redirect ?? "/dashboard"; | ||
|
|
||
| const result = await authClient.signIn.magicLink({ | ||
| email, | ||
| callbackURL, | ||
| }); |
There was a problem hiding this comment.
The callbackURL is constructed directly from the user-controlled redirect query parameter without validation. This exposes the application to an open redirect vulnerability, where an attacker can craft a link that redirects users to a malicious external site after successful login. Sanitize the callbackURL to ensure it is a relative path (e.g., starts with / and not //) before passing it to the authentication client.
| const callbackURL = search.redirect ?? "/dashboard"; | |
| const result = await authClient.signIn.magicLink({ | |
| email, | |
| callbackURL, | |
| }); | |
| let callbackURL = search.redirect ?? "/dashboard"; | |
| if (callbackURL.startsWith("http://") || callbackURL.startsWith("https://") || callbackURL.startsWith("//")) { | |
| callbackURL = "/dashboard"; | |
| } | |
| const result = await authClient.signIn.magicLink({ | |
| email, | |
| callbackURL, | |
| }); |
| export function hydrateInstallEvents( | ||
| serverId: string, | ||
| installRecord: { | ||
| id: string; | ||
| status: string; | ||
| step: string; | ||
| log: string | null; | ||
| }, | ||
| ) { | ||
| if (!installRecord.log) { | ||
| return []; | ||
| } | ||
|
|
||
| const lines = installRecord.log.split("\n").filter(Boolean); | ||
| if (lines.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| return lines.map((line, index) => { | ||
| const timestamp = line.slice(0, 24); | ||
| const stepMatch = line.match(/\[(.+?)\]/); | ||
| const messageIndex = line.indexOf("] "); | ||
|
|
||
| return { | ||
| installId: installRecord.id, | ||
| serverId, | ||
| step: stepMatch?.[1] ?? installRecord.step, | ||
| progress: Math.round(((index + 1) / lines.length) * 100), | ||
| message: messageIndex >= 0 ? line.slice(messageIndex + 2) : line, | ||
| status: normalizeInstallStatus(installRecord.status), | ||
| timestamp, | ||
| }; | ||
| }); | ||
| } |
There was a problem hiding this comment.
The hydrateInstallEvents function parses the install log by splitting it with \n and slicing the first 24 characters for the timestamp. However, if any command execution (like Docker pull or container start) outputs multiline text, only the first line will contain the timestamp and step prefix. The subsequent lines will be parsed as separate malformed events with garbage timestamps. Storing the log as JSON lines (one JSON-serialized InstallEvent per line) is a much more robust approach that completely avoids fragile string parsing and handles multiline output gracefully. Note that you should also update emitInstallEvent to push JSON.stringify(event) instead of the formatted string.
export function hydrateInstallEvents(
serverId: string,
installRecord: {
id: string;
status: string;
step: string;
log: string | null;
},
) {
if (!installRecord.log) {
return [];
}
return installRecord.log
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as InstallEvent;
} catch {
return null;
}
})
.filter((event): event is InstallEvent => event !== null);
}| if (!database) { | ||
| const client = postgres(process.env.DATABASE_URL, { | ||
| max: parseInt(process.env.DB_POOL_MAX ?? "5", 10), | ||
| prepare: false, |
There was a problem hiding this comment.
If DB_POOL_MAX is set to an invalid integer value, parseInt will return NaN, which can cause the postgres client to throw an error or behave unexpectedly. Provide a fallback default value if the parsed value is NaN.
| prepare: false, | |
| max: (() => { const val = parseInt(process.env.DB_POOL_MAX ?? "5", 10); return isNaN(val) ? 5 : val; })(), |
…/release Replace the check-then-set pattern with an atomic tryClaimInstallStream that claims the slot before any await, avoiding TOCTOU races. Extract a shared cleanup function in streamServerInstallEvents to fix idle-timer and heartbeat teardown on abort. Heartbeat success resets the idle timer so long-running steps (apt-get, docker pull) don't disconnect.
…n SQL Add isValidDockerTag guard on rollback targetVersion at both the handler entry (early 400) and the command builder (defense-in-depth) to prevent shell injection via malicious image tags. Move serverId filtering from JS post-filter to the SQL WHERE clause so LIMIT correctly scopes to the target server's rows.
Encrypt the Telegram bot token before persisting to the database so stored credentials remain protected even if the DB is compromised. Decrypt on read for token-last-4 display. Gracefully handle decryption failure by masking the token entirely.
X-Forwarded-For appends left-to-right per proxy hop. The rightmost entries are the closest proxies, not the client. Use the entry just left of TRUSTED_PROXY_COUNT instead, matching the standard model where proxies add themselves to the right.
…bubble Resend errors Logging a token-bearing URL in production is a security leak — throw instead so it's a loud 500. Bubble Resend HTTP errors as thrown errors so Better Auth doesn't tell the user to check an inbox for an email that was never actually sent.
…dential endpoints Extract applyMagicLinkRateLimit for reuse on both /auth/send-magic-link and Better Auth's built-in /auth/sign-in/magic-link so rate limiting is consistent regardless of which path the client calls. Wrap provider config, provider test, and Telegram connect endpoints with requireHttps so credential-bearing POST bodies are never sent over plaintext.
Add CI validation plus VPS and Dokku deployment paths so the app can ship through GitHub Actions instead of a manual host setup. This also hardens local build tooling by excluding generated dist output from Biome and keeping server-only SSH dependencies out of Vite's client-side optimizeDeps scan.
A dead or unreachable server should not keep getting polled every 30 seconds forever. Add exponential backoff, pause automatic refreshes after three consecutive failures, and surface an explicit manual retry state so the UI stays informative without hammering the backend.
SSR auth flows need stable absolute URLs and explicit failures when the database is unavailable, otherwise redirects and auth requests can fail in confusing ways. Injectable session loading keeps requireSession easy to test, and the extra route coverage locks in the expected 503 behavior for auth endpoints without DATABASE_URL.
These helpers carry important timeout, cleanup, and fallback behavior that was previously untested even though other features depend on them. Expose the small pure helpers needed for focused tests, make the credential sweeper configurable in tests, and lock in rollback, SSH, SSE, and credential lifecycle behavior without adding broad integration fixtures.
The repo instructions and planning notes had drifted from the current build, deploy, and testing workflow. Update the project guidance, context notes, and concern tracker so the next implementation pass starts from the verified commands, gotchas, and resolved follow-up work.
The starter-template routes showed TanStack branding and placeholder copy. Replace them with product-aware landing, about, and meta tags that describe HermesHub's setup steps, launch pillars, and CTA flow.
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 33465768 | Triggered | Generic Password | 28a0c06 | compose.yaml | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
Restructure the README to match the jellydn open-source convention: badges, emoji features list, demo section, prerequisites, quick start, scripts table, architecture tree, stack table, API reference, author section, and support badge. Aligns with the style used across jellydn GitHub projects.
…onnect Invert the requireHostKeyPin flag to allowMissingHostKeyPin. The secure behavior (fail closed when no fingerprint is stored) is now the default. Only verifyServerConnection (first-connect) opts out by passing allowMissingHostKeyPin: true. This eliminates requireHostKeyPin: true from 15 call sites and removes the field from DeployComposeInput, ManagedComposeDeployInput, and SshConnectionConfig intermediate types. A future caller that forgets the flag is safe by default. Also extracts parseOptionId helper and generic keepLatestBy to eliminate copy-paste. Uses composition for DeployComposeInput (extends SshConnectionInput instead of duplicating its fields). Review fixes #1, #2, #4, #5.
* feat: add Telegram model/provider switch endpoint
Add POST /api/telegram/model-switch endpoint that allows switching the
AI model and/or provider on a deployed Hermes server via SSH.
What:
- New switchModelProvider handler in server/telegram.ts
- Accepts { model?, provider? } in request body
- Validates provider against known ApiProviderId values
- Validates model string format and provider-model compatibility
- Uses SSH to execute hermes config set commands on the remote server
- Writes audit logs for success and failure cases
Why:
- Closes #34
- Users currently need to use the web UI to change model/provider
- This enables programmatic switching (e.g., from Telegram bot commands)
How:
- Reuses existing setProviderModel/setProviderInferenceProvider from runtime.ts
- Reuses PROVIDER_ENV_CONFIGS for provider-to-hermes-provider mapping
- Follows same auth + SSH + audit log pattern as other Telegram endpoints
- 8 new tests covering auth, validation, SSH execution, and error handling
* fix: address thermo-nuclear review findings for model-switch endpoint
Fixes:
- CRITICAL: Silent no-op when provider mapping missing — now validates
hermesProvider exists and returns 400 if not
- CRITICAL: Unsafe provider as ApiProviderId cast — now uses typed
validatedProvider variable throughout
- MAJOR: Extract resolveTelegramSshContext helper to eliminate 30-line
SSH boilerplate duplication
- MAJOR: Trim model before validation (not after) to handle whitespace
* fix: biome formatting in server/telegram.ts
* fix: biome formatting in renovate.json and server/telegram.test.ts
* fix: TS errors in switchModelProvider — SshAuthMethod type and status code cast
* fix: biome formatting — import order and multi-line context.json call
* fix: add switchModelProvider to telegram mock in app.test.ts
* chore: trigger CI
* fix: address PR review feedback for switchModelProvider
* feat(telegram): rewrite compose and restart container on model/provider switch
- Extract getModelValidationError helper for cleaner validation
- Validate model against subscription backends in addition to API providers
- Rewrite managed compose file and restart hermes container after SSH deploy
so new provider env vars take effect without manual intervention
- Use cached pre-switch backend for DB persistence instead of re-fetching,
ensuring DB is never updated if remote configuration fails
- Split subscription model persistence into credential vs oauth cases
* feat(telegram): add model-access-options API and rewrite model-switch to use optionId
- Add GET /api/telegram/model-access-options that returns the latest saved
deployable option per backend (API providers, credential subscriptions,
OAuth subscriptions) with opaque optionId: api-provider:<uuid>,
credential-subscription:<uuid>, oauth-subscription:<uuid>
- Rewrite POST /api/telegram/model-switch to accept { optionId, model }
payload, resolve the saved row, validate model against the backend, deploy
via SSH compose, and activate the selected row while deactivating others
in a DB transaction
- Add shared contracts for ModelAccessOption, ModelAccessOptionsResponse,
and ModelSwitchPayload
- Add query helpers for listing and resolving saved deployable options
- Update backend tests for the new option-based switch API
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
* feat(telegram): add model-access switcher to Telegram page UI
- Add TelegramModelAccessSection component that fetches model access options,
shows active provider/model, lets users pick a saved backend and model
(fixed dropdown or custom input), and switches via the API
- Wire section into TelegramSettings, shown only when Telegram is deployed
- Empty state links users to /ai-provider to save a config first
- Update frontend tests to provide model-access-options fetch mock
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
* refactor(telegram): fix model-access queries, type safety, and extract model-switch service
- Fix ResolvedOption.provider type lie: change from ApiProviderId to string,
remove unsafe as unknown as ApiProviderId casts
- Replace N+1 per-type DB queries with batch inArray queries in
getModelAccessOptions (4+ queries → 3 total)
- Remove isDeployable pre-filter; fold decrypt into builders via
decryptAndGetLast4 helper to avoid double decryption
- Change findActiveOptionIds to return typed { providerIds, subscriptionIds }
so deactivation uses single UPDATE WHERE id IN (...) per table
- Extract switchModelProvider orchestration (SSH + compose + DB transaction)
into server/telegram/model-switch.ts; handler is now a thin wrapper
- Refactor testTelegramBot to reuse resolveTelegramSshContext helper
- Replace dynamic import("../providers/config") with static import
- Remove unused ModelSwitchPayload export from shared contract
* refactor(telegram): replace useState with useReducer and fix react-doctor warnings
Convert TelegramModelAccessSection from 6 useState calls to a single
useReducer, which batches related state updates into one React render
instead of 6. The reducer groups form state (options, selection) and
UI state (loading, switching, messages) into typed actions.
Also switches from useEffect to useMountEffect for the initial data
fetch, matching the established pattern in telegram-pairing-section
and other components. This eliminates the react-doctor/no-event-handler
false positive — isDeployed is set by the parent based on external
server state, not a user event.
React Doctor score: 79 → 100/100.
* test(telegram): update switchModelProvider tests for extracted service
- Add executeModelSwitch mock for ./telegram/model-switch module
- Update activeOptionIds format from flat array to { providerIds, subscriptionIds }
- Update success assertions to verify executeModelSwitchMock args instead
of checking SSH runtime calls directly (those are now tested in the
service layer)
- Update SSH failure test to mock executeModelSwitch rejection
* refactor(dashboard): extract polling logic into useDashboardPolling hook
DashboardStatusOverview was 313 lines — just over the react-doctor
no-giant-component threshold of 300. The component already had good
subcomponent extraction (ServerInventoryCard, StatusCard, VpsHealthCard,
etc.), but ~125 lines of polling state machine logic (7 refs, 4 useState,
5 functions, 1 useMountEffect) was mixed into the component body.
Extract all polling machinery into a useDashboardPolling custom hook
that returns { snapshot, fetchState, fetchError, pollingPaused,
refreshStatus, handleManualRetry }. The main component drops to ~190
lines and focuses purely on rendering.
No behavior change — the polling logic is identical, just relocated.
React Doctor: no-giant-component warning resolved.
* refactor(providers): replace boolean props with explicit status enum in AccessSelectionActions
AccessSelectionActions took isSaving and isTesting as separate booleans,
creating an implicit 4-state system (idle, saving, testing, both) when
only 3 are valid. The \"both\" state is impossible in practice but the
type system allowed it.
Replace with status: \"idle\" | \"saving\" | \"testing\" — a single prop
that makes the valid states explicit and eliminates the dead 4th state
at the type level. Call sites derive the status from the existing
booleans with a ternary.
React Doctor: prefer-explicit-variants warning resolved.
Also: added react-doctor GitHub Actions CI workflow (npx react-doctor install).
* test(crypto): add unit tests for AES-256-GCM secret helpers
Pins round-trip, tamper rejection, malformed payload, missing key, and
all three decryptApiServerKey branches (empty, legacy plaintext,
encrypted round-trip, malformed-with-dot). Uses real node:crypto — no
mocking — since testing the actual implementation is the point.
Plan 001 from audit at 29c3b46.
* fix(agent-skills): fail skill install when remote download fails
The curl | sudo tee pipeline masked curl non-zero exit code because
in a shell pipeline only the last command exit status propagates. A
failed download (404, DNS, network drop) would write an empty SKILL.md
and report success.
Replace with download-to-temp + test -s + mv: curl writes to a .download
temp file, test -s verifies it is non-empty, then mv atomically moves it
to the final path. Any failure in the && chain aborts the install.
Plan 002 from audit at 29c3b46.
* fix(ssh): fail closed when host-key pin missing on credential ops
Add requireHostKeyPin flag to SshConnectionInput. When set, the
hostVerifier throws host_key_missing if no expectedFingerprint is
stored, instead of silently accepting any key (fail-open).
Every post-registration withSshConnection caller now passes
requireHostKeyPin: true. The first-connect flow (verifyServerConnection)
is unchanged — it still tolerates a missing fingerprint so the pin can
be stored on initial connection.
Covers 15 call sites across 13 files. Plan 003 from audit at 29c3b46.
* test(request-guards): cover auth and ownership boundary
Pins status codes for requireAuthSession (401), requireOwnedServer
(400 missing id, 401 unauthenticated, 404 not owned), and
requireOwnedServerSsh (400 SSH failure, 404 not owned). Asserts that
getOwnedServerRecord is called with the session user.id — the IDOR
guard. Safety net for the later guard-consolidation refactor (DEBT-01).
Plan 004 from audit at 29c3b46.
* test(server-records): cover credential resolution branches
Pins resolveServerCredential branches: stored-present, stored-missing,
session-missing-id, session-expired, session-valid. Covers
normalizeAuthMethod (password, ssh-key, unsupported) and both
resolveServerSshConfigOrError branches (ok + error). Mocks crypto and
credentials — no DB or real crypto needed.
Plan 005 from audit at 29c3b46.
* chore: mark all audit plans as DONE
* docs: add audit plan files
* refactor(ssh): default to requiring host-key pin, opt out for first-connect
Invert the requireHostKeyPin flag to allowMissingHostKeyPin. The secure
behavior (fail closed when no fingerprint is stored) is now the default.
Only verifyServerConnection (first-connect) opts out by passing
allowMissingHostKeyPin: true.
This eliminates requireHostKeyPin: true from 15 call sites and removes
the field from DeployComposeInput, ManagedComposeDeployInput, and
SshConnectionConfig intermediate types. A future caller that forgets
the flag is safe by default.
Also extracts parseOptionId helper and generic keepLatestBy to eliminate
copy-paste. Uses composition for DeployComposeInput (extends
SshConnectionInput instead of duplicating its fields).
Review fixes #1, #2, #4, #5.
* chore(plans): remove completed audit plan documents
All five audit plans (001-005) were fully implemented and verified:
- 001: crypto helper tests in server/crypto.test.ts
- 002: pipefail fix for skill install downloads
- 003: host-key pinning fail-closed on credential SSH ops
- 004: request guard tests in server/request-guards.test.ts
- 005: credential resolution tests in server/server-records.test.ts
These planning artifacts served their purpose and are now cleaned up.
* feat(server): handle SSH host-key errors on provider deploy endpoints
Catch SshConnectError with host_key_missing and host_key_mismatch
codes during deploy flow and return a structured 409 response with
observed fingerprint, algorithm, and (on mismatch) expected fingerprint.
Update test mocks to use importOriginal so SshConnectError is available
for new test assertions.
* feat(server): handle SSH host-key errors on Telegram model switch
Catch SshConnectError with host_key_missing and host_key_mismatch
during switchModelProvider and return a structured 409 response.
Add test coverage for both host-key error codes and the generic
SSH error fallback (502).
* feat(providers): add host-key trust UI for provider deploy
Surface host-key errors from the deploy endpoint as a warning panel
showing observed and expected fingerprints. Add "Trust host key and
retry" flow that POSTs to /api/servers/:id/host-key/accept then
re-attempts the deploy. Wire the new state, reducer actions, and
controller method into ProviderSettings.
* feat(server): extract shared host-key error response utility
Deduplicate the repetitive SSH host-key error handling that was inlined
across deploy endpoints. The new isRecoverableHostKeyError type guard and
hostKeyErrorResponse builder produce a consistent 409 JSON payload with
structured hostKey (observed fingerprint/algorithm, expected on mismatch).
* feat(client): add shared host-key recovery types and trust panel UI
Extract HostKeyErrorPayload type and parseHostKeyErrorPayload parser to a
shared module under features/servers. The HostKeyTrustPanel component
renders the host-key trust prompt (fingerprint display, trust/retry and
cancel buttons) so it can be reused across provider deploy and Telegram
model-switch views.
* refactor(server): deduplicate host-key error handling in deploy endpoints
Replace the duplicated host_key_missing / host_key_mismatch inline blocks
in deployProviderToHermes, deployToHermesAgent, and switchModelProvider
with the shared isRecoverableHostKeyError / hostKeyErrorResponse utilities.
On the client side, swap the inline host-key error parsing in
provider-access-actions for parseHostKeyErrorPayload and replace the
inline host-key alert UI in provider-settings-aside with the reusable
HostKeyTrustPanel component.
* refactor(telegram): extract ModelAccessForm sub-component
Pull the inline option selector, model picker, and switch/refresh buttons
into a self-contained ModelAccessForm component to reduce clutter in the
TelegramModelAccessSection render method.
* refactor(contracts,providers): extract shared host-key types and acceptHostKey action
Create shared/contracts/host-key-error.ts with canonical HostKeyErrorCode and HostKeyErrorResponsePayload types, replacing duplicate definitions on both sides of the boundary.\n\nAdd acceptHostKey() to provider-access-actions.ts as a reusable action for trusting server host keys, using the existing readJsonBody helper.\n\nRestore try/catch in handleTrustAndRetryDeploy to prevent unhandled rejections on network errors.\n\nUpdate server/lib/host-key-error-response.ts, src/features/servers/host-key-recovery.ts, and src/features/providers/provider-access-actions.ts to import from the shared contract.
* refactor(server): extract resolveSwitchOption branch resolvers
Split the large multi-branch resolveSwitchOption function in server/telegram/model-access.ts into three self-contained resolver functions (resolveApiProviderOption, resolveCredentialSubscriptionOption, resolveOAuthSubscriptionOption) with a switch-based dispatcher. Each resolver handles its own DB query, validation, and ResolvedOption construction. No behavioral changes.
* feat(telegram): add host-key trust-and-retry to model switch
Extract state machine and data fetching from telegram-model-access-section.tsx into use-model-access-controller.ts with host-key recovery support.\n\nThe hook now parses 409 host-key error responses via parseHostKeyErrorPayload and exposes a HostKeyTrustPanel flow (handleTrustAndRetrySwitch) instead of showing a generic error. This brings the Telegram model switch UX to parity with the AI provider deploy flow for host-key recovery.\n\nThe section component delegates to the hook and renders HostKeyTrustPanel when a host-key error is detected during model switching.
* chore(pre-commit): move typecheck hook to push stage
Move TypeScript typecheck from commit stage to push stage so partial staging is not blocked by type errors in unstaged files. Full typecheck still runs on every git push.
* fix(server): add host key error recovery to deploy and test endpoints
Add isRecoverableHostKeyError checks before generic 502 fallbacks in three server paths that were missing host key recovery:\n\n- deployTelegramToServer: initial Telegram bot deploy to a server\n- testTelegramBot: test-bot flow over SSH\n- deployToTelegramLinkedHermes: skills/agent deploy to Telegram-linked server\n\nWhen a server has hostKeyFingerprint NULL in the DB, SSH connections throw 'host key pin required but not stored'. These paths previously returned a generic 502; they now return a 409 with a hostKeyErrorResponse payload so the client can surface the trust-and-retry panel.\n\nPattern matches the existing handlers: switchModelProvider, deployToHermesAgent, deployProviderToHermes.
* feat(telegram): add host key trust panel to deploy and test sections
Add HostKeyTrustPanel with trust-and-retry flow to the Telegram deploy section (initial bot deploy) and test section (test-bot flow).\n\nBoth sections now parse 409 responses via parseHostKeyErrorPayload and surface a HostKeyTrustPanel when a host key error is detected. On trust, the host key is accepted via POST /api/servers/:id/host-key/accept and the operation retries automatically.\n\nState management consolidated into useReducer (with useRef for stale-closure avoidance) to match the pattern in use-model-access-controller.ts.\n\nBrings the Telegram deploy and test UIs to parity with the model switch and provider deploy flows for host key recovery.
* test(telegram): add host key error recovery integration tests
Add integration tests for host key recovery in deployTelegramToServer and testTelegramBot endpoints.\n\nVerifies that both endpoints return 409 with hostKey recovery payload when SSH fails with host_key_missing, instead of the previous generic 502. Confirms no audit log is written for recoverable errors and no deploy state is persisted.
* docs(api): document host key recovery flow and new endpoints
Add Host Key Recovery section to the API reference explaining the 409 response format, error codes (host_key_missing/host_key_mismatch), recovery flow, and list of affected endpoints.\n\nDocument new POST /api/servers/:id/host-key/accept endpoint.\n\nAdd previously undocumented Telegram endpoints: POST /api/telegram/deploy, POST /api/telegram/test, POST /api/telegram/model-switch.\n\nUpdate error response tables on all 8 deploy endpoints (telegram deploy/test/model-switch, providers deploy, persona/mcp/agent-skills deploy, web-ui deploy) to include 409 for host key errors.
* docs(glossary): add host key pin and recovery terminology
Add two glossary entries to CONTEXT.md:\n\n- Host Key Pin: explains the stored SHA256 fingerprint, its role in SSH verification, and the host_key_missing/host_key_mismatch error contract\n- Host Key Recovery: explains the 409 -> trust panel -> accept -> retry flow and lists all 8 endpoints that support it
---------
Co-authored-by: Hermes Agent <agent@nousresearch.com>
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
What
Implements the complete HermesHub MVP: a web application that lets non-technical users deploy and manage a self-hosted Hermes AI Agent on a VPS through a browser — zero terminal required.
Surface area (28 commits, 90 files, ~14 000 lines added):
Why
Deploying an AI agent on a VPS typically requires SSH access, terminal proficiency, and manual Docker orchestration. HermesHub removes that barrier by providing a full browser-based control plane so anyone can get Hermes running in minutes.
How
server/routes under/api/*)baseURLfor SSR compatibilitygen_random_uuid()::textprimary keys and migration supportserver/install/sse-stream.tsmodule that mirrors install progress from DB and in-memory state for reconnect resiliencyENCRYPTION_KEY)rate-limiter-flexible) on auth and credential endpointssrc/features/with co-located testsAppShellfor consistent sidebar navigation across all authenticated pagesConfirmationCardfor destructive actions (restart/update/rollback), notwindow.confirmChecklist