diff --git a/.gitignore b/.gitignore index 4fcea76f57..4dbe81b0c8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ data/ # Bundled community plugin index seed (shipped as package data) — the bare # `data/` pattern above would otherwise swallow it. !hermes_cli/data/ +# The Connectors page's wire layer (apps/desktop/src/app/capabilities/connectors/data/) +# — same reason: the bare `data/` pattern above would otherwise swallow it. +!apps/desktop/src/app/capabilities/connectors/data/ .pytest_cache/ test_durations.json .pytest-cache/ diff --git a/AGENTS.md b/AGENTS.md index b338eaf635..2cf8a41ee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,6 +291,16 @@ families: `hermes_state.py` (21), `gateway/run.py` (15), `tools/mcp_tool.py` (15 spawns (`served_profile_child_env`, never `os.environ.copy()`). Fail-closed reads exist only after `set_multiplex_active(True)`. Prove live with two homes (A→B→A) under multiplex, not one temp `HERMES_HOME`. Advisory lint: `scripts/check_profile_scope_patterns.py`. +- **Machine facts and resource lookup go through `hermes_platform`.** `hermes_platform.host` is the + one answer for OS family, native architecture (`IsWow64Process2` → `platform.machine()`; never + `PROCESSOR_ARCHITECTURE` alone, it reads AMD64 under x64-on-ARM64 emulation), CPU identity, and + WSL/container/Termux. Facts are cached per process and take **no environment-variable input**, so + a hardware recognizer (`host/products.py`) cannot be set from a shell. Distinguish the control + host (where this Python runs) from the terminal execution target (SSH/container) and the Desktop + client (another machine): `host.*` answers only the first. A new bare `shutil.which` or a + hand-written known-path table outside `hermes_platform/` fails + `tests/test_managed_runtime_resolution.py` unless allowlisted with a reason; resolvers land in + `hermes_platform/resolver/`. Lookup never installs, downloads, or starts anything. - **Argparse alias dispatch:** `add_parser("list", aliases=["ls"])` sets `dest` to the literal the user typed (`"ls"`). Dispatch must accept both (caught PTY-testing `hermes webhook ls`). - **Don't wire in dead code without E2E validation.** Unshipped code was dead for a reason; diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index c6dade80d3..a98c3c6ec1 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -24,7 +24,7 @@ from agent.runtime_cwd import resolve_agent_cwd from agent.skill_utils import ( EXCLUDED_SKILL_DIRS, ORG_ACTIVE_MARKER, ORG_MIRROR_DIR_NAME, ORG_PROVENANCE_FILE, SKILL_SUPPORT_DIRS, extract_skill_conditions, extract_skill_description, get_all_skills_dirs, get_disabled_skill_names, - iter_skill_index_files, parse_frontmatter, read_active_org_id, skill_matches_environment, + iter_skill_index_files, parse_frontmatter, read_active_org_id, skill_matches_apps, skill_matches_environment, skill_matches_platform, skill_matches_platform_list, ) from tools.threat_patterns import scan_for_threats as _scan_for_threats @@ -1118,7 +1118,7 @@ _SKILLS_PROMPT_CACHE_MAX = 32 _SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict() _SKILLS_PROMPT_CACHE_LOCK = threading.Lock() # v2 added org provenance fields (org_id/org_author); older snapshots are rebuilt. -_SKILLS_SNAPSHOT_VERSION = 2 +_SKILLS_SNAPSHOT_VERSION = 3 def _skills_prompt_snapshot_path() -> Path: @@ -1179,6 +1179,12 @@ def _load_skills_snapshot(skills_dir: Path) -> Optional[dict]: return None +def _requires_apps_list(frontmatter: dict) -> list[str]: + raw = frontmatter.get("requires_apps") + items = raw if isinstance(raw, list) else [raw] if raw else [] + return [str(a).strip() for a in items if str(a).strip()] + + def _build_snapshot_entry(skill_file: Path, skills_dir: Path, frontmatter: dict, description: str) -> dict: """Serialisable metadata dict for one skill.""" parts = skill_file.relative_to(skills_dir).parts @@ -1194,6 +1200,7 @@ def _build_snapshot_entry(skill_file: Path, skills_dir: Path, frontmatter: dict, "skill_name": skill_name, "category": category, "frontmatter_name": str(frontmatter.get("name", skill_name)), "description": description, "platforms": [str(p).strip() for p in platforms if str(p).strip()], "conditions": extract_skill_conditions(frontmatter), + "requires_apps": _requires_apps_list(frontmatter), } if org_id: entry["org_id"] = org_id @@ -1219,8 +1226,8 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]: raw = skill_file.read_text(encoding="utf-8-sig") frontmatter, _ = parse_frontmatter(raw) # Host-platform / runtime-environment gates are offer-time only; explicit loads bypass them. - if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter): - return False, frontmatter, "" + if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter) or not skill_matches_apps(frontmatter): + return False, frontmatter, extract_skill_description(frontmatter) return True, frontmatter, extract_skill_description(frontmatter) except Exception as e: logger.warning("Failed to parse skill file %s: %s", skill_file, e) @@ -1425,9 +1432,13 @@ def _build_skills_system_prompt_inner( _platform_hint, tuple(sorted(disabled)), tuple(sorted(compact_categories or ())), _oneshot_prompt_variant(), ) + snapshot = _load_skills_snapshot(skills_dir) + app_gated = snapshot is not None and any( + entry.get("requires_apps") for entry in snapshot.get("skills", []) if isinstance(entry, dict) + ) with _SKILLS_PROMPT_CACHE_LOCK: cached = _SKILLS_PROMPT_CACHE.get(cache_key) - if cached is not None: + if cached is not None and not app_gated: _SKILLS_PROMPT_CACHE.move_to_end(cache_key) return cached @@ -1439,9 +1450,10 @@ def _build_skills_system_prompt_inner( skills_by_category: dict[str, list[tuple[str, str]]] = {} category_descriptions: dict[str, str] = {} # Disk snapshot (fast path) vs. full scan: both yield (entry, is_compatible) pairs so labeling runs identically. - snapshot = _load_skills_snapshot(skills_dir) if snapshot is not None: - candidates = [(entry, skill_matches_platform_list(entry.get("platforms") or [])) + # Platforms and app presence are host facts that change without SKILL.md changing: re-evaluate both. + candidates = [(entry, skill_matches_platform_list(entry.get("platforms") or []) + and skill_matches_apps({"requires_apps": entry.get("requires_apps") or []})) for entry in snapshot.get("skills", []) if isinstance(entry, dict)] category_descriptions = {str(k): str(v) for k, v in (snapshot.get("category_descriptions") or {}).items()} else: diff --git a/agent/skill_commands.py b/agent/skill_commands.py index f41771f2a3..a20cab4dcb 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -355,12 +355,12 @@ def skill_command_collision_note(name: str) -> Optional[str]: def _scan_skill_md(skill_md: Path, disabled: set, seen_names: set, commands: Dict[str, Dict[str, Any]]) -> None: """Register one SKILL.md in *commands* (no-op when filtered or colliding).""" - from tools.skills_tool import _parse_frontmatter, skill_matches_platform, skill_matches_environment + from tools.skills_tool import _parse_frontmatter, skill_matches_apps, skill_matches_platform, skill_matches_environment if any(part in _SCAN_SKIP_PARTS for part in skill_md.parts): return frontmatter, body = _parse_frontmatter(skill_md.read_text(encoding='utf-8-sig')) # OS gate is hard; environment gate (kanban/docker/s6) is offer-time only. - if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter): + if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter) or not skill_matches_apps(frontmatter): return name = frontmatter.get('name', skill_md.parent.name) if name in seen_names or name in disabled: diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 8f7d2406c3..e832cc469e 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -206,6 +206,28 @@ def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool: return any(_detect_environment(tag) for tag in tags if tag) +def skill_matches_apps(frontmatter: Dict[str, Any]) -> bool: + """True when every app named in ``requires_apps:`` has a registered declaration this host satisfies. + + Names resolve through ``hermes_platform.declaration`` (registered by whoever owns the server, + e.g. the plugin loader); the check is the same ``availability()`` the MCP check_fn uses. An + unknown name hides the skill (fail closed). Offer-time filter, like ``environments:``. + """ + names = frontmatter.get("requires_apps") + if not names: + return True + from hermes_platform import declaration + from hermes_platform.resolver.availability import availability + + for name in names if isinstance(names, list) else [names]: + decl = declaration.lookup(str(name).strip()) + if decl is None or decl.app is None: + return False + if not availability(decl).offerable: + return False + return True + + _RAW_CONFIG_CACHE: Dict[Tuple[str, int, int, int, int], Dict[str, Any]] = {} diff --git a/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx b/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx new file mode 100644 index 0000000000..a6e350f2b1 --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import type { ProfileScope } from '@/hermes' +import { useI18n } from '@/i18n' +import { notifyError } from '@/store/notifications' + +import { McpJsonEditor } from '../mcp/mcp-editor' +import type { McpServersController } from '../mcp/use-mcp-servers' + +import { type AddServerDraft, EMPTY_ADD_DRAFT, entryOfDraft, isDraftComplete } from './add-server-draft' +import { AddServerForm } from './add-server-form' +import { setMcpBearerToken } from './data/rpc' + +export interface AddServerDialogProps { + controller: McpServersController + onOpenChange: (open: boolean) => void + open: boolean + profile: ProfileScope +} + +export function AddServerDialog({ controller, onOpenChange, open, profile }: AddServerDialogProps) { + const { t } = useI18n() + const copy = t.connectorsPage.add + const [draft, setDraft] = useState(EMPTY_ADD_DRAFT) + const [raw, setRaw] = useState(false) + const [saving, setSaving] = useState(false) + + const name = draft.name.trim() + const nameTaken = name !== '' && name in controller.servers + + const discardRawDraft = () => { + if (controller.dirty) { + controller.resetDraft(controller.servers) + } + } + + const close = () => { + discardRawDraft() + setDraft(EMPTY_ADD_DRAFT) + setRaw(false) + onOpenChange(false) + } + + const openRaw = () => { + controller.addServer() + setRaw(true) + } + + const leaveRaw = () => { + discardRawDraft() + setRaw(false) + } + + const save = async () => { + setSaving(true) + + try { + if (!(await controller.addServerEntry(name, entryOfDraft(draft)))) { + return + } + + if (draft.transport === 'http' && draft.auth === 'bearer' && draft.bearer.trim() !== '') { + await setMcpBearerToken(profile, name, draft.bearer.trim()) + controller.refetchConfig() + } + + close() + } catch (err) { + notifyError(err, copy.saveFailed) + } finally { + setSaving(false) + } + } + + return ( + (next ? onOpenChange(true) : close())} open={open}> + +
+ {copy.title} +
+ {copy.hint} + + {raw ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+ {raw ? ( + + ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts b/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts new file mode 100644 index 0000000000..e2d069036e --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts @@ -0,0 +1,151 @@ +import { type McpServerEntry, normalizeEntry } from '@/lib/mcp-servers' + +export type AddServerAuth = 'bearer' | 'none' | 'oauth' + +export type AddServerTransport = 'http' | 'stdio' + +export interface DraftValue { + id: number + value: string +} + +export interface DraftPair { + id: number + key: string + value: string +} + +export interface AddServerDraft { + args: DraftValue[] + auth: AddServerAuth + bearer: string + command: string + cwd: string + env: DraftPair[] + headers: DraftPair[] + name: string + passthrough: DraftValue[] + transport: AddServerTransport + url: string +} + +let rowCounter = 0 + +export const nextRowId = (): number => ++rowCounter + +export const emptyValue = (value = ''): DraftValue => ({ id: nextRowId(), value }) + +export const emptyPair = (key = '', value = ''): DraftPair => ({ id: nextRowId(), key, value }) + +export const EMPTY_ADD_DRAFT: AddServerDraft = { + args: [], + auth: 'none', + bearer: '', + command: '', + cwd: '', + env: [], + headers: [], + name: '', + passthrough: [], + transport: 'stdio', + url: '' +} + +export const isDraftComplete = (draft: AddServerDraft): boolean => + draft.name.trim() !== '' && (draft.transport === 'stdio' ? draft.command.trim() !== '' : draft.url.trim() !== '') + +const filledPairs = (rows: readonly DraftPair[]): Record => + Object.fromEntries(rows.filter(row => row.key.trim() !== '').map(row => [row.key.trim(), row.value])) + +const filledValues = (rows: readonly DraftValue[]): string[] => + rows.map(row => row.value.trim()).filter(value => value !== '') + +const envReference = (name: string): string => `\${${name}}` + +function httpEntry(draft: AddServerDraft): McpServerEntry { + const headers = filledPairs(draft.headers) + const entry: McpServerEntry = { url: draft.url.trim() } + + if (Object.keys(headers).length > 0) { + entry.headers = headers + } + + if (draft.auth === 'oauth') { + entry.auth = 'oauth' + } + + return entry +} + +function stdioEntry(draft: AddServerDraft): McpServerEntry { + const env = { ...filledPairs(draft.env) } + + for (const name of filledValues(draft.passthrough)) { + env[name] = envReference(name) + } + + const args = filledValues(draft.args) + const entry: McpServerEntry = { command: draft.command.trim() } + + if (args.length > 0) { + entry.args = args + } + + if (Object.keys(env).length > 0) { + entry.env = env + } + + if (draft.cwd.trim() !== '') { + entry.cwd = draft.cwd.trim() + } + + return entry +} + +export const entryOfDraft = (draft: AddServerDraft): McpServerEntry => + draft.transport === 'http' ? httpEntry(draft) : stdioEntry(draft) + +type EntryValue = McpServerEntry[string] + +const isString = (value: EntryValue): value is string => Object.prototype.toString.call(value) === '[object String]' + +const asString = (value: EntryValue): string => (isString(value) ? value : '') + +const asRecord = (value: EntryValue): McpServerEntry => + value instanceof Object && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {} + +const asValues = (value: EntryValue): DraftValue[] => + Array.isArray(value) ? value.map((entry: EntryValue) => emptyValue(asString(entry))) : [] + +const forwarded = (key: string, value: EntryValue): boolean => value === envReference(key) + +export function draftFromEntry(name: string, raw: McpServerEntry, previous: AddServerDraft): AddServerDraft { + const entry = normalizeEntry(raw) + const url = asString(entry.url) + const named = name || previous.name + + if (url !== '') { + return { + ...EMPTY_ADD_DRAFT, + auth: asString(entry.auth) === 'oauth' ? 'oauth' : 'none', + bearer: previous.bearer, + headers: Object.entries(asRecord(entry.headers)).map(([key, value]) => emptyPair(key, asString(value))), + name: named, + transport: 'http', + url + } + } + + const env = Object.entries(asRecord(entry.env)) + + return { + ...EMPTY_ADD_DRAFT, + args: asValues(entry.args), + command: asString(entry.command), + cwd: asString(entry.cwd), + env: env.filter(([key, value]) => !forwarded(key, value)).map(([key, value]) => emptyPair(key, asString(value))), + name: named, + passthrough: env.filter(([key, value]) => forwarded(key, value)).map(([key]) => emptyValue(key)), + transport: 'stdio' + } +} diff --git a/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx b/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx new file mode 100644 index 0000000000..e10630e7d3 --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx @@ -0,0 +1,326 @@ +import { type ReactNode, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { SegmentedControl } from '@/components/ui/segmented-control' +import { Textarea } from '@/components/ui/textarea' +import { useI18n } from '@/i18n' +import type { Translations } from '@/i18n/types' +import { parseMcpImport } from '@/lib/mcp-import' + +import { + type AddServerAuth, + type AddServerDraft, + type AddServerTransport, + draftFromEntry, + type DraftPair, + type DraftValue, + emptyPair, + emptyValue +} from './add-server-draft' + +type AddCopy = Translations['connectorsPage']['add'] + +type SetDraft = (patch: Partial) => void + +export interface AddServerFormProps { + draft: AddServerDraft + nameTaken: boolean + onChange: (next: AddServerDraft) => void +} + +export function AddServerForm({ draft, nameTaken, onChange }: AddServerFormProps) { + const { t } = useI18n() + const copy = t.connectorsPage.add + const set: SetDraft = patch => onChange({ ...draft, ...patch }) + + return ( +
+ + + + set({ name: event.currentTarget.value })} size="sm" value={draft.name} /> + {nameTaken ?

{copy.nameTaken}

: null} +
+ +
+ set({ transport: next })} + options={[ + { id: 'stdio', label: copy.typeStdio }, + { id: 'http', label: copy.typeHttp } + ]} + value={draft.transport} + /> +
+ + {draft.transport === 'stdio' ? ( + + ) : ( + + )} +
+ ) +} + +function StdioFields({ copy, draft, set }: { copy: AddCopy; draft: AddServerDraft; set: SetDraft }) { + return ( + <> + + set({ command: event.currentTarget.value })} size="sm" value={draft.command} /> + + + set({ args })} + removeLabel={copy.removeRow} + rows={draft.args} + /> + + set({ env })} + rows={draft.env} + /> + + set({ passthrough })} + placeholder={copy.keyPlaceholder} + removeLabel={copy.removeRow} + rows={draft.passthrough} + /> + + + set({ cwd: event.currentTarget.value })} size="sm" value={draft.cwd} /> + + + ) +} + +const AUTH_OPTIONS: readonly AddServerAuth[] = ['none', 'oauth', 'bearer'] + +function HttpFields({ copy, draft, set }: { copy: AddCopy; draft: AddServerDraft; set: SetDraft }) { + const authLabel = { bearer: copy.authBearer, none: copy.authNone, oauth: copy.authOauth } + + return ( + <> + + set({ url: event.currentTarget.value })} size="sm" value={draft.url} /> + + + set({ headers })} + rows={draft.headers} + /> + +
+ set({ auth: next })} + options={AUTH_OPTIONS.map(id => ({ id, label: authLabel[id] }))} + value={draft.auth} + /> +
+ + {draft.auth === 'bearer' ? ( + + set({ bearer: event.currentTarget.value })} + size="sm" + type="password" + value={draft.bearer} + /> + + ) : null} + + ) +} + +function PasteBox({ + copy, + onFill, + previous +}: { + copy: AddCopy + onFill: (next: AddServerDraft) => void + previous: AddServerDraft +}) { + const [text, setText] = useState('') + const [failed, setFailed] = useState(false) + + const read = (value: string) => { + setText(value) + + if (value.trim() === '') { + setFailed(false) + + return + } + + const entries = parseMcpImport(value) + + setFailed(entries === null) + + if (entries !== null) { + onFill(draftFromEntry(entries[0].name, entries[0].config, previous)) + } + } + + return ( +
+