Skip to content

feat: HermesHub MVP — full web app for self-hosted Hermes AI agent deployment#1

Merged
jellydn merged 43 commits into
mainfrom
ralph/hermes-hub-mvp
May 29, 2026
Merged

feat: HermesHub MVP — full web app for self-hosted Hermes AI agent deployment#1
jellydn merged 43 commits into
mainfrom
ralph/hermes-hub-mvp

Conversation

@jellydn

@jellydn jellydn commented May 28, 2026

Copy link
Copy Markdown
Owner

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):

  • Auth — Better Auth magic-link email login with rate limiting and HTTPS enforcement
  • Database — Drizzle ORM + PostgreSQL schema (servers, installs, provider_credentials, telegram_configs, audit_logs)
  • SSH & VPS connectivity — node-ssh based connection wizard with OS validation and reachability check
  • One-click install — orchestrated Hermes installation with real-time SSE progress streaming
  • Dashboard — status cards (system info, install state, config health) with 30-second polling
  • AI Provider setup — OpenAI/Anthropic/custom credential form with AES-256-GCM encryption at rest
  • Telegram integration — bot token setup and status page
  • Server actions — restart, update, rollback with confirmation dialogs and audit logging
  • Logs viewer — install history + operational action log aggregation
  • Route protection — auth guards, AppShell sidebar layout, responsive navigation

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

  • TanStack Start with file-based routing, SSR-capable server functions, and Hono API layer (server/ routes under /api/*)
  • Better Auth magic-link-only auth — no passwords, no OAuth — with lazy initialization and absolute baseURL for SSR compatibility
  • Drizzle ORM with gen_random_uuid()::text primary keys and migration support
  • SSE streaming via a dedicated server/install/sse-stream.ts module that mirrors install progress from DB and in-memory state for reconnect resiliency
  • AES-256-GCM encryption for stored provider credentials (env-driven ENCRYPTION_KEY)
  • Rate limiting (rate-limiter-flexible) on auth and credential endpoints
  • Feature-component architecture — each domain (dashboard, providers, telegram, servers, logs) lives in src/features/ with co-located tests
  • Shared AppShell for consistent sidebar navigation across all authenticated pages
  • Confirmation pattern via inline ConfirmationCard for destructive actions (restart/update/rollback), not window.confirm
  • Comprehensive test coverage — 11 test files covering API routes, SSE streaming, SSH, crypto, and React components with jsdom/Vitest

Checklist

  • Tests added or updated
  • Documentation updated (README, API reference, CONTEXT.md, CONCERNS.md)
  • No breaking changes (or breaking changes documented above)

jellydn added 28 commits May 26, 2026 09:50
- 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).
@changeset-bot

changeset-bot Bot commented May 28, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f053f57

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@jellydn, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3b2f8f03-8715-4825-a077-2da946eb4f6d

📥 Commits

Reviewing files that changed from the base of the PR and between cc7e6db and f053f57.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (103)
  • .dockerignore
  • .env.example
  • .github/workflows/ci.yml
  • .github/workflows/deploy.yml
  • .planning/codebase/ARCHITECTURE.md
  • .planning/codebase/CONCERNS.md
  • .planning/codebase/CONVENTIONS.md
  • .planning/codebase/INTEGRATIONS.md
  • .planning/codebase/STACK.md
  • .planning/codebase/STRUCTURE.md
  • .planning/codebase/TESTING.md
  • AGENTS.md
  • CONTEXT.md
  • Dockerfile
  • README.md
  • app.json
  • biome.json
  • components.json
  • compose.yaml
  • docs/api-reference.md
  • docs/test-coverage-review.md
  • drizzle.config.ts
  • drizzle/0000_swift_luckman.sql
  • drizzle/meta/0000_snapshot.json
  • drizzle/meta/_journal.json
  • justfile
  • package.json
  • postcss.config.mjs
  • scripts/ralph/prd.json
  • scripts/ralph/progress.txt
  • scripts/start-production.mjs
  • server/app.test.ts
  • server/app.ts
  • server/auth.ts
  • server/credentials.test.ts
  • server/credentials.ts
  • server/crypto.ts
  • server/dashboard.test.ts
  • server/dashboard.ts
  • server/db/health.ts
  • server/db/index.ts
  • server/db/schema.ts
  • server/install-idle-timeout.test.ts
  • server/install.test.ts
  • server/install.ts
  • server/install/sse-stream.test.ts
  • server/install/sse-stream.ts
  • server/lib/get-client-ip.ts
  • server/lib/send-magic-link-email.ts
  • server/logs.test.ts
  • server/logs.ts
  • server/providers.test.ts
  • server/providers.ts
  • server/server-actions.test.ts
  • server/server-actions.ts
  • server/servers.test.ts
  • server/servers.ts
  • server/ssh.test.ts
  • server/ssh.ts
  • server/telegram.test.ts
  • server/telegram.ts
  • src/components/Footer.tsx
  • src/components/Header.tsx
  • src/components/ui/button.tsx
  • src/features/dashboard/status-overview.test.tsx
  • src/features/dashboard/status-overview.tsx
  • src/features/logs/logs-viewer.test.tsx
  • src/features/logs/logs-viewer.tsx
  • src/features/providers/provider-settings.test.tsx
  • src/features/providers/provider-settings.tsx
  • src/features/servers/connection-wizard.test.tsx
  • src/features/servers/connection-wizard.tsx
  • src/features/servers/install-progress.test.tsx
  • src/features/servers/install-progress.tsx
  • src/features/servers/server-detail.test.tsx
  • src/features/servers/server-detail.tsx
  • src/features/telegram/telegram-settings.test.tsx
  • src/features/telegram/telegram-settings.tsx
  • src/lib/ai-providers.ts
  • src/lib/auth-client.ts
  • src/lib/dashboard-status.ts
  • src/lib/logs.ts
  • src/lib/server-detail.ts
  • src/lib/session.test.ts
  • src/lib/session.ts
  • src/lib/use-mount-effect.ts
  • src/lib/utils.ts
  • src/routeTree.gen.ts
  • src/routes/__root.tsx
  • src/routes/about.tsx
  • src/routes/ai-provider.tsx
  • src/routes/dashboard.tsx
  • src/routes/index.tsx
  • src/routes/login.tsx
  • src/routes/logs.tsx
  • src/routes/servers.$id.install.tsx
  • src/routes/servers.$id.tsx
  • src/routes/servers.tsx
  • src/routes/settings.tsx
  • src/routes/telegram.tsx
  • src/server.ts
  • src/styles.css
  • vite.config.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ralph/hermes-hub-mvp

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@socket-security

socket-security Bot commented May 28, 2026

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread server/server-actions.ts
Comment on lines +129 to +134
const versionTarget =
action === "rollback"
? payload.targetVersion?.trim() ||
(await getRollbackTarget(serverId)) ||
"latest"
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

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);
	}

Comment on lines +27 to +66
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;
};
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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]);

Comment thread src/routes/login.tsx
Comment on lines +39 to +44
const callbackURL = search.redirect ?? "/dashboard";

const result = await authClient.signIn.magicLink({
email,
callbackURL,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

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.

Suggested change
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,
});

Comment on lines +46 to +79
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,
};
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
}

Comment thread server/db/index.ts
if (!database) {
const client = postgres(process.env.DATABASE_URL, {
max: parseInt(process.env.DB_POOL_MAX ?? "5", 10),
prepare: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
prepare: false,
max: (() => { const val = parseInt(process.env.DB_POOL_MAX ?? "5", 10); return isNaN(val) ? 5 : val; })(),

jellydn added 14 commits May 29, 2026 15:39
…/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

gitguardian Bot commented May 29, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
33465768 Triggered Generic Password 28a0c06 compose.yaml View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. 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


🦉 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.

@jellydn jellydn marked this pull request as ready for review May 29, 2026 09:05
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.
@jellydn jellydn merged commit fe7a564 into main May 29, 2026
6 of 7 checks passed
jellydn added a commit that referenced this pull request Jun 14, 2026
…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.
jellydn added a commit that referenced this pull request Jun 16, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant