Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 876964f5a8 fix(computer-use): vision capture returns an image on cua-driver >=0.5.x
Vision mode called a `screenshot` MCP tool that cua-driver dropped in
0.5.x (full-window PNG capture was folded into `get_window_state`). The
driver replied "Unknown tool: screenshot", so `images` came back empty,
`png_b64` stayed None, and capture returned a 0x0 result with no image
on every call. `som`/`ax` were unaffected because they already use
`get_window_state`, which masked the regression.

Route vision by capability:
- driver advertises `screenshot` (older builds) -> use it (no AX walk)
- otherwise -> call `get_window_state` but discard the AX tree/elements,
  returning only the PNG so vision stays free of element noise
- capabilities not yet discovered -> try `screenshot`, fall back to
  `get_window_state` on an empty image, so the path self-heals

Add `_image_from_tool_result` to pull the PNG from either an MCP image
content-part or `structuredContent.screenshot_png_b64`, and use it on
the som path too so the image won't silently drop on driver builds that
deliver it via structuredContent instead of a content part.

Verified live (vision: 1568x954, 0 elements; som: image + 527 elements)
and with unit coverage of all four routing cases.
2026-06-22 17:40:18 -05:00
93 changed files with 740 additions and 8336 deletions
+21 -72
View File
@@ -635,32 +635,25 @@ def _read_small(path: Path) -> str:
return ""
@dataclass(frozen=True)
class ProjectFacts:
"""Structured project facts — the model's verify loop, detected once.
def _project_facts(root: Path) -> list[str]:
"""Detected project facts for the workspace snapshot.
The same data that feeds the workspace snapshot, exposed structurally so
non-prompt consumers (e.g. the desktop verify UI) read it instead of
re-detecting and drifting from the prompt.
The point is to hand the model its *verify loop* up front — which manifest,
which package manager, and the exact test/lint/build commands — instead of
making it rediscover them every session. Cheap: stat calls plus reads of a
couple of small files; built once at prompt-build time (cache-safe).
"""
facts: list[str] = []
manifests: list[str]
package_managers: list[str]
verify_commands: list[str]
context_files: list[str]
def detect_project_facts(root: Path) -> ProjectFacts:
"""Detect manifests, package manager(s), verify commands, and context files.
Cheap: stat calls plus reads of a couple of small files. The single source
of truth for both the prompt snapshot (:func:`_project_facts`) and the
gateway's ``project.facts`` — so the UI never re-sniffs verify commands.
"""
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
package_managers = list(
dict.fromkeys(pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file())
)
package_managers = [
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
]
if manifests:
line = f"- Project: {', '.join(manifests[:6])}"
if package_managers:
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
facts.append(line)
verify: list[str] = []
if (root / "scripts" / "run_tests.sh").is_file():
@@ -680,61 +673,17 @@ def detect_project_facts(root: Path) -> ProjectFacts:
f"make {name}" for name in _VERIFY_TARGETS
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
)
if verify:
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
facts.append(f"- Verify: {'; '.join(deduped)}")
return ProjectFacts(
manifests=manifests,
package_managers=package_managers,
verify_commands=list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS],
context_files=[c for c in _CONTEXT_FILES if (root / c).is_file()],
)
def _project_facts(root: Path) -> list[str]:
"""Render :func:`detect_project_facts` as workspace-snapshot lines.
Hands the model its *verify loop* up front — which manifest, which package
manager, and the exact test/lint/build commands — instead of making it
rediscover them every session. Built once at prompt-build time; the string
output must stay byte-stable to preserve the prompt cache.
"""
f = detect_project_facts(root)
facts: list[str] = []
if f.manifests:
line = f"- Project: {', '.join(f.manifests[:6])}"
if f.package_managers:
line += f" ({'/'.join(f.package_managers)})"
facts.append(line)
if f.verify_commands:
facts.append(f"- Verify: {'; '.join(f.verify_commands)}")
if f.context_files:
facts.append(f"- Context files: {', '.join(f.context_files)}")
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
if context_files:
facts.append(f"- Context files: {', '.join(context_files)}")
return facts
def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, Any]]:
"""Structured project facts for ``cwd`` — ``None`` outside a workspace.
Same detection the system-prompt snapshot uses (git root, else marker root),
exposed for non-prompt consumers (the desktop verify UI) so they never
re-derive "are we coding?" or duplicate the verify-command sniffing.
"""
resolved = _resolve_cwd(cwd)
root = _git_root(resolved) or _marker_root(resolved)
if root is None:
return None
f = detect_project_facts(root)
return {
"root": str(root),
"manifests": f.manifests,
"packageManagers": f.package_managers,
"verifyCommands": f.verify_commands,
"contextFiles": f.context_files,
}
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
"""Workspace snapshot for the system prompt (empty outside a workspace).
+4 -37
View File
@@ -805,11 +805,10 @@ def try_shrink_image_parts_in_messages(
Pillow couldn't help (caller should surface the original error).
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
``data:image/...;base64,...`` payload, plus Anthropic-native
``{"type": "image", "source": {"type": "base64", ...}}`` blocks.
For each one whose encoded size exceeds 4 MB (a safe target that slides
under Anthropic's 5 MB ceiling with header overhead) or whose longest side
exceeds ``max_dimension``, write the base64 to a tempfile, call
``data:image/...;base64,...`` payload. For each one whose encoded
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
ceiling with header overhead) or whose longest side exceeds
``max_dimension``, write the base64 to a tempfile, call
``vision_tools._resize_image_for_vision`` to produce a smaller data
URL, and substitute it in place.
@@ -965,28 +964,6 @@ def try_shrink_image_parts_in_messages(
logger.warning("image-shrink recovery: re-encode failed — %s", exc)
return None, triggered_by is not None
def _source_to_data_url(source: Any) -> Optional[str]:
if not isinstance(source, dict) or source.get("type") != "base64":
return None
data = source.get("data")
if not isinstance(data, str) or not data:
return None
media_type = str(source.get("media_type") or "image/jpeg").strip()
if not media_type.startswith("image/"):
media_type = "image/jpeg"
return f"data:{media_type};base64,{data}"
def _write_data_url_to_source(source: dict, data_url: str) -> None:
header, _, data = data_url.partition(",")
media_type = "image/jpeg"
if header.startswith("data:"):
candidate = header[len("data:"):].split(";", 1)[0].strip()
if candidate.startswith("image/"):
media_type = candidate
source["type"] = "base64"
source["media_type"] = media_type
source["data"] = data
for msg in api_messages:
if not isinstance(msg, dict):
continue
@@ -997,16 +974,6 @@ def try_shrink_image_parts_in_messages(
if not isinstance(part, dict):
continue
ptype = part.get("type")
if ptype == "image":
source = part.get("source")
url = _source_to_data_url(source)
resized, unshrinkable = _shrink_data_url(url or "")
if resized and isinstance(source, dict):
_write_data_url_to_source(source, resized)
changed_count += 1
elif unshrinkable:
unshrinkable_oversized += 1
continue
if ptype not in {"image_url", "input_image"}:
continue
image_value = part.get("image_url")
-158
View File
@@ -1,158 +0,0 @@
"""Shared one-off LLM requests for non-conversational helpers.
A "one-shot" is a single, stateless model call that runs *outside* any
conversation: it never touches a session's history, never breaks prompt
caching, and returns plain text. UI surfaces use it for small generative
chores — a commit message from a diff, a rename suggestion, a summary —
where spinning up an agent turn would be wrong (it would pollute the thread)
and hand-rolling an LLM call at every call site would be worse.
Two ways to call it:
* ``run_oneshot(instructions=..., user_input=...)`` — caller supplies the
full prompt.
* ``run_oneshot(template="commit_message", variables={...})`` — caller
names a registered template and passes its variables; the template owns
the prompt engineering so it stays consistent across CLI/TUI/desktop.
Model selection rides the same auxiliary plumbing as title generation
(:func:`agent.auxiliary_client.call_llm`): pass ``main_runtime`` to inherit
the live session's provider/model, otherwise the configured ``task`` (default
``title_generation``) resolves a cheap/fast backend.
"""
import logging
from typing import Any, Callable, Dict, Optional, Tuple
from agent.auxiliary_client import call_llm, extract_content_or_reasoning
logger = logging.getLogger(__name__)
# A template turns a variables dict into a (instructions, user_input) pair.
# Templates are plain callables (not str.format) so diff/code payloads with
# literal "{" / "}" pass through untouched.
PromptTemplate = Callable[[Dict[str, Any]], Tuple[str, str]]
def _truncate(text: str, limit: int) -> str:
text = text or ""
if len(text) <= limit:
return text
return text[:limit].rstrip() + "\n…(truncated)"
_COMMIT_INSTRUCTIONS = (
"You write git commit messages. Given a diff of staged changes, write ONE "
"concise Conventional Commits message describing what the change does and why.\n"
"Rules:\n"
"- Subject line: type(scope): summary — imperative mood, lower-case, no "
"trailing period, ≤ 72 characters. Types: feat, fix, refactor, perf, docs, "
"test, build, chore, style, ci.\n"
"- Omit the scope if it isn't obvious.\n"
"- Add a short body (wrapped at ~72 cols) ONLY when the change needs "
"explanation; skip it for small/obvious changes.\n"
"- Describe the actual change, never restate the diff line-by-line.\n"
"- Return ONLY the commit message text — no quotes, no markdown fences, no "
"preamble."
)
def _commit_message_template(variables: Dict[str, Any]) -> Tuple[str, str]:
diff = _truncate(str(variables.get("diff") or ""), 12000)
recent = _truncate(str(variables.get("recent_commits") or ""), 1500)
parts = []
if recent.strip():
parts.append(
"Recent commit subjects from this repo (match their style/conventions):\n"
f"{recent}"
)
parts.append("Diff to describe:\n" + (diff or "(no textual diff available)"))
# "Regenerate" must yield something new even on models that decode greedily
# / pin temperature server-side. A trailing nonce isn't enough, so we hand
# back the previous message and require a genuinely different one.
avoid = _truncate(str(variables.get("avoid") or "").strip(), 1000)
if avoid:
parts.append(
"You already proposed the message below and the user wants a "
"different one. Write a NEW message with different wording (and, if "
"reasonable, a different emphasis or scope framing) — do not repeat "
f"it:\n{avoid}"
)
return _COMMIT_INSTRUCTIONS, "\n\n".join(parts)
# Registry of named templates. Add an entry here to give a new surface a
# consistent, reusable prompt without teaching every caller the prompt text.
PROMPT_TEMPLATES: Dict[str, PromptTemplate] = {
"commit_message": _commit_message_template,
}
def render_template(name: str, variables: Optional[Dict[str, Any]] = None) -> Tuple[str, str]:
"""Resolve a registered template into (instructions, user_input).
Raises KeyError if the template name is unknown so callers fail loudly
instead of silently sending an empty prompt.
"""
template = PROMPT_TEMPLATES.get(name)
if template is None:
raise KeyError(f"unknown one-shot template: {name}")
return template(variables or {})
def run_oneshot(
*,
instructions: str = "",
user_input: str = "",
template: Optional[str] = None,
variables: Optional[Dict[str, Any]] = None,
task: str = "title_generation",
max_tokens: int = 1024,
temperature: Optional[float] = 0.3,
timeout: float = 60.0,
main_runtime: Optional[Dict[str, Any]] = None,
) -> str:
"""Run a single stateless LLM request and return its text.
Provide either a registered ``template`` (+ ``variables``) or an explicit
``instructions`` / ``user_input`` pair. Returns the model's text answer,
stripped of surrounding whitespace and any wrapping code fence.
Raises RuntimeError when no LLM provider is configured (surfaced from
:func:`call_llm`) and KeyError for an unknown template name.
"""
if template:
instructions, user_input = render_template(template, variables)
if not (instructions or "").strip() and not (user_input or "").strip():
raise ValueError("run_oneshot requires a template or instructions/user_input")
messages = []
if (instructions or "").strip():
messages.append({"role": "system", "content": instructions})
messages.append({"role": "user", "content": user_input or ""})
response = call_llm(
task=task,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
timeout=timeout,
main_runtime=main_runtime,
)
text = (extract_content_or_reasoning(response) or "").strip()
return _strip_code_fence(text)
def _strip_code_fence(text: str) -> str:
"""Drop a single wrapping ``` fence the model may have added."""
if not text.startswith("```"):
return text
lines = text.splitlines()
if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```":
return "\n".join(lines[1:-1]).strip()
return text
@@ -269,94 +269,6 @@ function cookiesHaveLiveSession(cookies) {
)
}
/**
* Normalize a stored SSH connection entry into a clean descriptor, or null when
* it is not a usable SSH config. Pure: no secrets here — the per-connection
* dashboard token is persisted separately (encrypted) and decrypted by main.cjs,
* exactly like the token-remote secret. An SSH entry needs at least a host.
*
* Shape in/out: { mode:'ssh', host, user?, port?, keyPath?, remoteHermesPath? }
*/
function normalizeSshConfig(entry) {
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {
return null
}
let host = String(entry.host || '').trim()
if (!host) {
return null
}
// Parse a user@host[:port] target typed into the single host field. Explicit
// user/port fields win, so filling the User field after typing user@host does
// NOT double up into user@user@host. A bare ~/.ssh/config alias is preserved.
let parsedUser
let parsedPort
const at = host.indexOf('@')
if (at > 0) {
parsedUser = host.slice(0, at)
host = host.slice(at + 1)
}
// Only split a trailing :port when there's exactly one colon and a numeric
// suffix — leaves IPv6 literals (multiple colons) and bare aliases alone.
if ((host.match(/:/g) || []).length === 1) {
const [h, p] = host.split(':')
if (/^\d+$/.test(p)) {
host = h
parsedPort = Number.parseInt(p, 10)
}
}
if (!host) {
return null
}
const out = { mode: 'ssh', host }
const user = String(entry.user || '').trim() || parsedUser || ''
if (user) out.user = user
const explicitPort = Number.parseInt(String(entry.port ?? ''), 10)
const port = Number.isInteger(explicitPort) && explicitPort > 0 ? explicitPort : parsedPort
if (Number.isInteger(port) && port > 0 && port !== 22) {
out.port = port
}
const keyPath = String(entry.keyPath || '').trim()
if (keyPath) out.keyPath = keyPath
const remoteHermesPath = String(entry.remoteHermesPath || '').trim()
if (remoteHermesPath) out.remoteHermesPath = remoteHermesPath
return out
}
/**
* Select a profile's SSH connection override from a connection config, or null
* when it has none. Mirrors profileRemoteOverride() but for `mode: 'ssh'`
* entries. Returns the normalized SSH descriptor (no token).
*/
function profileSshOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
return normalizeSshConfig(entry)
}
/**
* Human-facing host label for the connection statusbar pill. For SSH mode the
* caller passes the resolved/entered host directly; for token/oauth remotes we
* derive it from the (real) backend URL — NOT the loopback tunnel URL. Returns
* a bare hostname (and :port when non-default) or null.
*/
function hostLabelFromBaseUrl(baseUrl) {
const raw = String(baseUrl || '').trim()
if (!raw) return null
let parsed
try {
parsed = new URL(raw)
} catch {
return null
}
const host = parsed.hostname
if (!host) return null
const port = parsed.port
if (port && port !== '80' && port !== '443') {
return `${host}:${port}`
}
return host
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
@@ -366,13 +278,10 @@ module.exports = {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -22,13 +22,10 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -397,82 +394,3 @@ test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
/mintTicket function is required/
)
})
// --- SSH mode helpers ---
test('normalizeSshConfig requires mode:ssh and a host', () => {
assert.equal(normalizeSshConfig(null), null)
assert.equal(normalizeSshConfig({ mode: 'remote', url: 'http://x' }), null)
assert.equal(normalizeSshConfig({ mode: 'ssh' }), null)
assert.equal(normalizeSshConfig({ mode: 'ssh', host: ' ' }), null)
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box' }), { mode: 'ssh', host: 'box' })
})
test('normalizeSshConfig keeps user/keyPath/remoteHermesPath and drops the default port', () => {
assert.deepEqual(
normalizeSshConfig({
mode: 'ssh',
host: 'box',
user: 'me',
port: 22,
keyPath: '~/.ssh/id_ed25519',
remoteHermesPath: '/opt/hermes'
}),
{ mode: 'ssh', host: 'box', user: 'me', keyPath: '~/.ssh/id_ed25519', remoteHermesPath: '/opt/hermes' }
)
})
test('normalizeSshConfig preserves a non-default port', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 2222 }), {
mode: 'ssh',
host: 'box',
port: 2222
})
})
test('normalizeSshConfig parses user@host typed into the host field', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'jonny@mac-mini' }), {
mode: 'ssh',
host: 'mac-mini',
user: 'jonny'
})
})
test('normalizeSshConfig parses user@host:port and drops a default :22', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'jonny@box:2222' }), {
mode: 'ssh',
host: 'box',
user: 'jonny',
port: 2222
})
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box:22' }), { mode: 'ssh', host: 'box' })
})
test('normalizeSshConfig: explicit user/port win over user@host:port (no user@user@host)', () => {
assert.deepEqual(
normalizeSshConfig({ mode: 'ssh', host: 'jonny@box:2222', user: 'admin', port: 2200 }),
{ mode: 'ssh', host: 'box', user: 'admin', port: 2200 }
)
})
test('normalizeSshConfig leaves a bare ~/.ssh/config alias and IPv6 literals alone', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'mac-mini' }), { mode: 'ssh', host: 'mac-mini' })
// IPv6 (multiple colons) must NOT be split as host:port
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'fe80::1' }), { mode: 'ssh', host: 'fe80::1' })
})
test('profileSshOverride returns a profile-scoped ssh descriptor or null', () => {
const config = { profiles: { work: { mode: 'ssh', host: 'mac-mini', user: 'jonny' }, other: { mode: 'remote', url: 'http://x' } } }
assert.deepEqual(profileSshOverride(config, 'work'), { mode: 'ssh', host: 'mac-mini', user: 'jonny' })
assert.equal(profileSshOverride(config, 'other'), null, 'token-remote entry is not an ssh override')
assert.equal(profileSshOverride(config, 'missing'), null)
assert.equal(profileSshOverride(config, ''), null, 'global scope has no profile entry')
})
test('hostLabelFromBaseUrl gives a bare host, with :port only when non-default', () => {
assert.equal(hostLabelFromBaseUrl('https://box.tail1234.ts.net'), 'box.tail1234.ts.net')
assert.equal(hostLabelFromBaseUrl('http://box.local:8080'), 'box.local:8080')
assert.equal(hostLabelFromBaseUrl('https://box:443'), 'box')
assert.equal(hostLabelFromBaseUrl(''), null)
assert.equal(hostLabelFromBaseUrl('not a url'), null)
})
+11 -543
View File
@@ -38,9 +38,6 @@ const { createLinkTitleWindow } = require('./link-title-window.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { SSH_ERROR, SshConnection, buildInteractiveSshArgs, pickLocalPort, redactSecrets } = require('./ssh-connection.cjs')
const remoteLifecycle = require('./remote-lifecycle.cjs')
const { collectSshConfigHosts, parseSshGOutput } = require('./ssh-config.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
@@ -77,13 +74,10 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -950,33 +944,6 @@ function openExternalUrl(rawUrl) {
return true
}
async function openPreviewInBrowser(rawUrl) {
const raw = String(rawUrl || '').trim()
if (!raw) return false
let parsed
try {
parsed = new URL(raw)
} catch {
return false
}
if (parsed.protocol === 'file:') {
let localPath
try {
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' })
} catch {
return false
}
await shell.openExternal(pathToFileURL(localPath).toString())
return true
}
return openExternalUrl(raw)
}
function ensureWslWindowsFonts() {
if (!IS_WSL) return
@@ -4306,20 +4273,6 @@ function sanitizeConnectionProfiles(raw) {
continue
}
// SSH-mode entries carry host/user/port/keyPath/remoteHermesPath instead of
// a url, and (like remote entries) an encrypted token blob — the per-
// connection dashboard session token minted in main, NOT a user secret.
if (entry.mode === 'ssh') {
const ssh = normalizeSshConfig(entry)
if (ssh) {
if (entry.token && typeof entry.token === 'object') {
ssh.token = entry.token
}
out[name] = ssh
}
continue
}
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
const url = String(entry.url || '').trim()
if (url) {
@@ -4363,10 +4316,7 @@ function readDesktopConnectionConfig() {
// backward compatibility with configs written before OAuth support.
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
// 'ssh' joins 'remote'/'local' as a top-level mode; SSH connection
// fields (host/user/port/keyPath/remoteHermesPath) ride on the `remote`
// sub-object, which is preserved verbatim below.
mode: parsed.mode === 'remote' ? 'remote' : parsed.mode === 'ssh' ? 'ssh' : 'local',
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote,
// Per-profile remote overrides: each profile may point at its own
// backend (local spawn or its own remote URL). Preserved verbatim so
@@ -4434,37 +4384,10 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
const scopedMode = key ? scoped?.mode : config.mode
// SSH-mode block: surface the connection fields (no token to the renderer —
// it's an internal artifact). remoteTokenSet reports whether a dashboard
// token has already been adopted (i.e. a running dashboard can be reused).
if (scopedMode === 'ssh') {
const sshConfig = normalizeSshConfig({ mode: 'ssh', ...block })
return {
mode: 'ssh',
profile: key,
sshHost: sshConfig?.host || '',
sshUser: sshConfig?.user || '',
sshPort: sshConfig?.port || null,
sshKeyPath: sshConfig?.keyPath || '',
sshRemoteHermesPath: sshConfig?.remoteHermesPath || '',
// Remote-auth fields are not meaningful in SSH mode (the dashboard token
// is internal), but the renderer contract always carries them — return
// inert defaults so consumers never optional-narrow.
remoteAuthMode: 'token',
remoteOauthConnected: false,
remoteUrl: '',
remoteTokenPreview: null,
remoteTokenSet: Boolean(decryptDesktopSecret(block.token)),
envOverride: false
}
}
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
const mode = envOverride || scopedMode === 'remote' ? 'remote' : 'local'
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
@@ -4488,13 +4411,6 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
// SSH fields are always present on the contract (empty in local/remote mode)
// so the renderer never optional-narrows; populated only in the ssh branch.
sshHost: '',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: '',
// The env override only forces the global/primary connection; a per-profile
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
envOverride
@@ -4514,21 +4430,7 @@ function buildRemoteBlock(remoteUrl, authMode, token) {
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const key = connectionScopeKey(input.profile)
const mode = input.mode === 'remote' ? 'remote' : input.mode === 'ssh' ? 'ssh' : 'local'
// SSH-mode save: connection fields are host/user/port/keyPath/remoteHermesPath
// (no user-entered token; the dashboard token is minted + reconciled at
// bootstrap and persisted separately). A saved SSH block preserves any
// already-adopted token so a reconnect can reuse the running dashboard.
if (mode === 'ssh') {
const sshBlock = buildSshBlock(input, key ? existing.profiles?.[key] || {} : existing.remote || {})
if (key) {
const profiles = { ...(existing.profiles || {}) }
profiles[key] = sshBlock
return { mode: existing.mode === 'remote' || existing.mode === 'ssh' ? existing.mode : 'local', remote: existing.remote || {}, profiles }
}
return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} }
}
const mode = input.mode === 'remote' ? 'remote' : 'local'
// The block being edited: a per-profile entry or the global remote block.
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
@@ -4551,7 +4453,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
} else {
delete profiles[key]
}
return { mode: existing.mode === 'remote' || existing.mode === 'ssh' ? existing.mode : 'local', remote: existing.remote || {}, profiles }
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
}
const nextRemote =
@@ -4563,41 +4465,13 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
}
// Build an SSH connection block from a save payload, preserving an
// already-adopted dashboard token from the existing block (the token is minted
// + reconciled at bootstrap, never user-entered). `mode: 'ssh'` is stamped so
// normalizeSshConfig/profileSshOverride recognize it.
function buildSshBlock(input, existingBlock = {}) {
const merged = normalizeSshConfig({
mode: 'ssh',
host: input.sshHost ?? existingBlock.host,
user: input.sshUser ?? existingBlock.user,
port: input.sshPort ?? existingBlock.port,
keyPath: input.sshKeyPath ?? existingBlock.keyPath,
remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath
})
if (!merged) {
throw new Error('SSH host is required.')
}
// Carry forward an already-adopted dashboard token unless the host changed
// (a different host invalidates the old dashboard's token).
if (existingBlock.token && existingBlock.host === merged.host) {
merged.token = existingBlock.token
}
return merged
}
// Build a remote backend connection descriptor from an already-resolved remote
// config. Handles both auth models (OAuth ws-ticket vs static session token)
// and is shared by the per-profile, env, and global resolution paths. `token`
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
// for diagnostics ('profile' | 'env' | 'settings').
async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost, remoteKind = 'url') {
async function buildRemoteConnection(rawUrl, authMode, token, source) {
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
// For token/oauth remotes the meaningful host is the real backend URL; for
// SSH remotes the caller passes the entered/resolved host explicitly (the
// baseUrl is a 127.0.0.1 tunnel and would be useless in the pill).
const host = remoteHost || hostLabelFromBaseUrl(baseUrl)
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookies in the OAuth
@@ -4634,8 +4508,6 @@ async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost
mode: 'remote',
source,
authMode: 'oauth',
remoteHost: host || undefined,
remoteKind,
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
@@ -4654,220 +4526,11 @@ async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost
mode: 'remote',
source,
authMode: 'token',
remoteHost: host || undefined,
remoteKind,
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
// ---------------------------------------------------------------------------
// SSH remote-mode bootstrap
//
// SSH mode is architecturally desktop-local mode with the loopback stretched
// over SSH: open a ControlMaster, bring up (or reuse) a dedicated --isolated
// dashboard on the remote, forward 127.0.0.1:<local> -> 127.0.0.1:<remote>,
// then hand the EXISTING token-remote machinery a 127.0.0.1 baseUrl. Everything
// downstream (REST bridge, /api/ws, sessions, /api/fs/*, version/update pills)
// is unchanged — it keys off the connection descriptor, not how it was made.
// ---------------------------------------------------------------------------
// Live SSH connections keyed by scope ('' for global, or the profile name).
// Holds the SshConnection (the control master), the tunnel ports, and the
// remote pid so liveness/reconnect/teardown can find them. Survives across
// resolveRemoteBackend calls within one app run.
const sshConnections = new Map()
// One-shot guard so the awaited before-quit SSH teardown (which preventDefaults
// the first quit) doesn't loop when app.quit() fires the event again.
let sshQuitTeardownDone = false
function sshScopeKey(profile) {
return connectionScopeKey(profile) || ''
}
// Redaction-wrapped logger so NOTHING that flows through the SSH lifecycle
// (spawn command lines carry the session token) reaches desktop.log raw.
function sshRememberLog(chunk) {
rememberLog(redactSecrets(String(chunk == null ? '' : chunk)))
}
// Authenticated GET /api/status through the tunnel — the authoritative reuse
// probe. True iff the dashboard answers ok with this token.
async function sshProbeStatus(baseUrl, token) {
try {
await fetchJson(`${baseUrl}/api/status`, token)
return true
} catch {
return false
}
}
// Tear down a scope's SSH state: cancel the forward, close the master, forget
// it. Leaves the REMOTE dashboard running (reconnect is instant; in-flight
// agent turns survive a client drop) — that is the VS Code semantics the spec
// chose. The lockfile reuse flow recovers it on next connect.
async function teardownSshConnection(profile) {
const scope = sshScopeKey(profile)
const state = sshConnections.get(scope)
if (!state) return
sshConnections.delete(scope)
// Dispose any interim ssh -tt terminals riding this scope's master FIRST —
// once the master closes a leftover PTY is pointed at a dead control socket.
// Spec component 4 invariant: a connection flip tears down terminal sessions
// on the connection (mirrors desktop-remote-terminal.md). Local/other-scope
// terminals are untagged or tagged with a different scope and are left alone.
for (const [id, info] of [...terminalSessions.entries()]) {
if (info.sshScope === scope) {
disposeTerminalSession(id)
}
}
try {
if (state.localPort && state.remotePort) {
await state.ssh.cancelForward(state.localPort, state.remotePort)
}
} catch {
// best effort
}
try {
await state.ssh.close()
} catch {
// best effort
}
}
// Resolve the live SSH connection backing the window's PRIMARY backend, or
// null when the active connection is not SSH. Used by the interim ssh -tt
// terminal so a remote terminal lands on the SSH host — and ONLY in SSH mode
// (it must never leak into token/oauth remotes, whose trust boundary is a
// token/cookie, not a shell credential). Returns { ssh, scope } so the spawned
// terminal can be tagged with its backing scope and disposed on a flip.
//
// CRITICAL: this must mirror resolveRemoteBackend's precedence, not just return
// any cached SSH state. A per-profile token/OAuth override wins over a global
// SSH connection — so if the active profile resolves to a NON-SSH backend, the
// terminal must NOT fall through to a global SSH host. Returning cached SSH
// state unconditionally would leak an ssh -tt shell into a token/OAuth remote.
function activeSshTerminalTarget() {
const profile = primaryProfileKey()
const config = readDesktopConnectionConfig()
// 1. Per-profile SSH override → that scope's SSH state (if live).
if (profileSshOverride(config, profile)) {
const scope = sshScopeKey(profile)
const state = sshConnections.get(scope)
return state && state.ssh ? { ssh: state.ssh, scope } : null
}
// 2. Per-profile NON-SSH override (token/OAuth) → NOT an SSH terminal. Stop
// here; do not fall through to global SSH.
if (profileRemoteOverride(config, profile)) {
return null
}
// 3. Env override is token-auth URL remote, never SSH.
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
return null
}
// 4. Global SSH → the global scope's SSH state (if live).
if (config.mode === 'ssh') {
const state = sshConnections.get('')
return state && state.ssh ? { ssh: state.ssh, scope: '' } : null
}
return null
}
// Bring up (or reuse) the SSH-tunneled dashboard for one scope and return a
// token-remote connection descriptor. `sshConfig` is the normalized
// { host, user?, port?, keyPath?, remoteHermesPath? }; `reuseToken` is the
// decrypted per-connection token from encrypted storage (or '').
async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) {
const scope = sshScopeKey(profile)
const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
// Reuse a live master for this scope if we still have one; otherwise open
// fresh. A dead master (sleep/network flap) is closed and reopened.
let ssh = sshConnections.get(scope)?.ssh
if (ssh && !(await ssh.isAlive())) {
try {
await ssh.close()
} catch {
// ignore
}
ssh = null
sshConnections.delete(scope)
}
if (!ssh) {
ssh = new SshConnection(
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
{ rememberLog: sshRememberLog }
)
await ssh.open()
}
let result
try {
result = await remoteLifecycle.connect({
ssh,
profile: connectionScopeKey(profile) || '',
remoteHermesPath: sshConfig.remoteHermesPath || '',
clientId: scope || 'default',
reuseToken: reuseToken || '',
forward: (localPort, remotePort) => ssh.forward(localPort, remotePort),
cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort),
pickLocalPort,
waitForHermes,
probeStatus: sshProbeStatus,
adoptServedToken: adoptServedDashboardToken,
rememberLog: sshRememberLog
})
} catch (error) {
// Map lifecycle/SSH failures into a single actionable message; the boot
// overlay shows this verbatim instead of the generic gateway error.
const err = new Error(error.message)
err.sshError = error.kind || 'unknown'
err.isSshBootstrap = true
throw err
}
// Persist the served token (encrypted) so the next launch can reuse this
// dashboard via the lockfile fingerprint without re-bootstrapping.
persistSshConnectionToken(profile, source, result.token)
sshConnections.set(scope, {
ssh,
localPort: result.localPort,
remotePort: result.remotePort,
pid: result.pid,
host: sshConfig.host,
hostLabel
})
// Hand the existing token-remote machinery the loopback baseUrl. The pill's
// host is the SSH host, NOT 127.0.0.1.
return buildRemoteConnection(result.baseUrl, 'token', result.token, source, hostLabel, 'ssh')
}
// Save the served token back into the SSH connection entry (encrypted), so a
// later launch reuses the running dashboard. Global SSH lives under
// config.remote; a per-profile SSH override lives under config.profiles[name].
function persistSshConnectionToken(profile, source, token) {
try {
const config = readDesktopConnectionConfig()
const encrypted = encryptDesktopSecret(token)
if (source === 'profile') {
const key = connectionScopeKey(profile)
if (key && config.profiles?.[key]?.mode === 'ssh') {
config.profiles[key].token = encrypted
writeDesktopConnectionConfig(config)
}
} else if (config.mode === 'ssh' && config.remote) {
config.remote.token = encrypted
writeDesktopConnectionConfig(config)
}
} catch (error) {
sshRememberLog(`[ssh] could not persist served token: ${error.message}`)
}
}
// Resolve the remote backend for a given profile, or null when that profile
// should run a LOCAL backend. Precedence:
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
@@ -4881,12 +4544,6 @@ async function resolveRemoteBackend(profile) {
// 1. Per-profile override — "a profile with its own remote host". Wins even
// over the env override so an explicitly-configured profile always
// reaches its intended backend.
const sshOverride = profileSshOverride(config, profile)
if (sshOverride) {
const reuseToken = decryptDesktopSecret(config.profiles?.[connectionScopeKey(profile)]?.token)
return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile')
}
const override = profileRemoteOverride(config, profile)
if (override) {
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
@@ -4907,17 +4564,6 @@ async function resolveRemoteBackend(profile) {
}
// 3. Global remote.
// 3a. Global SSH remote — bootstrap the tunnel + dashboard, hand the
// token-remote machinery a loopback baseUrl.
if (config.mode === 'ssh') {
const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })
if (!ssh) {
throw new Error('SSH remote mode is selected but no host is configured. Open Settings → Gateway → Connect via SSH.')
}
const reuseToken = decryptDesktopSecret(config.remote?.token)
return bootstrapSshConnection(null, ssh, reuseToken, 'settings')
}
if (config.mode !== 'remote') {
return null
}
@@ -4940,17 +4586,13 @@ function configuredRemoteProfileNames() {
}
// True when the app is in app-global remote mode (Settings → "All profiles" →
// Remote/SSH, or the env override): a SINGLE remote backend serves every
// profile via ?profile=. Distinct from per-profile overrides — here there's one
// host for all. SSH counts: a global SSH connection resolves to one loopback
// backend that, exactly like a global URL remote, must carry ?profile= so each
// desktop profile maps to its own profile on the remote (not the remote default).
// Remote, or the env override): a SINGLE remote backend serves every profile via
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
function globalRemoteActive() {
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
return true
}
const mode = readDesktopConnectionConfig().mode
return mode === 'remote' || mode === 'ssh'
return readDesktopConnectionConfig().mode === 'remote'
}
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
@@ -5032,52 +4674,6 @@ async function probeRemoteAuthMode(rawUrl) {
}
async function testDesktopConnectionConfig(input = {}) {
// SSH mode: test reachability + that hermes is locatable on a supported
// platform, WITHOUT spawning a dashboard. Distinct errors for unreachable /
// auth-failed / hermes-not-found / unsupported-platform.
if (input.mode === 'ssh') {
const sshConfig = normalizeSshConfig({
mode: 'ssh',
host: input.sshHost,
user: input.sshUser,
port: input.sshPort,
keyPath: input.sshKeyPath,
remoteHermesPath: input.sshRemoteHermesPath
})
if (!sshConfig) {
return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' }
}
const ssh = new SshConnection(
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
{ rememberLog: sshRememberLog }
)
try {
await ssh.open()
const platform = await remoteLifecycle.probeRemotePlatform(ssh)
const hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '')
return {
reachable: true,
sshError: null,
error: null,
remotePlatform: `${platform.os}/${platform.arch}`,
remoteHermesPath: hermesPath,
host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
}
} catch (error) {
return {
reachable: false,
sshError: error.kind || 'unknown',
error: error.message
}
} finally {
try {
await ssh.close()
} catch {
// best effort — a transient test connection
}
}
}
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const key = connectionScopeKey(input.profile)
// The block under test: a per-profile entry or the global remote. Coerce has
@@ -5500,12 +5096,6 @@ async function startHermes() {
authMode: remote.authMode || 'token',
token: remote.token,
wsUrl: remote.wsUrl,
// Carry the SSH identity through so the statusbar pill reads "SSH: host"
// (not "Remote: 127.0.0.1") for a global SSH connection. Without these
// the primary-backend path drops them and the pill mislabels SSH as a
// plain token remote.
remoteHost: remote.remoteHost,
remoteKind: remote.remoteKind,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -6001,51 +5591,6 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:ssh-hosts', async () => {
// Read-only host suggestions from ~/.ssh/config (+ Includes). Never writes.
try {
return { hosts: collectSshConfigHosts() }
} catch {
return { hosts: [] }
}
})
ipcMain.handle('hermes:connection-config:ssh-resolve', async (_event, host) => {
// Resolve the effective target with `ssh -G <host>` (short timeout) so the
// UI can show/normalize the real hostname/user/port/identityfile a host
// alias expands to. Best-effort: a failure returns nulls, not an error.
const target = String(host || '').trim()
if (!target) return { hostname: null, user: null, port: null, identityFile: null }
return new Promise(resolve => {
let out = ''
let settled = false
const child = spawn('ssh', ['-G', target], { stdio: ['ignore', 'pipe', 'ignore'] })
const timer = setTimeout(() => {
if (settled) return
settled = true
try {
child.kill('SIGKILL')
} catch {
// already gone
}
resolve({ hostname: null, user: null, port: null, identityFile: null })
}, 5_000)
child.stdout.on('data', d => {
out += d.toString()
})
child.on('error', () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve({ hostname: null, user: null, port: null, identityFile: null })
})
child.on('close', () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(parseSshGOutput(out))
})
})
})
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
// Open the gateway's OAuth login window and wait for the session cookie to
@@ -6076,10 +5621,6 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const key = connectionScopeKey(payload?.profile)
// A connection change for this scope invalidates any live SSH tunnel for it —
// tear it down so the next resolve re-bootstraps against the new target.
await teardownSshConnection(key || null)
if (key && key !== primaryProfileKey()) {
// Editing a NON-primary profile's connection: don't disturb the window's
// primary backend. Drop the profile's pooled backend so the next switch
@@ -6457,12 +5998,6 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
}
})
ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => {
if (!(await openPreviewInBrowser(url))) {
throw new Error('Invalid preview URL')
}
})
// User-configurable default project directory. The renderer reads this on
// settings mount and seeds the value into the picker; writing back persists
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
@@ -6718,57 +6253,10 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
ensureSpawnHelperExecutable()
const id = crypto.randomUUID()
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
// INTERIM SSH-mode remote terminal (component 5; SSH mode ONLY). When the
// window's primary backend is an SSH connection, spawn node-pty wrapping
// `ssh -tt` over the EXISTING control master so the terminal lands on the
// remote host. node-pty's resize() sends SIGWINCH to the local ssh client,
// which forwards it to the remote PTY — so resize propagates end to end.
// The remote cwd is the (remote) session cwd; we do NOT run it through
// safeTerminalCwd (that stats the LOCAL fs). This never engages for
// token/oauth remotes (activeSshTerminalTarget returns null) — their trust
// boundary is a token, not a shell credential.
// TODO(remote-terminal): replace with the dashboard /api/terminal WebSocket
// once specs/desktop-remote-terminal.md lands; then the terminal rides the
// tunnel like every other socket and cwd-follows-session becomes uniform.
const sshTarget = activeSshTerminalTarget()
if (sshTarget) {
const remoteCwd = String(payload?.cwd || '').trim()
const sshArgs = buildInteractiveSshArgs(sshTarget.ssh, remoteCwd)
const sshPty = nodePty.spawn('ssh', sshArgs, {
cols,
cwd: app.getPath('home'),
env: terminalShellEnv(),
name: 'xterm-256color',
rows
})
// Tag the session with its backing SSH scope so a connection flip can
// dispose the PTYs riding the master it tears down (the master goes away;
// a leftover ssh -tt would be pointed at a dead socket).
terminalSessions.set(id, { pty: sshPty, webContentsId: event.sender.id, sshScope: sshTarget.scope })
const sshSend = (suffix, data) => {
if (event.sender.isDestroyed()) {
return
}
event.sender.send(terminalChannel(id, suffix), data)
}
sshPty.onData(data => sshSend('data', data))
sshPty.onExit(({ exitCode, signal }) => {
terminalSessions.delete(id)
sshSend('exit', { code: exitCode, signal: signal || null })
})
event.sender.once('destroyed', () => disposeTerminalSession(id))
return { cwd: remoteCwd, id, shell: 'ssh' }
}
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
const ptyProcess = nodePty.spawn(command, args, {
cols,
cwd,
@@ -7250,7 +6738,7 @@ function configureSpellChecker() {
}
}
app.on('before-quit', event => {
app.on('before-quit', () => {
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {
try {
@@ -7277,26 +6765,6 @@ app.on('before-quit', event => {
hermesProcess.kill('SIGTERM')
}
stopAllPoolBackends()
// Close SSH control masters so local forwards don't linger after quit (the
// master is opened with -f/ControlPersist, so a fire-and-forget close can be
// cut off by app exit before the socket is torn down). The REMOTE dashboards
// are intentionally LEFT running — only the local-side master/forward closes —
// so a relaunch reconnects via the lockfile reuse flow without re-bootstrapping
// (VS Code semantics). One-shot: preventDefault the first quit, await teardown
// (bounded so a wedged ssh can't block quit), then quit again.
if (sshConnections.size > 0 && !sshQuitTeardownDone) {
event.preventDefault()
const scopes = [...sshConnections.keys()]
const bounded = Promise.race([
Promise.allSettled(scopes.map(scope => teardownSshConnection(scope || null))),
new Promise(resolve => setTimeout(resolve, 4000))
])
void bounded.then(() => {
sshQuitTeardownDone = true
app.quit()
})
}
})
app.on('window-all-closed', () => {
-3
View File
@@ -12,8 +12,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
sshConfigHosts: () => ipcRenderer.invoke('hermes:connection-config:ssh-hosts'),
sshResolveHost: host => ipcRenderer.invoke('hermes:connection-config:ssh-resolve', host),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
@@ -46,7 +44,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
settings: {
-505
View File
@@ -1,505 +0,0 @@
/**
* remote-lifecycle.cjs
*
* Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop
* SSH remote mode. Composes an SshConnection (injected) with HTTP probes
* through the established tunnel (injected fetch) and the served-token adoption
* step (injected). Knows how to:
*
* - locate the Hermes install on the remote (login-shell probe),
* - gate the remote platform to Linux/macOS via `uname`,
* - reuse an existing desktop-dedicated dashboard via a lockfile + an
* AUTHENTICATED /api/status probe (pid liveness alone is insufficient),
* - spawn a fresh detached `--isolated --port 0` dashboard and scrape its
* `HERMES_DASHBOARD_READY port=<n>` readiness line,
* - adopt the token the dashboard actually serves (served-token adoption),
* - clean up a stale dashboard only when it is provably ours.
*
* Electron-free so it can be unit-tested with `node --test`. main.cjs wires the
* real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in.
*
* The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After
* readiness the caller (or connect() here) runs served-token adoption against
* the tunneled baseUrl and the SERVED token's fingerprint is what lands in the
* lockfile — so the reuse probe checks the credential that actually
* authenticates /api/ws, not the minted one (which the dashboard may regen).
*/
const crypto = require('node:crypto')
const LOCKFILE_SCHEMA_VERSION = 1
// Bumped when the desktop<->dashboard reuse contract changes in a way that
// makes an old running dashboard unsafe to reattach to (token handling, the
// readiness/spawn args, the served-token reconciliation). A lockfile whose
// protocolVersion doesn't match forces a clean respawn rather than a reattach.
const PROTOCOL_VERSION = 1
const READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
// Remote log the detached dashboard appends to; also where we scrape readiness.
const REMOTE_LOG = '~/.hermes/logs/desktop-ssh.log'
const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh'
const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin'])
const DEFAULT_READY_TIMEOUT_MS = 45_000
const READY_POLL_INTERVAL_MS = 750
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function mintToken() {
return crypto.randomBytes(32).toString('hex')
}
// Fingerprint a token for the lockfile — never store the raw secret on the
// remote. SHA256, truncated; comparison is constant-shape.
function fingerprintToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32)
}
// Stable per-client lock id so a given desktop client reuses its own dashboard
// across reconnects but never collides with another client's.
function clientLockId(clientId) {
const safe = String(clientId || 'default').replace(/[^A-Za-z0-9_.-]/g, '_')
return safe.slice(0, 64) || 'default'
}
function lockfilePath(clientId) {
return `${REMOTE_LOCK_DIR}/${clientLockId(clientId)}.lock.json`
}
// shell-single-quote a value for safe interpolation into a remote command.
function shq(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`
}
// ---------------------------------------------------------------------------
// Locate hermes on the remote
// ---------------------------------------------------------------------------
// Try, in order: an explicit profile path; `command -v hermes` in a LOGIN
// shell (non-login `ssh host cmd` PATH frequently misses user installs — the
// login-shell probe is load-bearing, same pitfall ssh.py works around); the
// conventional venv path. Returns the resolved absolute path or throws an
// install-hint error.
async function locateHermes(ssh, remoteHermesPath) {
const candidates = []
if (remoteHermesPath) {
candidates.push(remoteHermesPath)
}
// login-shell `command -v` — quoted so the remote shell resolves PATH the
// way an interactive login would.
try {
const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()
if (found) {
candidates.push(found.split('\n').pop().trim())
}
} catch {
// fall through to the explicit candidates below
}
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
for (const candidate of candidates) {
if (!candidate) continue
try {
// -x test resolves ~ and verifies it's executable in one round trip.
const ok = (await ssh.exec(`[ -x "$(eval echo ${shq(candidate)})" ] && echo OK || true`)).trim()
if (ok === 'OK') {
return candidate
}
} catch {
// try the next candidate
}
}
const err = new Error(
'Hermes is not installed on the remote host (could not find a `hermes` executable). ' +
'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' +
'— or set the Hermes path explicitly in the SSH connection settings.'
)
err.kind = 'hermes-not-found'
throw err
}
// ---------------------------------------------------------------------------
// Remote platform gate
// ---------------------------------------------------------------------------
async function probeRemotePlatform(ssh) {
const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
const osName = (out[0] || '').trim()
const arch = (out[1] || '').trim()
if (!SUPPORTED_REMOTE_OS.has(osName)) {
const err = new Error(
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux and macOS remote hosts only.`
)
err.kind = 'unsupported-platform'
throw err
}
return { os: osName, arch }
}
// The HERMES_HOME the remote dashboard will use (explicit env wins, else
// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same
// state store; best-effort (a probe failure falls back to '~/.hermes').
async function probeRemoteHermesHome(ssh) {
try {
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
return out || '~/.hermes'
} catch {
return '~/.hermes'
}
}
// ---------------------------------------------------------------------------
// Lockfile (lives on the REMOTE, read/written via ssh.exec)
// ---------------------------------------------------------------------------
async function readLockfile(ssh, clientId) {
const path = lockfilePath(clientId)
let raw
try {
raw = await ssh.exec(`cat "$(eval echo ${shq(path)})" 2>/dev/null || true`)
} catch {
return null
}
const text = String(raw || '').trim()
if (!text) return null
let parsed
try {
parsed = JSON.parse(text)
} catch {
return null
}
if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {
return null
}
return parsed
}
async function writeLockfile(ssh, clientId, lock) {
const path = lockfilePath(clientId)
const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })
await ssh.exec(
`mkdir -p "$(eval echo ${shq(REMOTE_LOCK_DIR)})" && ` +
`printf '%s' ${shq(json)} > "$(eval echo ${shq(path)})"`
)
}
async function removeLockfile(ssh, clientId) {
const path = lockfilePath(clientId)
try {
await ssh.exec(`rm -f "$(eval echo ${shq(path)})"`)
} catch {
// best effort
}
}
// True iff the pid is alive on the remote.
async function remotePidAlive(ssh, pid) {
if (!pid || !Number.isInteger(Number(pid))) return false
try {
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
return out === 'ALIVE'
} catch {
return false
}
}
// A pid is "provably ours" only if its remote cmdline carries our dashboard
// args — never kill a pid we can't positively identify as our dashboard.
async function pidIsOurDashboard(ssh, pid) {
if (!pid) return false
try {
// /proc on Linux; `ps` fallback covers macOS. Tolerate either being absent.
const out = await ssh.exec(
`(cat /proc/${Number(pid)}/cmdline 2>/dev/null | tr '\\0' ' '; ` +
`ps -o command= -p ${Number(pid)} 2>/dev/null) || true`
)
const cmd = String(out || '')
return /hermes\b/.test(cmd) && /dashboard/.test(cmd) && /--isolated/.test(cmd)
} catch {
return false
}
}
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
async function cleanupStale(ssh, clientId, pid) {
if (await pidIsOurDashboard(ssh, pid)) {
try {
await ssh.exec(`kill ${Number(pid)} 2>/dev/null || true`)
} catch {
// best effort
}
}
await removeLockfile(ssh, clientId)
}
// ---------------------------------------------------------------------------
// Spawn a fresh detached dashboard + scrape the readiness line
// ---------------------------------------------------------------------------
// Build the detached spawn command. setsid + </dev/null + redirect-to-log so it
// survives the SSH channel closing; echo $! returns the pid. The token rides as
// a spawn-time env var only — callers MUST redact this command before logging.
function buildSpawnCommand(hermesPath, profile, token) {
// Assembled from parts so the secret env var name is never a literal in one
// place; the value itself is shell-quoted.
const tokenEnvName = ['HERMES', 'DASHBOARD', 'SESSION', 'TOKEN'].join('_')
const envPrefix = `env ${tokenEnvName}=${shq(token)} HERMES_DESKTOP=1`
const hermes = `"$(eval echo ${shq(hermesPath)})"`
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
// --isolated => dedicated loopback dashboard, NOT routed into the host's
// unified machine dashboard. --port 0 => server picks a free port and prints
// HERMES_DASHBOARD_READY port=<n>. --skip-build => never trigger an npm web-UI
// build in this headless SSH bootstrap; if no built dist exists the backend
// fails loudly (which scrapeReadyPort surfaces) instead of hanging on a build.
const dashCmd =
`${envPrefix} ${hermes} ${profileArgs}dashboard --isolated --no-open ` +
`--host 127.0.0.1 --port 0 --skip-build`
return (
`mkdir -p "$(dirname ${logPath})" && ` +
`setsid sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`
)
}
// Scrape the most recent HERMES_DASHBOARD_READY line from the remote log,
// polling until it appears or the timeout fires. Returns the bound port.
//
// We mark the log with a unique sentinel BEFORE spawning so we only read the
// readiness line belonging to THIS spawn, never a stale one from a prior run.
async function scrapeReadyPort(ssh, sentinel, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive } = {}) {
const deadline = Date.now() + timeoutMs
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
while (Date.now() < deadline) {
if (isAlive && !(await isAlive())) {
const err = new Error('Remote dashboard process exited before announcing its port.')
err.kind = 'spawn-failed'
throw err
}
let tail
try {
// Read only the portion AFTER our sentinel so prior runs' READY lines
// can't satisfy us.
tail = await ssh.exec(
`awk ${shq(`/${sentinel}/{seen=1; next} seen{print}`)} ${logPath} 2>/dev/null || true`
)
} catch {
tail = ''
}
const m = READY_RE.exec(String(tail || ''))
if (m) {
return parseInt(m[1], 10)
}
await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS))
}
const err = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)
err.kind = 'ready-timeout'
throw err
}
// Write a unique sentinel into the remote log, then spawn. Returns { pid,
// sentinel }.
async function spawnRemoteDashboard(ssh, { hermesPath, profile, token }) {
const sentinel = `HERMES_SSH_SPAWN_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
await ssh.exec(`mkdir -p "$(dirname ${logPath})" && printf '%s\\n' ${shq(sentinel)} >> ${logPath}`)
const out = await ssh.exec(buildSpawnCommand(hermesPath, profile, token))
const pid = parseInt(String(out || '').trim().split('\n').pop(), 10)
if (!Number.isInteger(pid) || pid <= 0) {
const err = new Error('Failed to launch the remote dashboard (no pid returned).')
err.kind = 'spawn-failed'
throw err
}
return { pid, sentinel }
}
// ---------------------------------------------------------------------------
// connect() — the orchestrator
// ---------------------------------------------------------------------------
// Best-effort forward teardown when a reuse attempt fails mid-flight, so we
// don't leak a forward before respawning. `deps.cancelForward` is optional.
async function cancelForwardSafe(deps, localPort, remotePort) {
if (typeof deps.cancelForward !== 'function') return
try {
await deps.cancelForward(localPort, remotePort)
} catch {
// best effort
}
}
/**
* Establish (or reuse) a remote dashboard and a tunnel to it.
*
* @param {object} deps
* @param {object} deps.ssh an opened SshConnection
* @param {string} [deps.profile] hermes profile to launch
* @param {string} [deps.remoteHermesPath] explicit hermes path override
* @param {string} deps.clientId stable per-client id for the lockfile
* @param {(localPort:number, remotePort:number)=>Promise<void>} deps.forward
* @param {()=>Promise<number>} deps.pickLocalPort
* @param {(baseUrl:string, token:string)=>Promise<void>} deps.waitForHermes
* @param {(baseUrl:string, token:string)=>Promise<boolean>} deps.probeStatus
* authenticated GET /api/status — true iff it returns ok with `token`
* @param {(baseUrl:string, spawnToken:string, opts:object)=>Promise<string>} deps.adoptServedToken
* @param {(msg:string)=>void} [deps.rememberLog] already redaction-wrapped by caller
* @param {number} [deps.readyTimeoutMs]
* @returns {Promise<{baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform}>}
*/
async function connect(deps) {
const {
ssh,
profile = '',
remoteHermesPath = '',
clientId,
forward,
pickLocalPort,
waitForHermes,
probeStatus,
adoptServedToken,
rememberLog = () => {},
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS
} = deps
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
const platform = await probeRemotePlatform(ssh)
log(`remote platform ${platform.os}/${platform.arch}`)
const hermesPath = await locateHermes(ssh, remoteHermesPath)
log(`located hermes at ${hermesPath}`)
// --- Try lockfile reuse --------------------------------------------------
// The reuse credential (`reuseToken`) comes from the client's encrypted
// storage; the lockfile holds only its fingerprint. Reuse requires ALL of:
// schema parses (readLockfile enforces), pid alive, the stored token's
// fingerprint matches the lockfile, AND an authenticated /api/status probe
// through the tunnel succeeds with that token. PID liveness alone is not
// sufficient (recycled pid, wedged dashboard, rotated token).
const reuseToken = deps.reuseToken || ''
const lock = await readLockfile(ssh, clientId)
if (lock && lock.pid && lock.port) {
const pidAlive = await remotePidAlive(ssh, lock.pid)
const fpMatch = Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken)
// A lockfile written by an incompatible protocol (older/newer reuse
// contract) is not safe to reattach to — treat it like a stale lock and
// respawn. Absent protocolVersion (pre-versioning) also fails closed.
const protoMatch = lock.protocolVersion === PROTOCOL_VERSION
if (pidAlive && fpMatch && protoMatch) {
const localPort = await pickLocalPort()
try {
await forward(localPort, lock.port)
const baseUrl = `http://127.0.0.1:${localPort}`
const ok = await probeStatus(baseUrl, reuseToken)
if (ok) {
// Re-run served-token adoption so a token the dashboard rotated since
// the lockfile was written is picked up; the remote pid is alive so
// a served-token mismatch is benign (our backend regenerated it).
const token = await adoptServedToken(baseUrl, reuseToken, {
// pidAlive was checked above as the reuse gate; reuse it for the
// foreign-backend guard rather than asserting () => true.
childAlive: () => pidAlive,
label: 'reused remote dashboard'
})
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
const tokenFingerprint = fingerprintToken(token)
if (tokenFingerprint !== lock.tokenFingerprint) {
await writeLockfile(ssh, clientId, { ...lock, tokenFingerprint })
}
return {
baseUrl,
token,
tokenFingerprint,
remotePort: lock.port,
localPort,
pid: lock.pid,
reused: true,
platform
}
}
log('reuse /api/status probe did not authenticate; spawning fresh')
await cancelForwardSafe(deps, localPort, lock.port)
} catch (error) {
log(`reuse probe failed (${error.message}); spawning fresh`)
await cancelForwardSafe(deps, localPort, lock.port)
}
} else {
log(`lockfile present but not reusable (pidAlive=${pidAlive}, fpMatch=${fpMatch}, protoMatch=${protoMatch})`)
}
// Any failed condition → cleanup (kill only if provably ours) and respawn.
await cleanupStale(ssh, clientId, lock.pid)
}
// --- Spawn fresh ---------------------------------------------------------
const spawnToken = mintToken()
const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath, profile, token: spawnToken })
log(`spawned remote dashboard pid=${pid}`)
const remotePort = await scrapeReadyPort(ssh, sentinel, {
timeoutMs: readyTimeoutMs,
isAlive: () => remotePidAlive(ssh, pid)
})
log(`remote dashboard bound port ${remotePort}`)
const localPort = await pickLocalPort()
await forward(localPort, remotePort)
const baseUrl = `http://127.0.0.1:${localPort}`
await waitForHermes(baseUrl, spawnToken)
// Served-token adoption against the TUNNELED baseUrl — the served token is
// what /api/ws will accept; the minted token is only the spawn credential.
// Confirm the remote pid we just spawned is still alive at adoption time and
// pass that into the foreign-backend guard — if the dashboard exited between
// readiness and adoption, a served token from a DIFFERENT backend now bound to
// the same forwarded port must be rejected, not silently adopted.
const spawnedAlive = await remotePidAlive(ssh, pid)
const token = await adoptServedToken(baseUrl, spawnToken, {
childAlive: () => spawnedAlive,
label: 'remote dashboard'
})
const tokenFingerprint = fingerprintToken(token)
const hermesHome = await probeRemoteHermesHome(ssh)
await writeLockfile(ssh, clientId, {
pid,
port: remotePort,
profile,
hermesPath,
hermesHome,
tokenFingerprint,
protocolVersion: PROTOCOL_VERSION,
startedAt: new Date().toISOString()
})
return { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused: false, platform }
}
module.exports = {
DEFAULT_READY_TIMEOUT_MS,
LOCKFILE_SCHEMA_VERSION,
PROTOCOL_VERSION,
READY_RE,
REMOTE_LOCK_DIR,
REMOTE_LOG,
SUPPORTED_REMOTE_OS,
buildSpawnCommand,
cleanupStale,
clientLockId,
connect,
fingerprintToken,
locateHermes,
lockfilePath,
mintToken,
pidIsOurDashboard,
probeRemotePlatform,
probeRemoteHermesHome,
readLockfile,
remotePidAlive,
removeLockfile,
scrapeReadyPort,
shq,
spawnRemoteDashboard,
writeLockfile
}
@@ -1,384 +0,0 @@
/**
* Tests for electron/remote-lifecycle.cjs.
*
* Run with: node --test electron/remote-lifecycle.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Electron-free: a fake SshConnection with scripted exec() responses drives the
* locate/probe/lockfile/spawn/scrape/connect paths. No real ssh, no real
* dashboard.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
LOCKFILE_SCHEMA_VERSION,
PROTOCOL_VERSION,
buildSpawnCommand,
cleanupStale,
clientLockId,
connect,
fingerprintToken,
locateHermes,
lockfilePath,
pidIsOurDashboard,
probeRemotePlatform,
readLockfile,
remotePidAlive,
scrapeReadyPort,
spawnRemoteDashboard,
writeLockfile
} = require('./remote-lifecycle.cjs')
// A fake SshConnection whose exec() is matched against an ordered list of
// [regex|fn, response|fn] rules. First match wins; unmatched commands return ''.
function fakeSsh(rules = []) {
const calls = []
return {
calls,
async exec(cmd) {
calls.push(cmd)
for (const [matcher, resp] of rules) {
const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd)
if (hit) {
const out = typeof resp === 'function' ? resp(cmd) : resp
if (out instanceof Error) throw out
return out
}
}
return ''
}
}
}
// --- locateHermes -----------------------------------------------------------
test('locateHermes prefers the explicit profile path when executable', async () => {
const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']])
assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes')
})
test('locateHermes falls back to the login-shell command -v probe', async () => {
const ssh = fakeSsh([
[/command -v hermes/, '/home/u/.local/bin/hermes\n'],
[/\[ -x .*\.local\/bin\/hermes/, 'OK']
])
assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes')
})
test('locateHermes tries the conventional venv path last', async () => {
const ssh = fakeSsh([[/\[ -x .*venv\/bin\/hermes/, 'OK']])
assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes')
})
test('locateHermes throws a hermes-not-found error with an install hint', async () => {
const ssh = fakeSsh([]) // nothing is executable
await assert.rejects(() => locateHermes(ssh, ''), err => {
assert.equal(err.kind, 'hermes-not-found')
assert.match(err.message, /install/i)
return true
})
})
test('locateHermes uses a login shell for the command -v probe', async () => {
const ssh = fakeSsh([[/command -v hermes/, '/x/hermes'], [/\[ -x/, 'OK']])
await locateHermes(ssh, '')
assert.ok(ssh.calls.some(c => /bash -lc/.test(c)), 'must probe in a login shell (PATH pitfall)')
})
// --- probeRemotePlatform ----------------------------------------------------
test('probeRemotePlatform accepts Linux and macOS', async () => {
assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), {
os: 'Linux',
arch: 'x86_64'
})
assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\narm64']])), {
os: 'Darwin',
arch: 'arm64'
})
})
test('probeRemotePlatform rejects unsupported remote platforms', async () => {
await assert.rejects(() => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\nx86_64']])), err => {
assert.equal(err.kind, 'unsupported-platform')
return true
})
})
// --- lockfile ---------------------------------------------------------------
test('clientLockId sanitizes and bounds the id', () => {
assert.equal(clientLockId('a/b c'), 'a_b_c')
assert.equal(clientLockId(''), 'default')
assert.ok(clientLockId('x'.repeat(200)).length <= 64)
})
test('lockfilePath nests under the remote desktop-ssh dir', () => {
assert.match(lockfilePath('client1'), /\.hermes\/desktop-ssh\/client1\.lock\.json$/)
})
test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => {
assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), 'c'), null)
assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), 'c'), null)
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), 'c'), null)
const good = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 1, port: 2 }
assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), 'c'), good)
})
test('writeLockfile mkdir -ps and stamps the schema version', async () => {
const ssh = fakeSsh([])
await writeLockfile(ssh, 'c', { pid: 7, port: 9 })
const cmd = ssh.calls.join('\n')
assert.match(cmd, /mkdir -p/)
assert.match(cmd, new RegExp(`"schemaVersion":${LOCKFILE_SCHEMA_VERSION}`))
})
test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => {
assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true)
assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false)
assert.equal(await remotePidAlive(fakeSsh([]), null), false)
})
test('pidIsOurDashboard requires hermes + dashboard + --isolated in the cmdline', async () => {
const ours = 'env H=1 /x/hermes dashboard --isolated --no-open --host 127.0.0.1 --port 0'
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, ours]]), 5), true)
// a different hermes process (gateway) is NOT ours to kill
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']]), 5), false)
// an unrelated process is never ours
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, 'sshd: u@pts/0']]), 5), false)
})
test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => {
// not ours → no kill, lockfile removed
const notOurs = fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']])
await cleanupStale(notOurs, 'c', 5)
assert.ok(!notOurs.calls.some(c => /kill 5\b/.test(c)), 'must not kill a pid that is not our dashboard')
assert.ok(notOurs.calls.some(c => /rm -f/.test(c)))
// ours → killed + lockfile removed
const ours = fakeSsh([[/cmdline|ps -o/, '/x/hermes dashboard --isolated']])
await cleanupStale(ours, 'c', 9)
assert.ok(ours.calls.some(c => /kill 9\b/.test(c)))
assert.ok(ours.calls.some(c => /rm -f/.test(c)))
})
// --- spawn command + readiness scrape --------------------------------------
test('buildSpawnCommand uses --isolated --port 0 --no-open and a detached setsid', () => {
const cmd = buildSpawnCommand('/x/hermes', 'work', 'tok_secret_value')
assert.match(cmd, /--isolated/)
assert.match(cmd, /--no-open/)
assert.match(cmd, /--host 127\.0\.0\.1 --port 0/)
assert.match(cmd, /--skip-build/)
assert.match(cmd, /--profile/)
assert.match(cmd, /work/)
assert.match(cmd, /setsid/)
assert.match(cmd, /<\/dev\/null/)
assert.match(cmd, /echo \$!/)
})
test('spawnRemoteDashboard writes a sentinel then returns the echoed pid', async () => {
const ssh = fakeSsh([
[/printf '%s\\\\n'/, ''], // sentinel write
[/setsid/, '4242\n'] // spawn → pid
])
const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk' })
assert.equal(pid, 4242)
assert.match(sentinel, /^HERMES_SSH_SPAWN_/)
})
test('spawnRemoteDashboard rejects when no pid is returned', async () => {
const ssh = fakeSsh([[/setsid/, 'not-a-pid']])
await assert.rejects(() => spawnRemoteDashboard(ssh, { hermesPath: '/x', profile: '', token: 't' }), err => {
assert.equal(err.kind, 'spawn-failed')
return true
})
})
test('scrapeReadyPort parses the READY line that follows the sentinel', async () => {
const ssh = fakeSsh([[/awk/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']])
const port = await scrapeReadyPort(ssh, 'SENT', { timeoutMs: 1000 })
assert.equal(port, 51234)
})
test('scrapeReadyPort times out and reports a dead spawn', async () => {
// never emits a READY line
const ssh = fakeSsh([[/awk/, 'still starting...']])
await assert.rejects(() => scrapeReadyPort(ssh, 'SENT', { timeoutMs: 60 }), err => {
assert.equal(err.kind, 'ready-timeout')
return true
})
// dead process before announcement → spawn-failed
await assert.rejects(
() => scrapeReadyPort(fakeSsh([[/awk/, '']]), 'SENT', { timeoutMs: 1000, isAlive: async () => false }),
err => {
assert.equal(err.kind, 'spawn-failed')
return true
}
)
})
// --- connect() orchestration ------------------------------------------------
function connectDeps(ssh, over = {}) {
return {
ssh,
clientId: 'client1',
profile: '',
forward: async () => {},
cancelForward: async () => {},
pickLocalPort: async () => 50001,
waitForHermes: async () => {},
probeStatus: async () => true,
adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token',
rememberLog: () => {},
readyTimeoutMs: 2000,
...over
}
}
test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => {
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, ''], // no lockfile
[/printf '%s\\\\n'/, ''],
[/setsid/, '777\n'],
[/kill -0 777/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=51999\n']
])
const result = await connect(connectDeps(ssh, { adoptServedToken: async () => 'the-served-token' }))
assert.equal(result.reused, false)
assert.equal(result.remotePort, 51999)
assert.equal(result.localPort, 50001)
assert.equal(result.pid, 777)
assert.equal(result.token, 'the-served-token')
assert.equal(result.baseUrl, 'http://127.0.0.1:50001')
assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token'))
})
test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => {
const reuseToken = 'stored-token'
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)
}
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0/, 'ALIVE']
])
const result = await connect(
connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })
)
assert.equal(result.reused, true)
assert.equal(result.pid, 333)
assert.equal(result.remotePort, 40000)
// never spawned
assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard')
})
test('connect() respawns when the lockfile protocolVersion is incompatible', async () => {
const reuseToken = 'stored-token'
// alive pid, matching fingerprint, but a protocolVersion we no longer accept
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION + 99,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)
}
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0 333/, 'ALIVE'],
[/cmdline|ps -o/, ''], // not provably ours → not killed, lockfile dropped
[/setsid/, '901\n'],
[/kill -0 901/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=44100\n']
])
const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' }))
assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach')
assert.equal(result.pid, 901)
})
test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => {
const writes = []
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, ''], // no lockfile
[/HERMES_HOME/, '/home/jonny/.hermes\n'], // probeRemoteHermesHome
[/printf '%s\\\\n'/, ''],
[/setsid/, '700\n'],
[/kill -0 700/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=45500\n'],
[/printf '%s' '/, c => { writes.push(c); return '' }] // writeLockfile printf
])
await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' }))
const lockWrite = writes.find(c => c.includes('schemaVersion')) || ''
assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`))
assert.match(lockWrite, /"hermesHome":"\/home\/jonny\/\.hermes"/)
})
test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => {
const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 333, port: 40000, tokenFingerprint: fingerprintToken('t') }
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0 333/, 'DEAD'],
[/cmdline|ps -o/, ''], // not provably ours
[/setsid/, '888\n'],
[/kill -0 888/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=42000\n']
])
const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' }))
assert.equal(result.reused, false)
assert.equal(result.pid, 888)
assert.equal(result.remotePort, 42000)
})
test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => {
const reuseToken = 'stored'
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)
}
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0/, 'ALIVE'],
[/cmdline|ps -o/, '/x/hermes dashboard --isolated'], // ours → may kill
[/setsid/, '999\n'],
[/kill -0 999/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=43000\n']
])
// probeStatus FAILS for the wedged dashboard → must respawn
const result = await connect(
connectDeps(ssh, { reuseToken, probeStatus: async () => false, adoptServedToken: async () => 'fresh' })
)
assert.equal(result.reused, false)
assert.equal(result.pid, 999)
assert.equal(result.remotePort, 43000)
})
test('connect() aborts on an unsupported remote platform before doing anything else', async () => {
const ssh = fakeSsh([[/uname/, 'SunOS\nsun4v']])
await assert.rejects(() => connect(connectDeps(ssh)), err => {
assert.equal(err.kind, 'unsupported-platform')
return true
})
assert.ok(!ssh.calls.some(c => /setsid/.test(c)))
})
-137
View File
@@ -1,137 +0,0 @@
/**
* ssh-config.cjs
*
* Pure, electron-free helpers for reading the user's OpenSSH client config:
* - parseSshConfigHosts(text): extract concrete `Host` aliases for the
* settings UI's host suggestions, filtering wildcard/negated patterns.
* - collectSshConfigHosts(rootPath, deps): read ~/.ssh/config and follow
* `Include` directives (read-only — we NEVER write that file).
* - parseSshGOutput(text): parse `ssh -G <host>` key/value output into the
* resolved hostname/user/port/identityfile for display + normalization.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test`. main.cjs requires this and wires the fs + `ssh -G` exec in.
*/
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
// Pull concrete host aliases out of an ssh_config body. A `Host` line can list
// several patterns; we keep only literal aliases (no `*`, `?`, or `!` negation)
// since those are the ones a user can actually connect to by name.
function parseSshConfigHosts(text) {
const hosts = []
const seen = new Set()
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const m = /^host\s+(.+)$/i.exec(line)
if (!m) continue
for (const pattern of m[1].split(/\s+/)) {
if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) {
continue
}
if (!seen.has(pattern)) {
seen.add(pattern)
hosts.push(pattern)
}
}
}
return hosts
}
// Extract `Include` paths from an ssh_config body (relative paths resolve under
// ~/.ssh). Globs are expanded by the caller's fs deps when supported; here we
// just return the raw tokens for the collector to resolve.
function parseSshConfigIncludes(text) {
const includes = []
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const m = /^include\s+(.+)$/i.exec(line)
if (!m) continue
for (const token of m[1].split(/\s+/)) {
if (token) includes.push(token)
}
}
return includes
}
// Read ~/.ssh/config and any files it Includes, returning a de-duplicated list
// of concrete host aliases. Read-only; bounded include depth to avoid cycles.
// `deps` injects { readFile, homeDir, globSync } for tests.
function collectSshConfigHosts(rootPath, deps = {}) {
const readFile =
deps.readFile ||
(p => {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
})
const homeDir = deps.homeDir || os.homedir()
const root = rootPath || path.join(homeDir, '.ssh', 'config')
const sshDir = path.join(homeDir, '.ssh')
const out = []
const seen = new Set()
const visited = new Set()
const resolveIncludePath = token => {
if (token.startsWith('~/')) return path.join(homeDir, token.slice(2))
if (path.isAbsolute(token)) return token
return path.join(sshDir, token)
}
const walk = (filePath, depth) => {
if (depth > 8 || visited.has(filePath)) return
visited.add(filePath)
const text = readFile(filePath)
if (text == null) return
for (const host of parseSshConfigHosts(text)) {
if (!seen.has(host)) {
seen.add(host)
out.push(host)
}
}
for (const token of parseSshConfigIncludes(text)) {
const target = resolveIncludePath(token)
// Optional glob expansion (token may contain * — e.g. config.d/*).
const expanded = deps.globSync ? deps.globSync(target) : [target]
for (const p of expanded) {
walk(p, depth + 1)
}
}
}
walk(root, 0)
return out
}
// Parse `ssh -G <host>` output. Keys are lowercased by ssh; we surface the ones
// the settings UI cares about. Returns { hostname, user, port, identityFile }.
function parseSshGOutput(text) {
const out = { hostname: null, user: null, port: null, identityFile: null }
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line) continue
const sp = line.indexOf(' ')
if (sp === -1) continue
const key = line.slice(0, sp).toLowerCase()
const value = line.slice(sp + 1).trim()
if (key === 'hostname' && !out.hostname) out.hostname = value
else if (key === 'user' && !out.user) out.user = value
else if (key === 'port' && !out.port) out.port = Number.parseInt(value, 10) || null
else if (key === 'identityfile' && !out.identityFile) out.identityFile = value
}
return out
}
module.exports = {
collectSshConfigHosts,
parseSshConfigHosts,
parseSshConfigIncludes,
parseSshGOutput
}
-107
View File
@@ -1,107 +0,0 @@
/**
* Tests for electron/ssh-config.cjs.
*
* Run with: node --test electron/ssh-config.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
collectSshConfigHosts,
parseSshConfigHosts,
parseSshConfigIncludes,
parseSshGOutput
} = require('./ssh-config.cjs')
test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => {
const cfg = [
'Host mac-mini',
' HostName 10.0.0.5',
'Host *.internal prod !staging glob*',
'Host alpha beta',
'# Host commented-out',
'host lower-case'
].join('\n')
assert.deepEqual(parseSshConfigHosts(cfg), ['mac-mini', 'prod', 'alpha', 'beta', 'lower-case'])
})
test('parseSshConfigHosts de-duplicates', () => {
assert.deepEqual(parseSshConfigHosts('Host box\nHost box\nHost box other'), ['box', 'other'])
})
test('parseSshConfigIncludes extracts include tokens', () => {
const cfg = 'Include ~/.ssh/config.d/*\nInclude work_hosts personal_hosts\n# Include ignored'
assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts'])
})
test('collectSshConfigHosts follows Include directives (read-only)', () => {
const files = {
'/home/u/.ssh/config': 'Host main\nInclude work\nInclude ~/abs_inc',
'/home/u/.ssh/work': 'Host work-box\nInclude nested',
'/home/u/.ssh/nested': 'Host deep',
'/home/u/abs_inc': 'Host home-abs'
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
})
assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort())
})
test('collectSshConfigHosts tolerates a missing config file', () => {
assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), [])
})
test('collectSshConfigHosts does not loop on a self-include cycle', () => {
const files = {
'/home/u/.ssh/config': 'Host a\nInclude loop',
'/home/u/.ssh/loop': 'Host b\nInclude config' // points back at config
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
})
assert.deepEqual(hosts.sort(), ['a', 'b'])
})
test('collectSshConfigHosts expands globbed includes via injected globSync', () => {
const files = {
'/home/u/.ssh/config': 'Host root\nInclude config.d/*',
'/home/u/.ssh/config.d/10-work': 'Host work',
'/home/u/.ssh/config.d/20-home': 'Host home'
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null,
globSync: pattern =>
pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]
})
assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort())
})
test('parseSshGOutput pulls hostname/user/port/identityfile', () => {
const out = [
'host mac-mini',
'hostname 10.0.0.5',
'user jonny',
'port 2222',
'identityfile ~/.ssh/id_ed25519',
'forwardagent no'
].join('\n')
assert.deepEqual(parseSshGOutput(out), {
hostname: '10.0.0.5',
user: 'jonny',
port: 2222,
identityFile: '~/.ssh/id_ed25519'
})
})
test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => {
const out = 'hostname box\nidentityfile ~/.ssh/a\nidentityfile ~/.ssh/b'
const parsed = parseSshGOutput(out)
assert.equal(parsed.identityFile, '~/.ssh/a')
assert.equal(parsed.user, null)
assert.equal(parsed.port, null)
})
-514
View File
@@ -1,514 +0,0 @@
/**
* ssh-connection.cjs
*
* Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH
* remote mode. Uses the system `ssh` client (not a JS SSH library) so it
* inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys
* for free — the same rationale as tools/environments/ssh.py.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as connection-config.cjs / dashboard-token.cjs.
* main.cjs requires this and wires it into the electron-coupled lifecycle.
*
* Conventions mirrored from tools/environments/ssh.py:
* - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused
* across exec/forward operations.
* - Hashed control-socket filename under a short tmpdir to stay under the
* 104-byte sun_path limit macOS enforces on Unix domain sockets
* (ssh.py:53-67 rationale applies verbatim).
* - BatchMode=yes for every programmatic invocation — a spawned ssh must
* never hang on an interactive prompt (passphrase / 2FA). If auth needs
* interactivity we fail fast and tell the user to load the key into their
* agent.
*
* Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log
* the fingerprint), never `no`. A host-key *change* fails closed with the
* verbatim OpenSSH error surfaced to the UI.
*
* Every operation is raced against a hard timeout. A half-open TCP connection
* after laptop sleep can leave ssh hanging indefinitely rather than erroring;
* timeout is treated as connection-dead so the caller does a full reconnect
* rather than retrying in place (VS Code's agent host does the same).
*/
const { spawn } = require('node:child_process')
const crypto = require('node:crypto')
const net = require('node:net')
const os = require('node:os')
const path = require('node:path')
const fs = require('node:fs')
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
const DEFAULT_EXEC_TIMEOUT_MS = 20_000
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
const CONTROL_PERSIST_SECONDS = 300
// ---------------------------------------------------------------------------
// Token / secret redaction
// ---------------------------------------------------------------------------
// Every lifecycle log line in SSH mode passes through this before it reaches
// rememberLog/desktop.log. The step-3 spawn command line embeds the session
// token (HERMES_DASHBOARD_SESSION_TOKEN=<token>); it must never be logged raw.
// We also scrub the URL/header carriers the dashboard protocol uses so a
// forwarded base URL or a copied curl line can't leak a credential.
//
// Patterns scrubbed (case-insensitive where it matters):
// - HERMES_DASHBOARD_SESSION_TOKEN=<value>
// - X-Hermes-Session-Token: <value> / X-Hermes-Session-Token=<value>
// - Authorization: Bearer <value>
// - ?token=<value> / &token=<value> (the WS auth param)
// - ?ticket=<value> / &ticket=<value> (the OAuth ws-ticket param)
const _REDACTIONS = [
[/(HERMES_DASHBOARD_SESSION_TOKEN=)(\S+)/g, '$1<redacted>'],
[/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1<redacted>'],
[/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1<redacted>'],
[/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1<redacted>']
]
function redactSecrets(text) {
let out = String(text == null ? '' : text)
for (const [re, repl] of _REDACTIONS) {
out = out.replace(re, repl)
}
return out
}
// ---------------------------------------------------------------------------
// Control-socket path
// ---------------------------------------------------------------------------
// Hash user@host:port to a short, stable, filesystem-safe socket id. Stable
// across reconnects so ControlMaster reuse works; short so the full path stays
// under sun_path's 104-byte limit.
//
// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the
// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a
// TEMPORARY listener at `<ControlPath>.<16 random chars>` (a 17-byte suffix)
// while establishing the master — so a path that itself fits 104 still overflows
// at bind time with `unix_listener: path "…" too long`. We root under a short
// per-user base (`~/.hermes/desktop-ssh`) so even worst case
// (~/.hermes/desktop-ssh = ~33 on macOS + 1 + 16 + 5 + 17 ≈ 72) stays clear.
// Windows has no AF_UNIX sun_path limit, so os.tmpdir() is fine there. ssh.py
// uses gettempdir() and would hit this on macOS — deliberate divergence.
function controlSocketPath(user, host, port, baseDir) {
const dir = baseDir || defaultControlDir()
const id = crypto.createHash('sha256').update(`${user}@${host}:${port}`).digest('hex').slice(0, 16)
return path.join(dir, `${id}.sock`)
}
function defaultControlDir() {
// Windows: AF_UNIX has no sun_path length limit → the per-user temp dir is
// fine. POSIX (macOS/Linux): a SHORT, PER-USER base — ~/.hermes/desktop-ssh —
// stays under the 104-byte socket limit AND avoids a world-shared /tmp dir
// (no foreign-owned-dir or symlink-hijack surface). Created 0700 in open().
if (process.platform === 'win32') {
return path.join(os.tmpdir(), 'hermes-desktop-ssh')
}
return path.join(os.homedir(), '.hermes', 'desktop-ssh')
}
// ---------------------------------------------------------------------------
// Command construction (pure — the unit tests exercise these directly)
// ---------------------------------------------------------------------------
function baseSshOptions(controlPath, connectTimeoutMs) {
const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000))
return [
'-o', `ControlPath=${controlPath}`,
'-o', 'ControlMaster=auto',
'-o', `ControlPersist=${CONTROL_PERSIST_SECONDS}`,
'-o', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=accept-new',
'-o', `ConnectTimeout=${connectSecs}`
]
}
// Per-host args shared by exec, the master open, and forward control commands:
// non-default port and explicit identity file.
function hostArgs({ port, keyPath }) {
const args = []
if (port && Number(port) !== 22) {
args.push('-p', String(port))
}
if (keyPath) {
args.push('-i', keyPath)
}
return args
}
function target(user, host) {
return user ? `${user}@${host}` : host
}
// `ssh <opts> <host> <remoteCommand>` — one-shot over the control connection.
function buildExecArgs(conn, remoteCommand, connectTimeoutMs) {
return [
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host),
remoteCommand
]
}
// `ssh -O <op> <opts> <host>` — control-command against the running master
// (check / forward / cancel / exit). -O commands don't take a remote command.
function buildControlArgs(conn, op, extra = [], connectTimeoutMs) {
return [
'-O', op,
...extra,
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host)
]
}
// Open the master explicitly: `-M -N -f` puts ssh into the background once the
// master is up, so the spawn resolves when the connection is established (or
// fails fast under BatchMode if auth is non-interactive-only).
function buildMasterArgs(conn, connectTimeoutMs) {
return [
'-M', '-N', '-f',
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host)
]
}
// Interactive `ssh -tt` for the INTERIM remote terminal (component 5, SSH mode
// only). Reuses the existing ControlMaster socket so NO new auth handshake
// happens — the master is already open, so this attaches instantly and never
// prompts (BatchMode stays safe here for that reason). `-tt` forces a PTY even
// though our stdio is a node-pty, so the remote sees a real terminal.
//
// When a remoteCwd is given we cd into it (best-effort) then exec the user's
// login shell so the prompt/rc files load; an unreadable cwd falls back to
// $HOME rather than failing the session.
//
// NOTE (tracked): this is the interim path until the dashboard /api/terminal
// WebSocket lands (specs/desktop-remote-terminal.md). Once that ships, the
// terminal rides the tunnel like every other socket and cwd-follows-session
// behavior becomes uniform; delete this path then.
function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs) {
const args = [
'-tt',
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host)
]
const cwd = String(remoteCwd || '').trim()
if (cwd) {
// cd then exec a login shell; quote the path; tolerate a missing dir.
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
} else {
args.push('exec "$SHELL" -l')
}
return args
}
// Local forward spec for `-O forward -L <local>:<remoteHost>:<remotePort>`.
// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does
// not re-expose the remote dashboard to the client's LAN.
function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {
return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`
}
// ---------------------------------------------------------------------------
// Error classification — distinct, actionable messages for the UI
// ---------------------------------------------------------------------------
const SSH_ERROR = {
UNREACHABLE: 'unreachable',
AUTH_FAILED: 'auth-failed',
HOST_KEY_CHANGED: 'host-key-changed',
TIMEOUT: 'timeout',
UNKNOWN: 'unknown'
}
// Map raw ssh stderr to a stable error kind. Order matters: the host-key-change
// banner also contains "WARNING"/"Offending", check it before generic auth.
function classifySshError(stderr) {
const text = String(stderr || '')
if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(text)) {
return SSH_ERROR.HOST_KEY_CHANGED
}
if (/Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(text)) {
return SSH_ERROR.AUTH_FAILED
}
if (/Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test(text)) {
return SSH_ERROR.UNREACHABLE
}
return SSH_ERROR.UNKNOWN
}
function sshErrorMessage(kind, conn, stderr) {
const host = target(conn.user, conn.host)
switch (kind) {
case SSH_ERROR.HOST_KEY_CHANGED:
return (
`The host key for ${host} has CHANGED since you last connected. ` +
`This could be a man-in-the-middle attack, or the server was reinstalled. ` +
`SSH refused to connect. Verify the change is expected, then remove the old key ` +
`with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}`
)
case SSH_ERROR.AUTH_FAILED:
return (
`SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` +
`(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` +
`ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` +
`~/.ssh/config. Original error: ${String(stderr || '').trim()}`
)
case SSH_ERROR.UNREACHABLE:
return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}`
case SSH_ERROR.TIMEOUT:
return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.`
default:
return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}`
}
}
// ---------------------------------------------------------------------------
// Spawn helper — runs an ssh invocation, races it against a hard timeout
// ---------------------------------------------------------------------------
// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the
// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests.
function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore' } = {}) {
return new Promise((resolve, reject) => {
let child
try {
child = spawnFn('ssh', args, { stdio: [stdin === 'ignore' ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
} catch (error) {
reject(error)
return
}
let stdout = ''
let stderr = ''
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
try {
child.kill('SIGKILL')
} catch {
// already gone
}
const err = new Error(`ssh timed out after ${timeoutMs}ms`)
err.kind = SSH_ERROR.TIMEOUT
reject(err)
}, timeoutMs)
child.stdout?.on('data', d => {
stdout += d.toString()
})
child.stderr?.on('data', d => {
stderr += d.toString()
})
child.on('error', error => {
if (settled) return
settled = true
clearTimeout(timer)
reject(error)
})
child.on('close', code => {
if (settled) return
settled = true
clearTimeout(timer)
resolve({ code, stdout, stderr })
})
})
}
// ---------------------------------------------------------------------------
// SshConnection — the public manager
// ---------------------------------------------------------------------------
class SshConnection {
/**
* @param {{host:string, user?:string, port?:number, keyPath?:string}} cfg
* @param {{ spawnFn?, rememberLog?, controlDir?, connectTimeoutMs?, execTimeoutMs?, forwardTimeoutMs? }} [opts]
*/
constructor(cfg, opts = {}) {
if (!cfg || !cfg.host) {
throw new Error('SshConnection requires a host.')
}
this.host = cfg.host
this.user = cfg.user || ''
this.port = cfg.port ? Number(cfg.port) : 22
this.keyPath = cfg.keyPath || ''
this.controlPath = controlSocketPath(this.user, this.host, this.port, opts.controlDir)
this._spawnFn = opts.spawnFn || spawn
this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {}
this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS
this._opened = false
}
// Lifecycle logging — ALWAYS through redaction.
_logLine(msg) {
this._log(redactSecrets(`[ssh] ${msg}`))
}
// Throw a classified, UI-ready error from an ssh result/exception.
_fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) {
if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) {
const err = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this))
err.kind = SSH_ERROR.TIMEOUT
return err
}
const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || ''
const kind = stderr ? classifySshError(stderr) : fallbackKind
const err = new Error(sshErrorMessage(kind, this, stderr))
err.kind = kind
return err
}
// Open the persistent ControlMaster. Idempotent: if a master socket is
// already alive (`-O check` succeeds), this is a no-op.
async open() {
if (await this.isAlive()) {
this._opened = true
return
}
// Ensure the control-socket directory exists — OpenSSH will not create
// intermediate dirs for ControlPath, so a fresh box (no prior hermes-ssh
// socket dir under $TMPDIR) would otherwise fail before the first connect.
// 0o700: the socket grants command execution on the master; keep it private.
try {
fs.mkdirSync(path.dirname(this.controlPath), { recursive: true, mode: 0o700 })
} catch {
// best effort — a pre-existing dir or a races-with-another-conn mkdir is fine
}
const args = buildMasterArgs(this, this._connectTimeoutMs)
this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)
let result
try {
result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {
throw this._fail(error, SSH_ERROR.UNREACHABLE)
}
if (result.code !== 0) {
throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)
}
this._opened = true
this._logLine('control master established')
}
// `-O check` against the master socket. True iff the master is alive.
async isAlive() {
const args = buildControlArgs(this, 'check', [], this._connectTimeoutMs)
try {
const result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
return result.code === 0
} catch {
return false
}
}
// One-shot remote command over the control connection. Resolves the trimmed
// stdout; rejects with a classified error on non-zero exit or timeout.
async exec(remoteCommand, { timeoutMs } = {}) {
const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)
let result
try {
result = await runSsh(args, { timeoutMs: timeoutMs ?? this._execTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {
throw this._fail(error)
}
if (result.code !== 0) {
throw this._fail(result.stderr)
}
return result.stdout
}
// Establish a local→remote forward against the running master.
// 127.0.0.1:<localPort> → <remoteHost>:<remotePort>.
async forward(localPort, remotePort, remoteHost = '127.0.0.1') {
const spec = forwardSpec(localPort, remotePort, remoteHost)
const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs)
this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`)
let result
try {
result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {
throw this._fail(error)
}
if (result.code !== 0) {
throw this._fail(result.stderr)
}
}
// Cancel a previously-established forward. Best-effort: a failure here is
// logged but not thrown (the master close tears everything down anyway).
async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') {
const spec = forwardSpec(localPort, remotePort, remoteHost)
const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs)
try {
await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
} catch (error) {
this._logLine(`cancelForward failed (ignored): ${error.message}`)
}
}
// Tear down the master. Best-effort; never throws.
async close() {
if (!this._opened) return
const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs)
try {
await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
this._logLine('control master closed')
} catch (error) {
this._logLine(`close failed (ignored): ${error.message}`)
} finally {
this._opened = false
}
}
}
// ---------------------------------------------------------------------------
// Free local port — for the tunnel's local end. Bind 127.0.0.1:0, read the
// kernel-assigned port, release. There is a benign TOCTOU window between
// release and the forward grabbing it; the forward failing is caught upstream
// and retried with a fresh port.
// ---------------------------------------------------------------------------
function pickLocalPort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.unref()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
server.close(() => resolve(port))
})
})
}
module.exports = {
CONTROL_PERSIST_SECONDS,
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_EXEC_TIMEOUT_MS,
DEFAULT_FORWARD_TIMEOUT_MS,
SSH_ERROR,
SshConnection,
baseSshOptions,
buildControlArgs,
buildExecArgs,
buildInteractiveSshArgs,
buildMasterArgs,
classifySshError,
controlSocketPath,
forwardSpec,
hostArgs,
pickLocalPort,
redactSecrets,
runSsh,
sshErrorMessage,
target
}
@@ -1,343 +0,0 @@
/**
* Tests for electron/ssh-connection.cjs.
*
* Run with: node --test electron/ssh-connection.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Pure, electron-free: command construction, secret redaction, error
* classification, and the SshConnection lifecycle are exercised with an
* injected fake `spawn` so no real ssh process is started.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
SSH_ERROR,
SshConnection,
baseSshOptions,
buildControlArgs,
buildExecArgs,
buildInteractiveSshArgs,
buildMasterArgs,
classifySshError,
controlSocketPath,
forwardSpec,
hostArgs,
redactSecrets,
sshErrorMessage,
target
} = require('./ssh-connection.cjs')
// --- secret redaction -------------------------------------------------------
test('redactSecrets scrubs the spawn-time session token env var', () => {
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
const out = redactSecrets(line)
assert.ok(!out.includes('abc123deadbeef'))
assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=<redacted>/)
// non-secret env vars are preserved
assert.match(out, /HERMES_DESKTOP=1/)
})
test('redactSecrets scrubs ?token= and ?ticket= URL params', () => {
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=<redacted>/)
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=<redacted>/)
assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=<redacted>/)
assert.ok(!redactSecrets('?token=supersecret').includes('supersecret'))
})
test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => {
assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/)
assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999'))
assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?<redacted>/)
assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888'))
})
test('redactSecrets handles null/undefined and non-secret text untouched', () => {
assert.equal(redactSecrets(null), '')
assert.equal(redactSecrets(undefined), '')
assert.equal(redactSecrets('uname -s -m'), 'uname -s -m')
})
// --- control-socket path ----------------------------------------------------
test('controlSocketPath is stable, short, and host-distinct', () => {
const a = controlSocketPath('me', 'box1', 22, '/tmp/d')
const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d')
const b = controlSocketPath('me', 'box2', 22, '/tmp/d')
assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')
assert.notEqual(a, b, 'different host → different socket')
// 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit
assert.match(a, /\/[0-9a-f]{16}\.sock$/)
})
test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {
// OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>`
// (a 17-byte suffix) while opening the master. The macOS regression was the
// default base under os.tmpdir() (/var/folders/.../T/) pushing 89 → 106 bytes.
// The default base must keep socket + 17-byte suffix comfortably under 104.
const p = controlSocketPath('hermes', 'vbuddy-ubuntu', 22) // no baseDir → default
const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix
assert.ok(
worstCase.length <= 104,
`default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})`
)
// And it must NOT live under the deeply-nested macOS per-user temp dir.
assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS')
})
// --- command construction ---------------------------------------------------
test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => {
const opts = baseSshOptions('/tmp/x.sock', 15000)
const joined = opts.join(' ')
assert.match(joined, /ControlPath=\/tmp\/x\.sock/)
assert.match(joined, /ControlMaster=auto/)
assert.match(joined, /ControlPersist=\d+/)
assert.match(joined, /BatchMode=yes/)
assert.match(joined, /StrictHostKeyChecking=accept-new/)
assert.match(joined, /ConnectTimeout=15/)
assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking')
})
test('hostArgs adds -p only for non-default port and -i only with a key', () => {
assert.deepEqual(hostArgs({ port: 22 }), [])
assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222'])
assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k'])
assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k'])
})
test('target builds user@host or bare host', () => {
assert.equal(target('me', 'box'), 'me@box')
assert.equal(target('', 'box'), 'box')
})
test('buildExecArgs ends with host then the remote command', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildExecArgs(conn, 'command -v hermes', 15000)
assert.equal(args[args.length - 1], 'command -v hermes')
assert.equal(args[args.length - 2], 'me@box')
assert.ok(args.includes('BatchMode=yes'))
})
test('buildControlArgs places -O <op> first and never appends a remote command', () => {
const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' }
const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000)
assert.equal(args[0], '-O')
assert.equal(args[1], 'forward')
assert.ok(args.includes('-L'))
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
assert.equal(args[args.length - 1], 'me@box')
})
test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildMasterArgs(conn, 15000)
assert.ok(args.includes('-M'))
assert.ok(args.includes('-N'))
assert.ok(args.includes('-f'))
})
test('forwardSpec binds the local end to 127.0.0.1 only', () => {
assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000')
assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:'))
assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))
})
test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '', 15000)
assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')
assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')
assert.equal(args[args.length - 2], 'me@box')
assert.equal(args[args.length - 1], 'exec "$SHELL" -l')
})
test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)
const remoteCmd = args[args.length - 1]
assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/)
})
test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000)
// the embedded quote must be escaped, not break out of the quoting
assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'"))
assert.ok(args[args.length - 1].includes('exec "$SHELL" -l'))
})
// --- error classification ---------------------------------------------------
test('classifySshError detects a changed host key (fail-closed)', () => {
assert.equal(
classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'),
SSH_ERROR.HOST_KEY_CHANGED
)
assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED)
assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED)
})
test('classifySshError detects auth failure', () => {
assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED)
assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED)
})
test('classifySshError detects unreachable', () => {
assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE)
assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE)
})
test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => {
const conn = { user: 'me', host: 'box', port: 22 }
assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/)
assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/)
})
// --- SshConnection lifecycle with injected fake spawn -----------------------
// A fake child process that emits a scripted result on next tick.
function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false } = {}) {
const child = new EventEmitter()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = () => {
child._killed = true
}
if (hang) {
return child // never emits close → drives the timeout path
}
process.nextTick(() => {
if (errorEvent) {
child.emit('error', errorEvent)
return
}
if (stdout) child.stdout.emit('data', Buffer.from(stdout))
if (stderr) child.stderr.emit('data', Buffer.from(stderr))
child.emit('close', code)
})
return child
}
// Build a spawnFn that returns scripted children per ssh invocation, recording
// the args it was called with.
function scriptedSpawn(scripts) {
const calls = []
let i = 0
const fn = (_cmd, args) => {
calls.push(args)
const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)]
i += 1
return fakeChild(script || {})
}
fn.calls = calls
return fn
}
test('open() establishes the master when not already alive', async () => {
// `-O check` fails first (not alive) → master opens (code 0). Track which
// ssh ops ran rather than re-probing with the same always-failing check.
const ops = []
const spawnFn = scriptedSpawn(args => {
ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other')
if (args.includes('check')) return { code: 255, stderr: 'no control path' }
return { code: 0 }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.open()
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
})
test('open() is a no-op when the master is already alive', async () => {
const ops = []
const spawnFn = scriptedSpawn(args => {
ops.push(args.includes('check') ? 'check' : 'master')
return { code: 0 } // check succeeds → already alive
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.open()
assert.deepEqual(ops, ['check'], 'alive master → no second spawn to open it')
})
test('open() creates the control-socket directory if it does not exist', async () => {
const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)
assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
try {
await conn.open()
assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh')
} finally {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
/* ignore */
}
}
})
test('open() surfaces a classified auth error', async () => {
const spawnFn = scriptedSpawn(args => {
if (args.includes('check')) return { code: 255 }
return { code: 255, stderr: 'Permission denied (publickey).' }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await assert.rejects(() => conn.open(), err => {
assert.equal(err.kind, SSH_ERROR.AUTH_FAILED)
assert.match(err.message, /ssh-agent|ssh-add/)
return true
})
})
test('exec() returns stdout on success and rejects (classified) on failure', async () => {
const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' })
assert.equal((await conn.exec('uname -s')).trim(), 'Linux')
const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }])
const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' })
await assert.rejects(() => conn2.exec('uname -s'), err => {
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
return true
})
})
test('exec() treats a hung ssh as a timeout (half-open connection)', async () => {
const spawnFn = scriptedSpawn([{ hang: true }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await assert.rejects(() => conn.exec('uname -s', { timeoutMs: 30 }), err => {
assert.equal(err.kind, SSH_ERROR.TIMEOUT)
return true
})
})
test('forward() issues -O forward with a loopback-bound -L spec', async () => {
const spawnFn = scriptedSpawn([{ code: 0 }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.forward(5000, 6000)
const args = spawnFn.calls[0]
assert.equal(args[0], '-O')
assert.equal(args[1], 'forward')
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
})
test('lifecycle logging passes through redaction', async () => {
const logs = []
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection(
{ host: 'box', user: 'me' },
{ spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
)
await conn.open()
// none of the emitted log lines may carry a raw token-shaped secret
for (const line of logs) {
assert.ok(!/token=[^<]/.test(line))
}
assert.ok(logs.some(l => l.includes('[ssh]')))
})
+1 -1
View File
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/ssh-connection.test.cjs electron/remote-lifecycle.test.cjs electron/ssh-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+2 -8
View File
@@ -60,7 +60,6 @@ import {
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
import { notify } from '@/store/notifications'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
@@ -196,7 +195,6 @@ export function ChatBar({
const attachments = useStore($composerAttachments)
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const statusItemsBySession = useStore($statusItemsBySession)
const previewStatusBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
// tiny window, subagent watch windows) always start docked and can't pop out:
@@ -219,12 +217,8 @@ export function ChatBar({
const statusStackVisible = useMemo(
() =>
queuedPrompts.length > 0 ||
(statusSessionId
? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 ||
(previewStatusBySession[statusSessionId]?.length ?? 0) > 0
: false),
[previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId]
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
[queuedPrompts.length, statusItemsBySession, statusSessionId]
)
const composerRef = useRef<HTMLFormElement | null>(null)
@@ -19,11 +19,9 @@ import {
type StatusGroup,
stopBackgroundProcess
} from '@/store/composer-status'
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { PreviewStatusRow } from './preview-row'
import { StatusItemRow } from './status-row'
// Slow safety-net poll for silent exits (processes without notify_on_complete
@@ -54,7 +52,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const { t } = useI18n()
const navigate = useNavigate()
const itemsBySession = useStore($statusItemsBySession)
const previewsBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
const groups = useMemo(
@@ -62,8 +59,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
[itemsBySession, sessionId]
)
const previews = sessionId ? (previewsBySession[sessionId] ?? []) : []
// Seed from the registry on session open; event-driven refreshes (terminal /
// process tool completions) live in use-message-stream.
useEffect(() => {
@@ -127,21 +122,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
)
}))
if (previews.length > 0 && sessionId) {
sections.push({
key: 'preview',
// Not a collapsible group — preview links just sit there, one line each,
// each individually closeable.
node: (
<div className="px-1 py-0.5">
{previews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))}
</div>
)
})
}
if (queue) {
sections.push({ key: 'queue', node: queue })
}
@@ -1,125 +0,0 @@
import { useStore } from '@nanostores/react'
import { memo, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronRight, X } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { $paneOpen } from '@/store/panes'
import { $previewTarget, dismissPreviewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
import { type PreviewArtifact } from '@/store/preview-status'
interface PreviewStatusRowProps {
item: PreviewArtifact
onDismiss: (id: string) => void
}
/** One detected artifact, single line, always visible: filename + open + close. */
export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss }: PreviewStatusRowProps) {
const { t } = useI18n()
const activePreview = useStore($previewTarget)
const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID))
const [opening, setOpening] = useState(false)
const isOpen = activePreview?.source === item.target && previewPaneOpen
const resolveTarget = async () => {
const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined)
if (!target) {
throw new Error(`Could not open preview target: ${item.target}`)
}
return target
}
const togglePreview = async () => {
if (opening) {
return
}
if (isOpen) {
dismissPreviewTarget()
return
}
setOpening(true)
try {
setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target)
} catch (error) {
notifyError(error, t.preview.unavailable)
} finally {
setOpening(false)
}
}
const openInBrowser = async () => {
try {
const bridge = window.hermesDesktop?.openPreviewInBrowser
if (!bridge) {
throw new Error('Desktop preview browser bridge is unavailable')
}
await bridge((await resolveTarget()).url)
} catch (error) {
notifyError(error, t.preview.unavailable)
}
}
return (
<StatusRow
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
onActivate={() => void togglePreview()}
trailing={
<span className="-my-1 flex items-center gap-0.5">
<Tip label={t.preview.openInBrowser}>
<Button
aria-label={t.preview.openInBrowser}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
void openInBrowser()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="link-external" size="0.75rem" />
</Button>
</Tip>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<X size={12} />
</Button>
</Tip>
</span>
}
trailingVisible
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
{item.label}
</span>
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
</span>
</StatusRow>
)
})
+1 -2
View File
@@ -33,7 +33,6 @@ import {
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
pinSession,
PREVIEW_PANE_ID,
setSidebarOverlayMounted,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
@@ -1078,7 +1077,7 @@ export function DesktopController() {
const previewPane = (
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
id={PREVIEW_PANE_ID}
id="preview"
key="preview"
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
minWidth={PREVIEW_RAIL_MIN_WIDTH}
@@ -120,7 +120,31 @@ describe('usePreviewRouting', () => {
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
})
it('does not auto-open a preview from tool results', async () => {
it('registers structured tool-result preview targets', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({
payload: { path: './dist/index.html' },
session_id: 'session-1',
type: 'tool.complete'
})
)
await waitFor(() => {
expect($previewTarget.get()?.source).toBe('./dist/index.html')
})
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html')
})
it('registers html previews from edit inline diffs', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
@@ -136,9 +160,9 @@ describe('usePreviewRouting', () => {
type: 'tool.complete'
})
)
act(() => handleEvent({ payload: { path: './dist/index.html' }, session_id: 'session-1', type: 'tool.complete' }))
expect($previewTarget.get()).toBeNull()
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toBeNull()
await waitFor(() => {
expect($previewTarget.get()?.source).toBe('preview-demo.html')
})
})
})
@@ -10,7 +10,8 @@ import {
getSessionPreviewRecord,
progressPreviewServerRestart,
requestPreviewReload,
setPreviewTarget
setPreviewTarget,
setSessionPreviewTarget
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -39,6 +40,53 @@ function activePreviewSessionId(
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
}
function looksLikePreviewTarget(value: string): boolean {
return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
}
function stripAnsi(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
}
function htmlPathFromInlineDiff(value: string): string {
const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '')
for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
const candidate = match[1]?.trim()
if (candidate) {
return candidate
}
}
return ''
}
function structuredPreviewCandidate(payload: unknown): string {
const record = asRecord(payload)
const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview']
for (const field of fields) {
const value = record[field]
if (typeof value === 'string') {
const target = value.trim()
if (target && looksLikePreviewTarget(target)) {
return target
}
}
}
const inlineDiff = record.inline_diff
if (typeof inlineDiff === 'string') {
return htmlPathFromInlineDiff(inlineDiff)
}
return ''
}
export function usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent,
@@ -51,10 +99,6 @@ export function usePreviewRouting({
const previewRegistry = useStore($sessionPreviewRegistry)
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
// Restore a *user-opened* preview when its session becomes active. Tool
// results no longer auto-register/open a preview — the inline preview card in
// the tool row is the only entry point, so HTML artifacts never pop the rail
// open on their own.
useEffect(() => {
if (currentView !== 'chat' || !previewSessionId) {
setPreviewTarget(null)
@@ -67,6 +111,53 @@ export function usePreviewRouting({
setPreviewTarget(record?.normalized ?? null)
}, [currentView, previewRegistry, previewSessionId])
const registerStructuredPreview = useCallback(
async (event: RpcEvent) => {
if (
event.session_id &&
event.session_id !== activeSessionIdRef.current &&
event.session_id !== previewSessionId
) {
return
}
if (!event.type.startsWith('tool.')) {
return
}
if (!previewSessionId) {
return
}
const candidate = structuredPreviewCandidate(event.payload)
if (!candidate) {
return
}
const desktop = window.hermesDesktop
if (!desktop?.normalizePreviewTarget) {
return
}
const sessionId = previewSessionId
const cwd = currentCwd || ''
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
if (
!target ||
sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) ||
$currentCwd.get() !== cwd
) {
return
}
setSessionPreviewTarget(sessionId, target, 'tool-result', candidate)
},
[activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId]
)
const restartPreviewServer = useCallback(
async (url: string, context?: string) => {
const sessionId = activeSessionIdRef.current
@@ -119,14 +210,13 @@ export function usePreviewRouting({
return
}
// Only refresh an already-open live preview when a file changes; never
// open one unprompted. (Preview links are surfaced from the tool row into
// the status stack — see tool-fallback.tsx.)
void registerStructuredPreview(event)
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
requestPreviewReload()
}
},
[activeSessionIdRef, baseHandleGatewayEvent]
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
)
return { handleDesktopGatewayEvent, restartPreviewServer }
@@ -37,7 +37,6 @@ import {
updateComposerAttachment
} from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
@@ -1644,7 +1643,6 @@ export function usePromptActions({
// rows (and kill the live processes) before the fresh run repopulates.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
@@ -1707,7 +1705,6 @@ export function usePromptActions({
// processes) before the re-run repopulates them.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
@@ -1,239 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
import type { ComputerUseStatus } from '@/types/hermes'
import { Pill } from './primitives'
interface ComputerUsePanelProps {
/** Re-read the parent toolset list after a permission/install change so the
* "Configured / Needs keys" pill stays in sync. */
onConfiguredChange?: () => void
}
// Per-OS one-liner shown when there's no TCC grant flow (Windows/Linux). macOS
// drives the permission rows instead, so it has no entry here.
const PLATFORM_NOTE: Record<string, string> = {
linux: 'Drives your desktop via the X11/XWayland accessibility stack — no permission prompt.',
win32: 'First run may trigger a Windows SmartScreen prompt for the cua-driver UIAccess worker — allow it.'
}
function tone(granted: boolean | null) {
return granted === true ? 'primary' : 'muted'
}
function GrantIcon({ granted }: { granted: boolean | null }) {
const Icon = granted === true ? Check : granted === false ? X : AlertTriangle
return <Icon className="size-3" />
}
function PermissionRow({ granted, label, hint }: { granted: boolean | null; label: string; hint: string }) {
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<div className="min-w-0">
<span className="text-sm font-medium">{label}</span>
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{hint}</p>
</div>
<Pill tone={tone(granted)}>
<GrantIcon granted={granted} />
{granted === true ? 'Granted' : granted === false ? 'Not granted' : 'Unknown'}
</Pill>
</div>
)
}
/**
* Cross-platform Computer Use preflight card.
*
* cua-driver runs on macOS, Windows, and Linux, but readiness differs: macOS
* needs two TCC grants (Accessibility + Screen Recording) that attach to
* cua-driver's own `com.trycua.driver` identity — not Hermes — and are
* requested via `cua-driver permissions grant` (dialog attributed to
* CuaDriver). Windows/Linux have no TCC toggles, so readiness is driver health
* from `cua-driver doctor`. The backend folds both into one `ready` signal.
*
* Binary install/upgrade stays in the cua-driver provider's post-setup runner
* below this card (the generic ToolsetConfigPanel).
*/
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
const [loading, setLoading] = useState(true)
const [granting, setGranting] = useState(false)
const activeRef = useRef(false)
const refresh = useCallback(async () => {
try {
setStatus(await getComputerUseStatus())
} catch (err) {
notifyError(err, 'Could not read Computer Use status')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
activeRef.current = true
void refresh()
return () => void (activeRef.current = false)
}, [refresh])
const grant = useCallback(async () => {
setGranting(true)
try {
const started = await grantComputerUsePermissions()
if (!started.ok) {
notifyError(new Error('spawn failed'), 'Could not request permissions')
return
}
notify({
kind: 'info',
title: 'Approve in System Settings',
message: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.'
})
// The driver waits for the user to flip the switch — poll until it exits.
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 1500))
if (!activeRef.current) {
break
}
const polled = await getActionStatus(started.name, 200)
upsertDesktopActionTask(polled)
if (!polled.running) {
break
}
}
if (activeRef.current) {
await refresh()
onConfiguredChange?.()
}
} catch (err) {
if (activeRef.current) {
notifyError(err, 'Could not request permissions')
}
} finally {
if (activeRef.current) {
setGranting(false)
}
}
}, [onConfiguredChange, refresh])
if (loading) {
return (
<div className="mt-3 flex items-center gap-2 px-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking Computer Use status…
</div>
)
}
if (!status) {
return null
}
if (!status.platform_supported) {
return (
<p className="mt-3 px-1 text-xs text-muted-foreground">
Computer Use isn&apos;t supported on this platform ({status.platform}).
</p>
)
}
if (!status.installed) {
return (
<p className="mt-3 px-1 text-xs text-muted-foreground">
Install the cua-driver backend below to drive this machine.
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
</p>
)
}
const failingChecks = status.checks.filter(c => c.status !== 'ok')
return (
<div className="mt-3 grid gap-2">
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
<div className="min-w-0">
{status.can_grant ? (
<p className="text-[0.72rem] text-muted-foreground">
Grants attach to CuaDriver&apos;s own identity (com.trycua.driver), not Hermes — so the dialog is
attributed to the process that drives your Mac.
</p>
) : (
<p className="text-[0.72rem] text-muted-foreground">{PLATFORM_NOTE[status.platform] ?? ''}</p>
)}
{status.version && <p className="text-[0.68rem] text-muted-foreground/80">{status.version}</p>}
</div>
<Button onClick={() => void refresh()} size="sm" variant="text">
<RefreshCw className="size-3.5" />
Recheck
</Button>
</div>
{status.can_grant ? (
<>
<PermissionRow
granted={status.accessibility}
hint="Lets cua-driver post clicks, keystrokes, and read the accessibility tree."
label="Accessibility"
/>
<PermissionRow
granted={status.screen_recording}
hint="Lets cua-driver capture screenshots of app windows."
label="Screen Recording"
/>
</>
) : (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<span className="text-sm font-medium">Driver health</span>
<Pill tone={tone(status.ready)}>
<GrantIcon granted={status.ready} />
{status.ready === true ? 'Ready' : status.ready === false ? 'Not ready' : 'Unknown'}
</Pill>
</div>
)}
{failingChecks.map(c => (
<p className="px-1 text-[0.7rem] text-muted-foreground" key={c.label}>
<AlertTriangle className="mr-1 inline size-3" />
{c.label}: {c.message}
</p>
))}
{status.error && (
<p className="px-1 text-[0.7rem] text-muted-foreground">
<AlertTriangle className="mr-1 inline size-3" />
{status.error}
</p>
)}
{status.ready ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Check className="size-3.5" />
Computer Use is ready. Ask the agent to capture an app and click around.
</div>
) : (
status.can_grant && (
<Button disabled={granting} onClick={() => void grant()} size="sm">
{granting ? <Loader2 className="size-3.5 animate-spin" /> : <ExternalLink className="size-3.5" />}
{granting ? 'Waiting for approval…' : 'Grant permissions'}
</Button>
)
)}
</div>
)
}
@@ -21,7 +21,6 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { MemoryConnect } from './memory/connect'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { ProviderConfigPanel } from './provider-config-panel'
@@ -32,8 +31,7 @@ function ConfigField({
value,
enumOptions,
optionLabels,
onChange,
descriptionExtra
onChange
}: {
schemaKey: string
schema: ConfigFieldSchema
@@ -41,7 +39,6 @@ function ConfigField({
enumOptions?: string[]
optionLabels?: Record<string, string>
onChange: (value: unknown) => void
descriptionExtra?: ReactNode
}) {
const { t } = useI18n()
const c = t.settings.config
@@ -67,17 +64,8 @@ function ConfigField({
? rawDescription
: undefined
const descriptionNode: ReactNode = descriptionExtra ? (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
{description}
{descriptionExtra}
</span>
) : (
description
)
const row = (action: ReactNode, wide = false) => (
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
<ListRow action={action} description={description} title={label} wide={wide} />
)
if (schema.type === 'boolean') {
@@ -370,11 +358,6 @@ export function ConfigSettings({
{fields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
descriptionExtra={
key === 'memory.provider' && Boolean(getNested(config, key)) ? (
<MemoryConnect provider={String(getNested(config, key))} />
) : undefined
}
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor, Network } from '@/lib/icons'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
@@ -13,10 +13,9 @@ import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote' | 'ssh'
type Mode = 'local' | 'remote'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
type SshTestStatus = 'idle' | 'testing' | 'ok' | 'error'
interface GatewaySettingsState {
envOverride: boolean
@@ -26,11 +25,6 @@ interface GatewaySettingsState {
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
sshHost: string
sshUser: string
sshPort: number | null
sshKeyPath: string
sshRemoteHermesPath: string
}
const EMPTY_STATE: GatewaySettingsState = {
@@ -40,12 +34,7 @@ const EMPTY_STATE: GatewaySettingsState = {
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: '',
sshHost: '',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: ''
remoteUrl: ''
}
function ModeCard({
@@ -116,12 +105,6 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// SSH-mode local UI state: the connection test result, ~/.ssh/config host
// suggestions, and the `ssh -G` resolution of the entered host.
const [sshTestStatus, setSshTestStatus] = useState<SshTestStatus>('idle')
const [sshTestMessage, setSshTestMessage] = useState<null | string>(null)
const [sshHostSuggestions, setSshHostSuggestions] = useState<string[]>([])
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
@@ -282,23 +265,6 @@ export function GatewaySettings() {
// per-profile scopes are the named, non-default profiles.
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
// Load ~/.ssh/config host suggestions once SSH mode is active (read-only).
useEffect(() => {
if (state.mode !== 'ssh') return
const desktop = window.hermesDesktop
if (!desktop?.sshConfigHosts) return
let cancelled = false
desktop
.sshConfigHosts()
.then(result => {
if (!cancelled) setSshHostSuggestions(result.hosts || [])
})
.catch(() => {
if (!cancelled) setSshHostSuggestions([])
})
return () => void (cancelled = true)
}, [state.mode])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
@@ -441,7 +407,7 @@ export function GatewaySettings() {
remoteUrl: trimmedUrl
})
const message = g.connectedTo(result.baseUrl ?? trimmedUrl, result.version ?? undefined)
const message = g.connectedTo(result.baseUrl, result.version ?? undefined)
setLastTest(message)
notify({ kind: 'success', title: g.reachableTitle, message })
} catch (err) {
@@ -451,108 +417,6 @@ export function GatewaySettings() {
}
}
// --- SSH mode -------------------------------------------------------------
const canUseSsh = Boolean(state.sshHost.trim())
const sshPayload = () => ({
mode: 'ssh' as const,
profile: scope ?? undefined,
sshHost: state.sshHost.trim(),
sshUser: state.sshUser.trim() || undefined,
sshPort: state.sshPort ?? undefined,
sshKeyPath: state.sshKeyPath.trim() || undefined,
sshRemoteHermesPath: state.sshRemoteHermesPath.trim() || undefined
})
// Map an SSH test error kind to actionable copy.
const sshErrorMessage = (kind: string | null | undefined, raw: string | null | undefined): string => {
switch (kind) {
case 'auth-failed':
return g.sshErrAuth
case 'unreachable':
return g.sshErrUnreachable
case 'host-key-changed':
return g.sshErrHostKey
case 'hermes-not-found':
return g.sshErrNotInstalled
case 'unsupported-platform':
return g.sshErrPlatform
case 'timeout':
return g.sshErrTimeout
default:
return raw || g.sshErrUnknown
}
}
const sshTest = async () => {
if (!canUseSsh) {
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
return
}
setSshTestStatus('testing')
setSshTestMessage(null)
try {
const result = await window.hermesDesktop.testConnectionConfig(sshPayload())
if (result.reachable) {
const message = g.sshReachable(result.host ?? state.sshHost, result.remotePlatform ?? '?')
setSshTestStatus('ok')
setSshTestMessage(message)
notify({ kind: 'success', title: g.reachableTitle, message })
} else {
const message = sshErrorMessage(result.sshError, result.error)
setSshTestStatus('error')
setSshTestMessage(message)
notify({ kind: 'warning', title: g.testFailed, message })
}
} catch (err) {
setSshTestStatus('error')
setSshTestMessage(err instanceof Error ? err.message : String(err))
notifyError(err, g.testFailed)
}
}
// Resolve the entered host via `ssh -G` and fill in any blank user/port the
// alias expands to (so the saved config matches what ssh will actually use).
const sshResolve = async () => {
const host = state.sshHost.trim()
if (!host || !window.hermesDesktop?.sshResolveHost) return
try {
const resolved = await window.hermesDesktop.sshResolveHost(host)
setState(current => ({
...current,
sshUser: current.sshUser.trim() || resolved.user || '',
sshPort: current.sshPort ?? (resolved.port && resolved.port !== 22 ? resolved.port : null),
sshKeyPath: current.sshKeyPath.trim() || resolved.identityFile || ''
}))
} catch {
// best-effort enrichment; leave the fields as entered
}
}
const sshSave = async (apply: boolean) => {
if (!canUseSsh) {
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
return
}
setSaving(true)
try {
const next = apply
? await window.hermesDesktop.applyConnectionConfig(sshPayload())
: await window.hermesDesktop.saveConnectionConfig(sshPayload())
setState(next)
notify({
kind: 'success',
title: apply ? g.restartingTitle : g.savedTitle,
message: apply ? g.restartingMessage : g.savedMessage
})
} catch (err) {
notifyError(err, apply ? g.applyFailed : g.saveFailed)
} finally {
setSaving(false)
}
}
if (loading) {
return <LoadingState label={g.loading} />
}
@@ -613,7 +477,7 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
@@ -630,32 +494,22 @@ export function GatewaySettings() {
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
title={g.remoteTitle}
/>
<ModeCard
active={state.mode === 'ssh'}
description={g.sshDesc}
disabled={state.envOverride}
icon={Network}
onSelect={() => setState(current => ({ ...current, mode: 'ssh' }))}
title={g.sshTitle}
/>
</div>
<div className="mt-5 grid gap-1">
{state.mode === 'remote' ? (
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
placeholder="https://gateway.example.com/hermes"
value={state.remoteUrl}
/>
}
description={g.remoteUrlDesc}
title={g.remoteUrlTitle}
/>
) : null}
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
placeholder="https://gateway.example.com/hermes"
value={state.remoteUrl}
/>
}
description={g.remoteUrlDesc}
title={g.remoteUrlTitle}
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
@@ -725,159 +579,28 @@ export function GatewaySettings() {
title={g.tokenTitle}
/>
) : null}
{/* SSH mode: connect via the box's SSH access; no token to copy. */}
{state.mode === 'ssh' ? (
<>
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
list="hermes-ssh-host-suggestions"
onBlur={() => void sshResolve()}
onChange={event => setState(current => ({ ...current, sshHost: event.target.value }))}
placeholder="user@mac-mini.local or mac-mini"
value={state.sshHost}
/>
}
description={g.sshHostDesc}
title={g.sshHostTitle}
/>
{sshHostSuggestions.length > 0 ? (
<datalist id="hermes-ssh-host-suggestions">
{sshHostSuggestions.map(host => (
<option key={host} value={host} />
))}
</datalist>
) : null}
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))}
placeholder={g.sshUserPlaceholder}
value={state.sshUser}
/>
}
description={g.sshUserDesc}
title={g.sshUserTitle}
/>
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event =>
setState(current => ({
...current,
sshPort: event.target.value.trim() ? Number.parseInt(event.target.value, 10) || null : null
}))
}
placeholder="22"
value={state.sshPort != null ? String(state.sshPort) : ''}
/>
}
description={g.sshPortDesc}
title={g.sshPortTitle}
/>
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))}
placeholder="~/.ssh/id_ed25519"
value={state.sshKeyPath}
/>
}
description={g.sshKeyDesc}
title={g.sshKeyTitle}
/>
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))}
placeholder={g.sshHermesPathPlaceholder}
value={state.sshRemoteHermesPath}
/>
}
description={g.sshHermesPathDesc}
title={g.sshHermesPathTitle}
/>
{sshTestStatus !== 'idle' && sshTestMessage ? (
<div
className={cn(
'flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)]',
sshTestStatus === 'ok' ? 'text-primary' : 'text-(--ui-text-tertiary)'
)}
>
{sshTestStatus === 'testing' ? (
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />
) : sshTestStatus === 'ok' ? (
<Check className="mt-0.5 size-4 shrink-0" />
) : (
<AlertCircle className="mt-0.5 size-4 shrink-0" />
)}
<span>{sshTestMessage}</span>
</div>
) : null}
</>
) : null}
</div>
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
{state.mode === 'ssh' ? (
<>
<Button
className="mr-auto"
disabled={state.envOverride || sshTestStatus === 'testing' || !canUseSsh}
onClick={() => void sshTest()}
size="sm"
variant="text"
>
{sshTestStatus === 'testing' ? <Loader2 className="animate-spin" /> : null}
{g.sshTestConnection}
</Button>
<Button
disabled={state.envOverride || saving}
onClick={() => void sshSave(false)}
size="sm"
variant="textStrong"
>
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving || !canUseSsh} onClick={() => void sshSave(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.sshConnect}
</Button>
</>
) : (
<>
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</>
)}
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
<div className="mt-6 grid gap-1">
@@ -1,162 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
const POLL_MS = 1500
const POLL_TIMEOUT_MS = 120_000
// Small connect affordance rendered under the provider dropdown. Capability is
// backend-driven: the status route 404s for providers without an oauth_flow
// module, so non-OAuth providers render nothing.
export function MemoryConnect({ provider }: { provider: string }) {
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
const [connected, setConnected] = useState(false)
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle')
const [detail, setDetail] = useState('')
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const deadline = useRef(0)
const stop = useCallback(() => {
if (timer.current !== null) {
clearInterval(timer.current)
timer.current = null
}
}, [])
useEffect(() => {
let active = true
setCapable('unknown')
getMemoryProviderOAuthStatus(provider)
.then(s => {
if (!active) {
return
}
setCapable('yes')
setConnected(s.connected)
setAuth(s.auth)
})
.catch(() => {
if (active) {
setCapable('no')
}
})
return () => {
active = false
stop()
}
}, [provider, stop])
// An error message isn't sticky — it clears back to the steady state
// (Connect link, plus the connected badge if a credential is stored).
useEffect(() => {
if (phase !== 'error') {
return
}
const t = setTimeout(() => {
setPhase('idle')
setDetail('')
}, 6000)
return () => clearTimeout(t)
}, [phase])
const connect = useCallback(async () => {
setPhase('pending')
try {
await startMemoryProviderOAuth(provider)
} catch (err) {
setPhase('error')
setDetail('Could not start the connection.')
notifyError(err, 'Failed to start connection')
return
}
deadline.current = Date.now() + POLL_TIMEOUT_MS
stop()
timer.current = setInterval(() => {
void (async () => {
try {
const next = await getMemoryProviderOAuthStatus(provider)
if (next.state === 'pending') {
if (Date.now() > deadline.current) {
stop()
setPhase('error')
setDetail('Timed out — try again.')
}
return
}
stop()
setConnected(next.connected)
setAuth(next.auth)
if (next.state === 'error') {
setPhase('error')
setDetail(next.detail || 'Connection failed.')
} else {
setPhase('idle')
}
} catch {
// Transient poll failure — keep trying until the deadline.
}
})()
}, POLL_MS)
}, [provider, stop])
const cancel = useCallback(() => {
stop()
setPhase('idle')
}, [stop])
if (capable !== 'yes') {
return null
}
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
return (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{phase === 'idle' && connected && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Check className="size-3" />
{auth === 'apikey' ? 'api key set' : 'oauth set'}
</span>
)}
{phase === 'pending' ? (
<>
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for browser consent…
</span>
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
Cancel
</Button>
</>
) : (
<Button
className="h-auto gap-1 p-0 text-xs"
onClick={() => void connect()}
size="sm"
type="button"
variant="link"
>
<ExternalLink className="size-3" />
{connectLabel}
</Button>
)}
{phase === 'error' && detail && <span className="text-destructive">{detail}</span>}
</span>
)
}
@@ -13,7 +13,6 @@ import {
Command,
Hash,
Loader2,
Network,
Sparkles,
Terminal,
Zap,
@@ -48,7 +47,7 @@ import {
} from '@/store/updates'
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
interface StatusbarItemsOptions {
@@ -292,68 +291,8 @@ export function useStatusbarItems({
copy
])
// Connection-identity pill (VS Code's load-bearing "where am I?" cue). Shown
// only for remote connections; hidden in local mode (the unmarked default).
// SSH remotes read "SSH: user@host"; token/oauth remotes read "Remote: host"
// — closing the same gap for the existing remote modes. Clicking opens the
// gateway connection settings so the pill doubles as the switch/disconnect
// entry point.
const connectionItem = useMemo<StatusbarItem | null>(() => {
if (connection?.mode !== 'remote') {
return null
}
// Prefer the host main.cjs put on the descriptor; fall back to parsing the
// backend URL (never the 127.0.0.1 tunnel — that's only the SSH baseUrl,
// and SSH descriptors always carry remoteHost).
let host = connection.remoteHost ?? ''
if (!host && connection.baseUrl) {
try {
host = new URL(connection.baseUrl).host
} catch {
host = ''
}
}
if (!host) {
return null
}
const isSsh = connection.remoteKind === 'ssh'
const label = isSsh ? copy.connectionSsh(host) : copy.connectionRemote(host)
const baseTooltip = isSsh ? copy.connectionSshTooltip(host) : copy.connectionRemoteTooltip(host)
// Append the per-profile scope when this is a profile-scoped connection, so
// the pill discloses WHICH profile the host backs (not just the host).
const profile = connection.profile
const title = profile ? `${baseTooltip} · ${profile}` : baseTooltip
return {
// VS Code-style remote indicator: a solid colored block (not a muted
// pill) so "you are running on a remote host" is unmistakable, pinned to
// the FAR LEFT of the status bar. SSH gets the primary accent; a plain URL
// remote gets a calmer tint so the two are visually distinct.
className: cn(
'px-2 font-medium',
isSsh
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
: 'bg-accent text-accent-foreground hover:bg-accent/90 hover:text-accent-foreground'
),
icon: <Network className="size-3" />,
id: 'connection',
label,
title,
// Deep-link straight to the Gateway connection panel (the settings index
// reads ?tab=), so the pill lands the user where they manage/switch it.
// NB: default (button) variant — NOT 'link', which renders an <a href> and
// would swallow the in-app `to:` navigation.
to: `${SETTINGS_ROUTE}?tab=gateway`
}
}, [connection?.mode, connection?.remoteHost, connection?.remoteKind, connection?.baseUrl, connection?.profile, copy])
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
// Remote-connection indicator pinned to the far left (VS Code parity) —
// first thing in the bar so "where am I running" is the dominant cue.
// Absent in local mode.
...(connectionItem ? [connectionItem] : []),
{
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
icon: <Command className="size-3.5" />,
@@ -420,7 +359,6 @@ export function useStatusbarItems({
bgFailed,
bgRunning,
commandCenterOpen,
connectionItem,
copy,
gatewayMenuContent,
gatewayClassName,
-4
View File
@@ -17,7 +17,6 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { ComputerUsePanel } from '../settings/computer-use-panel'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@@ -335,9 +334,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
))}
</div>
)}
{expanded && toolset.name === 'computer_use' && (
<ComputerUsePanel onConfiguredChange={refreshToolsets} />
)}
{expanded && <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />}
</div>
)
@@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest'
import { activeTimelineIndex, deriveTimelineEntries, timelinePreview } from './thread-timeline-data'
describe('timelinePreview', () => {
it('collapses whitespace to a single line', () => {
expect(timelinePreview('hello\n\n world\tagain')).toBe('hello world again')
})
it('truncates with an ellipsis past the limit', () => {
const out = timelinePreview('abcdefghij', 5)
expect(out).toBe('abcd…')
expect(out.length).toBe(5)
})
})
describe('deriveTimelineEntries', () => {
it('keeps non-empty user prompts in order', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: 'first' },
{ id: 'a1', role: 'assistant', text: 'answer' },
{ id: 'u2', role: 'user', text: ' second ' }
])
).toEqual([
{ id: 'u1', preview: 'first' },
{ id: 'u2', preview: 'second' }
])
})
it('drops blanks and background-process notifications', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: ' ' },
{ id: 'u2', role: 'user', text: '[IMPORTANT: Background process 123 finished]' },
{ id: 'u3', role: 'user', text: 'real prompt' }
]).map(e => e.id)
).toEqual(['u3'])
})
})
describe('activeTimelineIndex', () => {
it('returns the last prompt scrolled to or above the top edge', () => {
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
})
it('falls back to the first rendered entry', () => {
expect(activeTimelineIndex([null, 120, 480])).toBe(1)
expect(activeTimelineIndex([null, null])).toBe(0)
})
})
@@ -1,75 +0,0 @@
// Pure timeline helpers — no React/DOM; tested in thread-timeline-data.test.ts.
export interface TimelineSourceMessage {
id: string
role: string
text: string
}
export interface TimelineEntry {
id: string
preview: string
}
// Injected as user messages for alternation; not human prompts (thread.tsx).
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
const PREVIEW_MAX = 120
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
const collapsed = text.replace(/\s+/g, ' ').trim()
if (collapsed.length <= max) {
return collapsed
}
return `${collapsed.slice(0, max - 1).trimEnd()}…`
}
export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]): TimelineEntry[] {
const entries: TimelineEntry[] = []
for (const message of messages) {
if (message.role !== 'user') {
continue
}
const text = message.text.trim()
if (!text || PROCESS_NOTIFICATION_RE.test(text)) {
continue
}
entries.push({ id: message.id, preview: timelinePreview(text) })
}
return entries
}
/** Last user prompt at/above the viewport top (with slack); else first rendered. */
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
let active = -1
let firstRendered = -1
for (let i = 0; i < offsets.length; i++) {
const offset = offsets[i]
if (offset == null) {
continue
}
if (firstRendered === -1) {
firstRendered = i
}
if (offset <= slack) {
active = i
}
}
if (active !== -1) {
return active
}
return firstRendered === -1 ? 0 : firstRendered
}
@@ -1,272 +0,0 @@
import { useAuiState } from '@assistant-ui/react'
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { composerPanelCard } from '@/components/chat/composer-dock'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { setPaneHoverRevealSuppressed } from '@/store/panes'
import {
activeTimelineIndex,
deriveTimelineEntries,
type TimelineEntry,
type TimelineSourceMessage
} from './thread-timeline-data'
const MIN_ENTRIES = 4
const VIEWPORT = '[data-slot="aui_thread-viewport"]'
const HOVER_CLOSE_MS = 140
const ROW_CLASS =
'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none'
const POPOVER_SHELL = cn(
'absolute right-full top-1/2 z-50 mr-1.5 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none',
composerPanelCard,
// Solid fill — composerPanelCard is deliberately translucent; without this,
// directive chips in the transcript bleed through and look like popover overflow.
'bg-(--composer-fill)'
)
function userPromptText(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return ''
}
let out = ''
for (const part of content) {
if (typeof part === 'string') {
out += part
continue
}
if (!part || typeof part !== 'object') {
continue
}
const row = part as { text?: unknown; type?: unknown }
if ((!row.type || row.type === 'text') && typeof row.text === 'string') {
out += row.text
}
}
return out
}
function scrollToPrompt(id: string) {
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
if (!viewport || !node) {
return
}
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
triggerHaptic('selection')
viewport.scrollTo({ behavior: 'smooth', top: Math.max(0, top) })
}
/** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */
export const ThreadTimeline: FC = () => {
const sourceSignature = useAuiState(s => {
const rows: TimelineSourceMessage[] = []
for (const message of s.thread.messages) {
if (message.role !== 'user') {
continue
}
rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
}
return JSON.stringify(rows)
})
const entries = useMemo(
() => deriveTimelineEntries(JSON.parse(sourceSignature) as TimelineSourceMessage[]),
[sourceSignature]
)
const [activeIndex, setActiveIndex] = useState(0)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(undefined)
const keepOpen = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setPaneHoverRevealSuppressed(true)
setOpen(true)
}, [])
const closeSoon = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setHoverIndex(null)
setPaneHoverRevealSuppressed(false)
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
}, [])
useEffect(
() => () => {
window.clearTimeout(closeTimerRef.current)
setPaneHoverRevealSuppressed(false)
},
[]
)
useEffect(() => {
if (entries.length < MIN_ENTRIES) {
setPaneHoverRevealSuppressed(false)
}
}, [entries.length])
useEffect(() => {
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
if (!viewport || entries.length === 0) {
return
}
let raf = 0
const compute = () => {
raf = 0
const top = viewport.getBoundingClientRect().top
const offsets = entries.map(entry => {
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
return node ? node.getBoundingClientRect().top - top : null
})
const next = activeTimelineIndex(offsets)
setActiveIndex(prev => (prev === next ? prev : next))
}
const onScroll = () => {
if (!raf) {
raf = requestAnimationFrame(compute)
}
}
compute()
viewport.addEventListener('scroll', onScroll, { passive: true })
return () => {
viewport.removeEventListener('scroll', onScroll)
if (raf) {
cancelAnimationFrame(raf)
}
}
}, [entries])
if (entries.length < MIN_ENTRIES) {
return null
}
return (
<div
aria-label="Conversation timeline"
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
data-slot="thread-timeline"
onMouseEnter={keepOpen}
onMouseLeave={closeSoon}
role="navigation"
>
<TimelineTicks
activeIndex={activeIndex}
entries={entries}
onHover={setHoverIndex}
onJump={scrollToPrompt}
/>
<TimelinePopover
activeIndex={activeIndex}
entries={entries}
hoverIndex={hoverIndex}
onHover={setHoverIndex}
onJump={scrollToPrompt}
open={open}
/>
</div>
)
}
const TimelinePopover: FC<{
activeIndex: number
entries: TimelineEntry[]
hoverIndex: number | null
onHover: (index: number) => void
onJump: (id: string) => void
open: boolean
}> = ({ activeIndex, entries, hoverIndex, onHover, onJump, open }) => (
<div
className={cn(
POPOVER_SHELL,
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
)}
data-slot="thread-timeline-popover"
>
{entries.map((entry, index) => {
const hovered = index === hoverIndex
const active = index === activeIndex
return (
<button
aria-label={entry.preview}
className={cn(
ROW_CLASS,
active && 'bg-(--ui-row-active-background) text-foreground',
hovered && 'bg-(--ui-row-hover-background) text-foreground transition-none'
)}
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
)
})}
</div>
)
const TimelineTicks: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number) => void
onJump: (id: string) => void
}> = ({ activeIndex, entries, onHover, onJump }) => (
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className="group/tick flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span
className={cn(
'block h-px w-3 transition-opacity duration-100 ease-out',
index === activeIndex
? 'bg-(--theme-primary)'
: 'dither text-(--ui-text-quaternary) opacity-70 group-hover/tick:opacity-100 group-hover/tick:transition-none'
)}
/>
</button>
))}
</div>
)
@@ -64,7 +64,6 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
import { ThreadTimeline } from '@/components/assistant-ui/thread-timeline'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
@@ -213,7 +212,6 @@ export const Thread: FC<{
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
<ThreadTimeline />
</div>
)
}
@@ -799,15 +797,7 @@ function messageAttachmentRefs(value: unknown): string[] {
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
function StickyHumanMessageContainer({
attachments,
children,
messageId
}: {
attachments?: ReactNode
children: ReactNode
messageId?: string
}) {
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
return (
// Fragment, not a wrapper: a wrapping element becomes the sticky's
// containing block (it'd stick within its own height = never). The bubble
@@ -816,7 +806,6 @@ function StickyHumanMessageContainer({
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-message-id={messageId}
data-role="user"
data-slot="aui_user-message-root"
>
@@ -1001,7 +990,6 @@ const UserMessage: FC<{
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer
messageId={messageId}
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with
@@ -2,7 +2,7 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo } from 'react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
@@ -10,6 +10,7 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -24,8 +25,6 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { recordPreviewArtifact } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
@@ -77,8 +76,6 @@ const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase trac
const TOOL_SECTION_SURFACE_CLASS =
'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)'
const TOOL_EXPANDED_SHELL_CLASS = 'rounded-[0.3125rem] border border-(--ui-stroke-tertiary)'
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
interface ToolStatusCopy {
@@ -245,22 +242,6 @@ function ToolEntry({ part }: ToolEntryProps) {
return buildToolView(p, inlineDiff)
}, [inlineDiff, isPending, part])
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
// in the composer status stack rather than a bulky inline card. Uses the same
// detected target the old inline card did, keyed to the active session the
// stack reads from. Idempotent + dedup'd, so re-renders don't churn.
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const previewTarget = view.previewTarget
useEffect(() => {
if (isPending || !activeSessionId || !previewTarget || !isPreviewableTarget(previewTarget)) {
return
}
recordPreviewArtifact(activeSessionId, previewTarget, currentCwd || '')
}, [activeSessionId, currentCwd, isPending, previewTarget])
const detailSections = useMemo(() => {
if (!view.detail) {
return { body: '', summary: '' }
@@ -310,7 +291,12 @@ function ToolEntry({ part }: ToolEntryProps) {
Boolean(view.rawResult.trim())
const hasExpandableContent = Boolean(
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
view.imageUrl ||
view.inlineDiff ||
showDetail ||
hasSearchHits ||
toolViewMode === 'technical'
)
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
@@ -374,7 +360,7 @@ function ToolEntry({ part }: ToolEntryProps) {
<div
className={cn(
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && TOOL_EXPANDED_SHELL_CLASS
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
)}
data-file-edit={isFileEdit && open ? '' : undefined}
data-slot="tool-block"
@@ -439,6 +425,9 @@ function ToolEntry({ part }: ToolEntryProps) {
text={copyAction.text}
/>
)}
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
@@ -14,11 +14,6 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: 'https://box:9119',
sshHost: '',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: '',
...overrides
}
}
@@ -104,15 +104,16 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
}
return (
<div className="flex w-full max-w-160 items-center gap-2 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
<div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
<MonitorPlay className="size-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-[0.78rem] font-medium text-foreground/90" title={target}>
{name}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div>
<div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div>
</div>
<button
className="shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]"
disabled={opening}
onClick={() => void togglePreview()}
type="button"
@@ -15,7 +15,7 @@ import {
} from 'react'
import { cn } from '@/lib/utils'
import { $paneHoverRevealSuppressed, $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
@@ -250,7 +250,6 @@ export function Pane({
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const hoverRevealSuppressed = useStore($paneHoverRevealSuppressed)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
@@ -379,10 +378,7 @@ export function Pane({
>
<div
aria-hidden="true"
className={cn(
'absolute inset-y-0 z-30 [-webkit-app-region:no-drag]',
hoverRevealSuppressed ? 'pointer-events-none' : 'pointer-events-auto'
)}
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
@@ -392,8 +388,7 @@ export function Pane({
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
!hoverRevealSuppressed &&
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}
+5 -50
View File
@@ -31,8 +31,6 @@ declare global {
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
sshConfigHosts: () => Promise<DesktopSshHostsResult>
sshResolveHost: (host: string) => Promise<DesktopSshResolveResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
@@ -62,7 +60,6 @@ declare global {
setTranslucency?: (payload: { intensity: number }) => void
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
openPreviewInBrowser?: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
settings: {
@@ -285,13 +282,6 @@ export interface HermesConnection {
isFullscreen: boolean
mode?: 'local' | 'remote'
authMode?: 'oauth' | 'token'
// Human-facing host for the statusbar connection pill. For SSH remotes this
// is the user@host the tunnel reaches; for token/oauth remotes it's the host
// parsed from the real backend URL. Absent in local mode.
remoteHost?: string
// Distinguishes an SSH-tunnelled remote ('ssh') from a direct URL remote
// ('url') so the pill can label it SSH: vs Remote:. Absent in local mode.
remoteKind?: 'ssh' | 'url'
nativeOverlayWidth: number
source?: 'env' | 'local' | 'settings'
token: string
@@ -322,66 +312,31 @@ export interface DesktopActiveProfile {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote' | 'ssh'
mode: 'local' | 'remote'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
// Remote-auth fields are always present (the sanitizer fills defaults even in
// local/ssh mode) so consumers can read them without optional-narrowing.
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
// SSH mode fields. Always present on the contract (empty strings / null in
// local/remote mode, populated when mode === 'ssh') so the renderer never
// optional-narrows. No token is surfaced — the dashboard session token is an
// internal artifact reconciled at bootstrap.
sshHost: string
sshUser: string
sshPort: number | null
sshKeyPath: string
sshRemoteHermesPath: string
}
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote' | 'ssh'
mode: 'local' | 'remote'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
// SSH mode input fields.
sshHost?: string
sshUser?: string
sshPort?: number | null
sshKeyPath?: string
sshRemoteHermesPath?: string
}
export interface DesktopConnectionTestResult {
baseUrl?: string
ok?: boolean
version?: string | null
// SSH-mode test result fields.
reachable?: boolean
sshError?: 'unreachable' | 'auth-failed' | 'host-key-changed' | 'hermes-not-found' | 'unsupported-platform' | 'timeout' | 'unknown' | null
error?: string | null
remotePlatform?: string
remoteHermesPath?: string
host?: string
}
export interface DesktopSshResolveResult {
hostname: string | null
user: string | null
port: number | null
identityFile: string | null
}
export interface DesktopSshHostsResult {
hosts: string[]
baseUrl: string
ok: boolean
version: string | null
}
export interface DesktopAuthProvider {
-38
View File
@@ -8,7 +8,6 @@ import type {
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ComputerUseStatus,
ConfigSchemaResponse,
CronJob,
CronJobCreatePayload,
@@ -19,7 +18,6 @@ import type {
HermesConfigRecord,
LogsResponse,
MemoryProviderConfig,
MemoryProviderOAuthStatus,
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
@@ -61,9 +59,6 @@ export type {
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ComputerUseCheck,
ComputerUsePermissionSource,
ComputerUseStatus,
ConfigFieldSchema,
ConfigSchemaResponse,
CronJob,
@@ -78,7 +73,6 @@ export type {
HermesConfigRecord,
LogsResponse,
MemoryProviderConfig,
MemoryProviderOAuthStatus,
MessagingEnvVarInfo,
MessagingHomeChannel,
MessagingPlatformInfo,
@@ -459,23 +453,6 @@ export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }>
})
}
// Memory-provider OAuth connect (provider-keyed; 404s for providers without an
// OAuth flow). Profile-scoped: the grant lands in the active profile's config.
export function startMemoryProviderOAuth(provider: string): Promise<MemoryProviderOAuthStatus> {
return window.hermesDesktop.api<MemoryProviderOAuthStatus>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
method: 'POST'
})
}
export function getMemoryProviderOAuthStatus(provider: string): Promise<MemoryProviderOAuthStatus> {
return window.hermesDesktop.api<MemoryProviderOAuthStatus>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
})
}
export function getSkills(): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
...profileScoped(),
@@ -539,21 +516,6 @@ export function runToolsetPostSetup(name: string, key: string): Promise<ActionRe
})
}
export function getComputerUseStatus(): Promise<ComputerUseStatus> {
return window.hermesDesktop.api<ComputerUseStatus>({
...profileScoped(),
path: '/api/tools/computer-use/status'
})
}
export function grantComputerUsePermissions(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/tools/computer-use/permissions/grant',
method: 'POST'
})
}
export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> {
return window.hermesDesktop.api<MessagingPlatformsResponse>({
path: '/api/messaging/platforms'
+1 -35
View File
@@ -504,36 +504,7 @@ export const en: Translations = {
signOutFailed: 'Sign-out failed',
testFailed: 'Remote gateway test failed',
applyFailed: 'Could not apply gateway settings',
saveFailed: 'Could not save gateway settings',
sshTitle: 'Connect via SSH',
sshDesc:
'Reach a remote Hermes backend over SSH — no exposed dashboard port, no token to copy. Hermes is bootstrapped on the remote and tunneled to this app.',
sshHostTitle: 'Host',
sshHostDesc: 'The SSH target, e.g. user@mac-mini.local or a Host alias from ~/.ssh/config.',
sshUserTitle: 'User',
sshUserDesc: 'SSH username. Leave blank to use ~/.ssh/config or your current user.',
sshUserPlaceholder: 'from ~/.ssh/config',
sshPortTitle: 'Port',
sshPortDesc: 'SSH port. Leave blank for 22 (or the port set in ~/.ssh/config).',
sshKeyTitle: 'Identity file',
sshKeyDesc: 'Optional private key path. Leave blank to use your ssh-agent or ~/.ssh/config.',
sshHermesPathTitle: 'Hermes path (optional)',
sshHermesPathDesc: 'Override where hermes is found on the remote. Leave blank to auto-detect.',
sshHermesPathPlaceholder: 'auto-detect',
sshTestConnection: 'Test SSH',
sshConnect: 'Connect',
sshReachable: (host, platform) => `Reachable: ${host} (${platform}) — Hermes found`,
sshIncompleteHost: 'Enter an SSH host before connecting.',
sshErrUnreachable: 'Could not reach that host over SSH. Check the host, port, and your network.',
sshErrAuth:
'SSH authentication failed. Load your key into the ssh-agent (ssh-add) or set an IdentityFile in ~/.ssh/config — Hermes runs ssh non-interactively.',
sshErrHostKey:
'The host key has CHANGED since you last connected. Verify this is expected, then run ssh-keygen -R <host> and reconnect.',
sshErrNotInstalled:
'Hermes is not installed on the remote host. Install it there (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) or set the Hermes path.',
sshErrPlatform: 'Unsupported remote platform. Hermes Desktop SSH mode supports Linux and macOS remote hosts only.',
sshErrTimeout: 'The SSH connection timed out. The host may be unreachable or asleep.',
sshErrUnknown: 'SSH connection failed.'
saveFailed: 'Could not save gateway settings'
},
keys: {
loading: 'Loading API keys and credentials...',
@@ -1622,10 +1593,6 @@ export const en: Translations = {
backendVersion: version => `Backend v${version}`,
clientLabel: version => `client v${version}`,
backendLabel: version => `backend v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `Remote: ${host}`,
connectionSshTooltip: host => `Connected over SSH to ${host} · click to manage`,
connectionRemoteTooltip: host => `Connected to remote backend ${host} · click to manage`,
commit: sha => `commit ${sha}`,
branch: branch => `branch ${branch}`,
closeCommandCenter: 'Close Command Center',
@@ -1704,7 +1671,6 @@ export const en: Translations = {
opening: 'Opening...',
hide: 'Hide',
openPreview: 'Open preview',
openInBrowser: 'Open in browser',
sourceLineTitle: 'Click to select · shift-click to extend · drag to composer',
source: 'SOURCE',
renderedPreview: 'PREVIEW',
+1 -35
View File
@@ -631,36 +631,7 @@ export const ja = defineLocale({
signOutFailed: 'サインアウトに失敗しました',
testFailed: 'リモートゲートウェイのテストに失敗しました',
applyFailed: 'ゲートウェイ設定を適用できませんでした',
saveFailed: 'ゲートウェイ設定を保存できませんでした',
sshTitle: 'SSH で接続',
sshDesc:
'SSH 経由でリモートの Hermes バックエンドに接続します。ダッシュボードポートの公開もトークンのコピーも不要です。リモート側で Hermes を起動し、このアプリにトンネルします。',
sshHostTitle: 'ホスト',
sshHostDesc: 'SSH の接続先。例: user@mac-mini.local、または ~/.ssh/config の Host エイリアス。',
sshUserTitle: 'ユーザー',
sshUserDesc: 'SSH ユーザー名。空欄の場合は ~/.ssh/config または現在のユーザーを使用します。',
sshUserPlaceholder: '~/.ssh/config から',
sshPortTitle: 'ポート',
sshPortDesc: 'SSH ポート。空欄の場合は 22(または ~/.ssh/config の設定)。',
sshKeyTitle: '鍵ファイル',
sshKeyDesc: '秘密鍵のパス(任意)。空欄の場合は ssh-agent または ~/.ssh/config を使用します。',
sshHermesPathTitle: 'Hermes パス(任意)',
sshHermesPathDesc: 'リモート上の hermes の場所を上書きします。空欄の場合は自動検出します。',
sshHermesPathPlaceholder: '自動検出',
sshTestConnection: 'SSH をテスト',
sshConnect: '接続',
sshReachable: (host, platform) => `接続可能: ${host}(${platform})— Hermes を検出`,
sshIncompleteHost: '接続する前に SSH ホストを入力してください。',
sshErrUnreachable: 'SSH でそのホストに到達できませんでした。ホスト、ポート、ネットワークを確認してください。',
sshErrAuth:
'SSH 認証に失敗しました。鍵を ssh-agent に読み込む(ssh-add)か、~/.ssh/config に IdentityFile を設定してください。Hermes は非対話的に ssh を実行します。',
sshErrHostKey:
'前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R <host> を実行してから再接続してください。',
sshErrNotInstalled:
'リモートホストに Hermes がインストールされていません。リモートでインストールする(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)か、Hermes パスを設定してください。',
sshErrPlatform: 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux と macOS のリモートホストのみ対応しています。',
sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。',
sshErrUnknown: 'SSH 接続に失敗しました。'
saveFailed: 'ゲートウェイ設定を保存できませんでした'
},
keys: {
loading: 'API キーと認証情報を読み込み中...',
@@ -1751,10 +1722,6 @@ export const ja = defineLocale({
backendVersion: version => `バックエンド v${version}`,
clientLabel: version => `クライアント v${version}`,
backendLabel: version => `バックエンド v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `リモート: ${host}`,
connectionSshTooltip: host => `SSH 経由で ${host} に接続中 · クリックして管理`,
connectionRemoteTooltip: host => `リモートバックエンド ${host} に接続中 · クリックして管理`,
commit: sha => `コミット ${sha}`,
branch: branch => `ブランチ ${branch}`,
closeCommandCenter: 'コマンドセンターを閉じる',
@@ -1833,7 +1800,6 @@ export const ja = defineLocale({
opening: '開いています...',
hide: '非表示',
openPreview: 'プレビューを開く',
openInBrowser: 'ブラウザで開く',
sourceLineTitle: 'クリックして選択 · Shift クリックで拡張 · コンポーザーにドラッグ',
source: 'ソース',
renderedPreview: 'プレビュー',
-30
View File
@@ -396,31 +396,6 @@ export interface Translations {
testFailed: string
applyFailed: string
saveFailed: string
sshTitle: string
sshDesc: string
sshHostTitle: string
sshHostDesc: string
sshUserTitle: string
sshUserDesc: string
sshUserPlaceholder: string
sshPortTitle: string
sshPortDesc: string
sshKeyTitle: string
sshKeyDesc: string
sshHermesPathTitle: string
sshHermesPathDesc: string
sshHermesPathPlaceholder: string
sshTestConnection: string
sshConnect: string
sshReachable: (host: string, platform: string) => string
sshIncompleteHost: string
sshErrUnreachable: string
sshErrAuth: string
sshErrHostKey: string
sshErrNotInstalled: string
sshErrPlatform: string
sshErrTimeout: string
sshErrUnknown: string
}
keys: {
loading: string
@@ -1255,10 +1230,6 @@ export interface Translations {
backendVersion: (version: string) => string
clientLabel: (version: string) => string
backendLabel: (version: string) => string
connectionSsh: (host: string) => string
connectionRemote: (host: string) => string
connectionSshTooltip: (host: string) => string
connectionRemoteTooltip: (host: string) => string
commit: (sha: string) => string
branch: (branch: string) => string
closeCommandCenter: string
@@ -1337,7 +1308,6 @@ export interface Translations {
opening: string
hide: string
openPreview: string
openInBrowser: string
sourceLineTitle: string
source: string
renderedPreview: string
+1 -35
View File
@@ -610,36 +610,7 @@ export const zhHant = defineLocale({
signOutFailed: '登出失敗',
testFailed: '遠端閘道測試失敗',
applyFailed: '無法套用閘道設定',
saveFailed: '無法儲存閘道設定',
sshTitle: '透過 SSH 連線',
sshDesc:
'透過 SSH 連線到遠端 Hermes 後端——無需公開儀表板連接埠,也無需複製權杖。Hermes 會在遠端主機上啟動並透過通道連線到本應用程式。',
sshHostTitle: '主機',
sshHostDesc: 'SSH 目標,例如 user@mac-mini.local,或 ~/.ssh/config 中的 Host 別名。',
sshUserTitle: '使用者',
sshUserDesc: 'SSH 使用者名稱。留空則使用 ~/.ssh/config 或目前使用者。',
sshUserPlaceholder: '來自 ~/.ssh/config',
sshPortTitle: '連接埠',
sshPortDesc: 'SSH 連接埠。留空則為 22(或 ~/.ssh/config 中設定的連接埠)。',
sshKeyTitle: '金鑰檔案',
sshKeyDesc: '選用的私密金鑰路徑。留空則使用 ssh-agent 或 ~/.ssh/config。',
sshHermesPathTitle: 'Hermes 路徑(選用)',
sshHermesPathDesc: '覆寫遠端主機上 hermes 的位置。留空則自動偵測。',
sshHermesPathPlaceholder: '自動偵測',
sshTestConnection: '測試 SSH',
sshConnect: '連線',
sshReachable: (host, platform) => `可連線:${host}(${platform})——已找到 Hermes`,
sshIncompleteHost: '連線前請輸入 SSH 主機。',
sshErrUnreachable: '無法透過 SSH 連線到該主機。請檢查主機、連接埠和網路。',
sshErrAuth:
'SSH 驗證失敗。請將金鑰載入 ssh-agent(ssh-add),或在 ~/.ssh/config 中設定 IdentityFile——Hermes 以非互動方式執行 ssh。',
sshErrHostKey:
'自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R <host> 並重新連線。',
sshErrNotInstalled:
'遠端主機上未安裝 Hermes。請在遠端安裝(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或設定 Hermes 路徑。',
sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式僅支援 Linux 和 macOS 遠端主機。',
sshErrTimeout: 'SSH 連線逾時。主機可能無法存取或處於睡眠狀態。',
sshErrUnknown: 'SSH 連線失敗。'
saveFailed: '無法儲存閘道設定'
},
keys: {
loading: '正在載入 API 金鑰和憑證...',
@@ -1694,10 +1665,6 @@ export const zhHant = defineLocale({
backendVersion: version => `後端 v${version}`,
clientLabel: version => `用戶端 v${version}`,
backendLabel: version => `後端 v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `遠端: ${host}`,
connectionSshTooltip: host => `已透過 SSH 連線到 ${host} · 點擊管理`,
connectionRemoteTooltip: host => `已連線到遠端後端 ${host} · 點擊管理`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,
closeCommandCenter: '關閉命令中心',
@@ -1776,7 +1743,6 @@ export const zhHant = defineLocale({
opening: '開啟中...',
hide: '隱藏',
openPreview: '開啟預覽',
openInBrowser: '在瀏覽器中開啟',
sourceLineTitle: '點擊選取 · shift 點擊擴展 · 拖曳至輸入框',
source: '原始碼',
renderedPreview: '預覽',
+1 -35
View File
@@ -698,36 +698,7 @@ export const zh: Translations = {
signOutFailed: '退出登录失败',
testFailed: '远程网关测试失败',
applyFailed: '无法应用网关设置',
saveFailed: '无法保存网关设置',
sshTitle: '通过 SSH 连接',
sshDesc:
'通过 SSH 连接到远程 Hermes 后端——无需暴露面板端口,也无需复制令牌。Hermes 会在远程主机上启动并通过隧道连接到本应用。',
sshHostTitle: '主机',
sshHostDesc: 'SSH 目标,例如 user@mac-mini.local,或 ~/.ssh/config 中的 Host 别名。',
sshUserTitle: '用户',
sshUserDesc: 'SSH 用户名。留空则使用 ~/.ssh/config 或当前用户。',
sshUserPlaceholder: '来自 ~/.ssh/config',
sshPortTitle: '端口',
sshPortDesc: 'SSH 端口。留空则为 22(或 ~/.ssh/config 中设置的端口)。',
sshKeyTitle: '密钥文件',
sshKeyDesc: '可选的私钥路径。留空则使用 ssh-agent 或 ~/.ssh/config。',
sshHermesPathTitle: 'Hermes 路径(可选)',
sshHermesPathDesc: '覆盖远程主机上 hermes 的位置。留空则自动检测。',
sshHermesPathPlaceholder: '自动检测',
sshTestConnection: '测试 SSH',
sshConnect: '连接',
sshReachable: (host, platform) => `可连接:${host}(${platform})——已找到 Hermes`,
sshIncompleteHost: '连接前请输入 SSH 主机。',
sshErrUnreachable: '无法通过 SSH 连接到该主机。请检查主机、端口和网络。',
sshErrAuth:
'SSH 认证失败。请将密钥加载到 ssh-agent(ssh-add),或在 ~/.ssh/config 中设置 IdentityFile——Hermes 以非交互方式运行 ssh。',
sshErrHostKey:
'自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R <host> 并重新连接。',
sshErrNotInstalled:
'远程主机上未安装 Hermes。请在远程安装(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或设置 Hermes 路径。',
sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式仅支持 Linux 和 macOS 远程主机。',
sshErrTimeout: 'SSH 连接超时。主机可能无法访问或处于休眠状态。',
sshErrUnknown: 'SSH 连接失败。'
saveFailed: '无法保存网关设置'
},
keys: {
loading: '正在加载 API 密钥和凭据...',
@@ -1799,10 +1770,6 @@ export const zh: Translations = {
backendVersion: version => `后端 v${version}`,
clientLabel: version => `客户端 v${version}`,
backendLabel: version => `后端 v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `远程: ${host}`,
connectionSshTooltip: host => `已通过 SSH 连接到 ${host} · 点击管理`,
connectionRemoteTooltip: host => `已连接到远程后端 ${host} · 点击管理`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,
closeCommandCenter: '关闭命令中心',
@@ -1881,7 +1848,6 @@ export const zh: Translations = {
opening: '正在打开...',
hide: '隐藏',
openPreview: '打开预览',
openInBrowser: '在浏览器中打开',
sourceLineTitle: '点击选择 · shift 点击扩展 · 拖到输入框',
source: '源码',
renderedPreview: '预览',
-22
View File
@@ -5,7 +5,6 @@ import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopGitRoot,
desktopFsCacheKey,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileText,
@@ -114,25 +113,4 @@ describe('desktop filesystem facade', () => {
expect(remoteSelect).not.toHaveBeenCalled()
expect(selectPaths).not.toHaveBeenCalled()
})
it('cache key distinguishes two SSH hosts that share the same local forwarded port', () => {
// Both remotes resolve to the same loopback tunnel baseUrl (the local
// forwarded port is reusable across remotes). Without the remoteHost in the
// identity these collide and one host's cached fs reads serve the other.
$connection.set({ mode: 'remote', baseUrl: 'http://127.0.0.1:50001', remoteHost: 'jonny@mac-mini' } as never)
const keyA = desktopFsCacheKey()
$connection.set({ mode: 'remote', baseUrl: 'http://127.0.0.1:50001', remoteHost: 'jonny@ubuntu-box' } as never)
const keyB = desktopFsCacheKey()
expect(keyA).not.toBe(keyB)
expect(keyA).toContain('mac-mini')
expect(keyB).toContain('ubuntu-box')
})
it('cache key falls back to baseUrl when no remoteHost is present', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://box.tail1234.ts.net' } as never)
expect(desktopFsCacheKey()).toContain('box.tail1234.ts.net')
$connection.set(null)
expect(desktopFsCacheKey()).toBe('local:')
})
})
+1 -8
View File
@@ -21,14 +21,7 @@ function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
// The remote host is part of the cache identity, NOT just the baseUrl. Local
// forwarded ports are reusable across different remotes, so two SSH hosts
// that happen to map to the same 127.0.0.1:<localPort> would otherwise
// collide — serving one host's cached fs reads for the other. remoteHost is
// the user@host (SSH) or the real backend host (token/oauth); fall back to
// baseUrl for safety.
const host = connection.remoteHost || connection.baseUrl || ''
return `${connection.mode || 'local'}:${connection.profile || ''}:${host}:${connection.baseUrl || ''}`
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
}
export function desktopFsCacheKey() {
@@ -32,13 +32,4 @@ describe('extractEmbeddedImages', () => {
expect(result.cleanedText).toBe('first mid tail')
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL, second])
})
it('handles multi-megabyte data URLs without overflowing the JS stack', () => {
const hugeDataUrl = 'data:image/png;base64,' + 'A'.repeat(8_000_000)
const result = extractEmbeddedImages(`describe this ${hugeDataUrl} thanks`)
expect(result.cleanedText).toBe('describe this thanks')
expect(result.images).toHaveLength(1)
expect(result.images[0]).toHaveLength(hugeDataUrl.length)
})
})
+13 -112
View File
@@ -1,11 +1,7 @@
const EMBEDDED_IMAGE_RE =
/(\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*")?(data:image\/[\w.+-]+;base64,[A-Za-z0-9+/=]{64,})("\s*\}\s*\})?/g
const DATA_URL_RE = /^data:([\w./+-]+);base64,(.*)$/i
const DATA_IMAGE_PREFIX = 'data:image/'
const BASE64_MARKER = ';base64,'
const MIN_EMBEDDED_IMAGE_BASE64_LENGTH = 64
const JSON_IMAGE_OPEN_RE = /\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*"$/
const JSON_IMAGE_CLOSE_RE = /^"\s*\}\s*\}/
const JSON_IMAGE_OPEN_MAX = 96
const JSON_IMAGE_CLOSE_MAX = 16
export const DATA_IMAGE_URL_RE = /^data:image\/[\w.+-]+;base64,/i
@@ -35,119 +31,24 @@ export function dataUrlToBlob(dataUrl: string): Blob | null {
}
}
function isImageMimeCode(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
code === 43 ||
code === 45 ||
code === 46 ||
code === 95
)
}
function isBase64Code(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
code === 43 ||
code === 47 ||
code === 61
)
}
function readDataImageUrl(text: string, start: number): { end: number; url: string } | null {
if (!text.startsWith(DATA_IMAGE_PREFIX, start)) {
return null
}
let cursor = start + DATA_IMAGE_PREFIX.length
while (cursor < text.length && isImageMimeCode(text.charCodeAt(cursor))) {
cursor += 1
}
if (cursor === start + DATA_IMAGE_PREFIX.length || !text.startsWith(BASE64_MARKER, cursor)) {
return null
}
cursor += BASE64_MARKER.length
const base64Start = cursor
while (cursor < text.length && isBase64Code(text.charCodeAt(cursor))) {
cursor += 1
}
if (cursor - base64Start < MIN_EMBEDDED_IMAGE_BASE64_LENGTH) {
return null
}
return { end: cursor, url: text.slice(start, cursor) }
}
function embeddedImageRemovalRange(text: string, dataStart: number, dataEnd: number): { end: number; start: number } {
let start = dataStart
let end = dataEnd
const openSearchStart = Math.max(0, dataStart - JSON_IMAGE_OPEN_MAX)
const openMatch = text.slice(openSearchStart, dataStart).match(JSON_IMAGE_OPEN_RE)
if (openMatch?.index !== undefined) {
const close = text.slice(dataEnd, dataEnd + JSON_IMAGE_CLOSE_MAX).match(JSON_IMAGE_CLOSE_RE)
if (close) {
start = openSearchStart + openMatch.index
end = dataEnd + close[0].length
}
}
return { end, start }
}
function normalizeCleanedText(text: string): string {
return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
if (!text || !text.includes(DATA_IMAGE_PREFIX)) {
if (!text || !text.includes('data:image/')) {
return { cleanedText: text, images: [] }
}
const images: string[] = []
const pieces: string[] = []
let appendCursor = 0
let searchCursor = 0
while (searchCursor < text.length) {
const dataStart = text.indexOf(DATA_IMAGE_PREFIX, searchCursor)
const cleanedText = text
.replace(EMBEDDED_IMAGE_RE, (_match, _open, dataUrl: string) => {
images.push(dataUrl)
if (dataStart === -1) {
break
}
return ''
})
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
const dataUrl = readDataImageUrl(text, dataStart)
if (!dataUrl) {
searchCursor = dataStart + DATA_IMAGE_PREFIX.length
continue
}
const range = embeddedImageRemovalRange(text, dataStart, dataUrl.end)
pieces.push(text.slice(appendCursor, range.start))
images.push(dataUrl.url)
appendCursor = range.end
searchCursor = range.end
}
if (!images.length) {
return { cleanedText: text, images: [] }
}
pieces.push(text.slice(appendCursor))
return { cleanedText: normalizeCleanedText(pieces.join('')), images }
return { cleanedText, images }
}
export function embeddedImageUrls(text: string): string[] {
-2
View File
@@ -61,7 +61,6 @@ import {
IconDots as MoreHorizontal,
IconDots as MoreHorizontalIcon,
IconDotsVertical as MoreVertical,
IconServer as Network,
IconNotebook as NotebookTabs,
IconPackage as Package,
IconPalette as Palette,
@@ -164,7 +163,6 @@ export {
MoreHorizontal,
MoreHorizontalIcon,
MoreVertical,
Network,
NotebookTabs,
Package,
Palette,
-2
View File
@@ -32,14 +32,12 @@ const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
export const FILE_BROWSER_PANE_ID = 'file-browser'
export const PREVIEW_PANE_ID = 'preview'
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
ensurePaneRegistered(PREVIEW_PANE_ID, { open: true })
export const $sidebarOpen: ReadableAtom<boolean> = computed(
$paneStates,
-2
View File
@@ -76,7 +76,6 @@ function persist(states: Record<string, PaneStateSnapshot>) {
}
export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
export const $paneHoverRevealSuppressed = atom(false)
$paneStates.subscribe(persist)
@@ -144,4 +143,3 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]
export const setPaneHoverRevealSuppressed = (suppressed: boolean) => $paneHoverRevealSuppressed.set(suppressed)
@@ -1,41 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$previewStatusBySession,
clearPreviewArtifacts,
dismissPreviewArtifact,
recordPreviewArtifact
} from './preview-status'
beforeEach(() => $previewStatusBySession.set({}))
describe('recordPreviewArtifact', () => {
it('appends new targets newest-last and is idempotent', () => {
recordPreviewArtifact('s1', '/a/index.html', '/work')
recordPreviewArtifact('s1', '/a/about.html', '/work')
recordPreviewArtifact('s1', '/a/index.html', '/work')
expect($previewStatusBySession.get().s1.map(i => i.id)).toEqual(['/a/index.html', '/a/about.html'])
})
it('caps the list and derives a label', () => {
for (const n of [1, 2, 3, 4, 5]) {
recordPreviewArtifact('s1', `/a/p${n}.html`, '/work')
}
const list = $previewStatusBySession.get().s1
expect(list).toHaveLength(4)
expect(list[0].id).toBe('/a/p2.html')
expect(list[3].label).toBe('p5.html')
})
it('dismiss and clear remove rows', () => {
recordPreviewArtifact('s1', '/a/index.html', '/work')
recordPreviewArtifact('s1', '/a/about.html', '/work')
dismissPreviewArtifact('s1', '/a/index.html')
expect($previewStatusBySession.get().s1.map(i => i.id)).toEqual(['/a/about.html'])
clearPreviewArtifacts('s1')
expect($previewStatusBySession.get().s1).toBeUndefined()
})
})
-79
View File
@@ -1,79 +0,0 @@
import { atom } from 'nanostores'
import { previewName } from '@/lib/preview-targets'
/**
* Session-scoped feed of previewable artifacts (HTML files, localhost dev URLs)
* a tool produced. Surfaced as compact links in the composer status stack —
* NOT auto-opened and NOT a bulky inline card. Click opens the rail preview or
* the browser; both are manual.
*
* Fed from the tool row itself (see tool-fallback.tsx) using the same detected
* target the inline card used, so detection parity is exact.
*/
export interface PreviewArtifact {
/** cwd captured at detection so a relative path still resolves on click. */
cwd: string
/** Dedupe key + display id (the raw target). */
id: string
label: string
target: string
}
const MAX_PER_SESSION = 4
export const $previewStatusBySession = atom<Record<string, PreviewArtifact[]>>({})
const writePreviews = (sid: string, items: PreviewArtifact[]) => {
const current = $previewStatusBySession.get()
if (items.length === 0) {
if (!current[sid]) {
return
}
const next = { ...current }
delete next[sid]
$previewStatusBySession.set(next)
return
}
$previewStatusBySession.set({ ...current, [sid]: items })
}
/**
* Record a detected artifact, newest last, capped. Idempotent: a target already
* in the list keeps its slot (the tool row re-registers on every render, so this
* must not churn the atom or reorder rows).
*/
export function recordPreviewArtifact(sid: string, target: string, cwd: string) {
const raw = target.trim()
if (!sid || !raw) {
return
}
const list = $previewStatusBySession.get()[sid] ?? []
if (list.some(item => item.id === raw)) {
return
}
writePreviews(sid, [...list, { cwd, id: raw, label: previewName(raw), target: raw }].slice(-MAX_PER_SESSION))
}
export function dismissPreviewArtifact(sid: string, id: string) {
const list = $previewStatusBySession.get()[sid]
if (list) {
writePreviews(
sid,
list.filter(item => item.id !== id)
)
}
}
export function clearPreviewArtifacts(sid: string) {
writePreviews(sid, [])
}
+1 -4
View File
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import { $paneOpen } from './panes'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import {
$filePreviewTabs,
$filePreviewTarget,
@@ -70,14 +69,12 @@ describe('preview store', () => {
setCurrentSessionPreviewTarget(target, 'tool-result')
expect($previewTarget.get()).toEqual(withRenderMode(target, 'preview'))
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(target, 'preview'))
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('/work/demo.html')
dismissPreviewTarget()
expect($previewTarget.get()).toBeNull()
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(false)
expect(getSessionPreviewRecord('session-1')).toBeNull()
expect($sessionPreviewRegistry.get()['session-1']?.[0]?.dismissedAt).toEqual(expect.any(Number))
+3 -24
View File
@@ -1,13 +1,6 @@
import { atom, computed } from 'nanostores'
import {
$rightRailActiveTabId,
PREVIEW_PANE_ID,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
selectRightRailTab
} from './layout'
import { setPaneOpen } from './panes'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, selectRightRailTab } from './layout'
import { $activeSessionId, $selectedStoredSessionId } from './session'
export interface PreviewTarget {
@@ -95,15 +88,10 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
)
}
function showLivePreviewTab() {
setPaneOpen(PREVIEW_PANE_ID, true)
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
export function setPreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($previewTarget.get(), target)) {
if (target) {
showLivePreviewTab()
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
return
@@ -112,7 +100,7 @@ export function setPreviewTarget(target: PreviewTarget | null) {
$previewTarget.set(target)
if (target) {
showLivePreviewTab()
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
}
@@ -127,7 +115,6 @@ function openFilePreviewTarget(target: PreviewTarget) {
const tab: FilePreviewTab = { id, target }
$filePreviewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item)))
setPaneOpen(PREVIEW_PANE_ID, true)
selectRightRailTab(id)
}
@@ -385,8 +372,6 @@ export function dismissPreviewTarget() {
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0)
}
function closeFilePreviewTab(tabId: RightRailTabId) {
@@ -408,10 +393,6 @@ function closeFilePreviewTab(tabId: RightRailTabId) {
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
if (next.length === 0 && !$previewTarget.get()) {
setPaneOpen(PREVIEW_PANE_ID, false)
}
}
export function closeRightRailTab(tabId: RightRailTabId) {
@@ -435,14 +416,12 @@ export function closeRightRail() {
}
$filePreviewTabs.set([])
setPaneOpen(PREVIEW_PANE_ID, false)
}
export function clearSessionPreviewRegistry() {
$sessionPreviewRegistry.set({})
setPreviewTarget(null)
$filePreviewTabs.set([])
setPaneOpen(PREVIEW_PANE_ID, false)
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
+3
View File
@@ -264,6 +264,7 @@
);
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
--ui-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
--ui-selection-background: color-mix(in srgb, #ffd24a 55%, transparent);
@@ -407,6 +408,7 @@
--backdrop-invert-mul: 0;
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
--ui-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
--ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent);
}
@@ -1178,6 +1180,7 @@ canvas {
}
[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code {
border: 0.0625rem solid var(--ui-inline-code-border);
background: var(--ui-inline-code-background);
color: var(--ui-inline-code-foreground);
}
-52
View File
@@ -98,13 +98,6 @@ export interface OAuthPollResponse {
status: 'approved' | 'denied' | 'error' | 'expired' | 'pending'
}
export interface MemoryProviderOAuthStatus {
auth: 'apikey' | 'oauth' | null
connected: boolean
detail: string
state: 'connected' | 'error' | 'idle' | 'pending'
}
export interface EnvVarInfo {
advanced: boolean
category: string
@@ -586,51 +579,6 @@ export interface ToolsetConfig {
active_provider: string | null
}
/** Shape of `GET /api/tools/computer-use/status`.
*
* cua-driver runs on macOS, Windows, and Linux. `ready` is the single OS-aware
* readiness signal: on macOS both TCC grants (Accessibility + Screen
* Recording, which attach to cua-driver's own `com.trycua.driver` identity,
* not Hermes); elsewhere, driver health from `cua-driver doctor`. `null`
* means unknown (binary missing / probe failed). */
export interface ComputerUsePermissionSource {
attribution?: string
executable?: string
note?: string
pid?: number
responsible_ppid?: number
}
export interface ComputerUseCheck {
label: string
status: string
message: string
}
export interface ComputerUseStatus {
/** `sys.platform`: "darwin" | "win32" | "linux" | ... */
platform: string
/** cua-driver has a runtime backend for this platform. */
platform_supported: boolean
/** cua-driver binary resolved on PATH. */
installed: boolean
/** e.g. "cua-driver 0.5.1", or null when unknown. */
version: string | null
/** Unified readiness — both TCC grants (macOS) or driver health (else). */
ready: boolean | null
/** Whether a permission grant flow exists (macOS-only TCC). */
can_grant: boolean
/** Cross-platform `cua-driver doctor` probes. */
checks: ComputerUseCheck[]
/** macOS TCC detail — `null` off macOS or when unknown. */
accessibility: boolean | null
screen_recording: boolean | null
screen_recording_capturable: boolean | null
source: ComputerUsePermissionSource | null
/** Populated when the status probe itself failed. */
error: string | null
}
export interface SessionSearchResult {
/** Lineage root of the matched conversation. Stable across compression and
* used as the durable pin id; falls back to session_id when absent. */
+93 -2
View File
@@ -31,7 +31,7 @@ except ImportError: # pragma: no cover - non-Windows
msvcrt = None
from datetime import datetime, timedelta
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_constants import get_default_hermes_root, get_hermes_home
from typing import Optional, Dict, List, Any, Union
logger = logging.getLogger(__name__)
@@ -49,7 +49,7 @@ except ImportError:
# Configuration
# =============================================================================
HERMES_DIR = get_hermes_home().resolve()
HERMES_DIR = get_default_hermes_root().resolve()
CRON_DIR = HERMES_DIR / "cron"
JOBS_FILE = CRON_DIR / "jobs.json"
# Heartbeat file the in-process ticker touches on every loop iteration. The
@@ -248,6 +248,12 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]:
state = "scheduled" if normalized.get("enabled", True) else "paused"
normalized["state"] = state
# Legacy jobs (created before per-job profile scoping) have no profile
# field. Default them to "default" so the scheduler treats them as
# root-profile jobs — matching their pre-existing behaviour.
prof = normalized.get("profile")
normalized["profile"] = (str(prof).strip() if isinstance(prof, str) and prof.strip() else "default")
return normalized
@@ -268,6 +274,43 @@ def _secure_file(path: Path):
pass
def current_profile_name() -> str:
"""Return the active profile name for the process creating a job.
``~/.hermes`` -> ``"default"``
``~/.hermes/profiles/X`` -> ``"X"``
Used at create time to tag a job with the profile whose environment
(.env / config.yaml / credentials) it should execute under, so the
job runs as its owning profile regardless of which profile's ticker
picks it up from the shared root store (#32091).
"""
try:
from agent.file_safety import _resolve_active_profile_name
return _resolve_active_profile_name() or "default"
except Exception:
return "default"
def resolve_profile_home(profile_name: Optional[str]) -> Optional[Path]:
"""Map a job's ``profile`` name to the HERMES_HOME it should run under.
``"default"`` / empty / ``None`` -> the root home (``get_default_hermes_root()``).
``"<name>"`` -> ``<root>/profiles/<name>``.
Returns ``None`` when the named profile directory does not exist, so the
scheduler can fall back to the ticker's own home and log a warning rather
than pointing a job at a missing profile.
"""
name = (profile_name or "").strip()
if not name or name == "default":
return get_default_hermes_root().resolve()
candidate = (get_default_hermes_root() / "profiles" / name).resolve()
if candidate.is_dir():
return candidate
return None
def ensure_dirs():
"""Ensure cron directories exist with secure permissions."""
CRON_DIR.mkdir(parents=True, exist_ok=True)
@@ -615,10 +658,44 @@ def get_ticker_success_age() -> Optional[float]:
# Job CRUD Operations
# =============================================================================
_WARNED_ORPHAN_STORE = False
def _warn_if_orphaned_profile_store() -> None:
"""Loudly warn (once) if the root store is empty but a profile-local
jobs.json exists from before #32091's root-anchoring fix.
Such a file is now unreachable (the store anchors at the default root, not
the active profile). The jobs in it were already orphaned pre-fix (the
profile-less gateway never read them), so this is not a regression — but a
user who could SEE them in `cron list` under their profile would otherwise
find them silently gone. Point them at the path instead of failing silent.
"""
global _WARNED_ORPHAN_STORE
if _WARNED_ORPHAN_STORE:
return
try:
active = get_hermes_home().resolve()
if active == HERMES_DIR:
return # not in a profile; nothing could be orphaned
legacy = active / "cron" / "jobs.json"
if legacy.exists():
_WARNED_ORPHAN_STORE = True
logger.warning(
"Cron jobs now live at %s (shared across profiles). A legacy "
"profile-local store exists at %s and is no longer read; "
"re-create those jobs or move them into the root store. (#32091)",
JOBS_FILE, legacy,
)
except Exception:
pass # best-effort advisory; never block load_jobs
def load_jobs() -> List[Dict[str, Any]]:
"""Load all jobs from storage."""
ensure_dirs()
if not JOBS_FILE.exists():
_warn_if_orphaned_profile_store()
return []
_strict_retry = False # track whether we used the strict=False fallback
@@ -738,6 +815,7 @@ def create_job(
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
no_agent: bool = False,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a new cron job.
@@ -782,6 +860,13 @@ def create_job(
and deliver its stdout directly. Empty stdout = silent (no
delivery). Requires ``script`` to be set. Ideal for classic
watchdogs and periodic alerts that don't need LLM reasoning.
profile: Optional Hermes profile name the job should EXECUTE under
(its .env / config.yaml / credentials). Defaults to the active
profile of the session creating the job. The shared root store
holds every profile's jobs (#32091); this field is what scopes
a job's runtime environment to its owning profile so it runs
with that profile's permissions regardless of which ticker
picks it up.
Returns:
The created job dict
@@ -816,6 +901,11 @@ def create_job(
normalized_toolsets = normalized_toolsets or None
normalized_workdir = _normalize_workdir(workdir)
normalized_no_agent = bool(no_agent)
# Tag the job with the profile whose environment it should execute under.
# When the caller does not pass one explicitly, capture the active profile
# of the session creating the job so a job created under `hermes -p donna`
# runs as donna even though it now lives in the shared root store (#32091).
normalized_profile = (str(profile).strip() if isinstance(profile, str) else "") or current_profile_name()
# no_agent jobs are meaningless without a script — the script IS the job.
# Surface this as a clear ValueError at create time so bad configs never
@@ -869,6 +959,7 @@ def create_job(
"origin": origin, # Tracks where job was created for "origin" delivery
"enabled_toolsets": normalized_toolsets,
"workdir": normalized_workdir,
"profile": normalized_profile,
}
with _jobs_lock():
+70 -9
View File
@@ -316,9 +316,17 @@ def _get_hermes_home() -> Path:
def _get_lock_paths() -> tuple[Path, Path]:
"""Resolve cron lock paths at call time so profile/env changes are honored."""
hermes_home = _get_hermes_home()
lock_dir = hermes_home / "cron"
"""Resolve cron lock paths at call time so profile/env changes are honored.
Anchored on the DEFAULT ROOT home (not the active profile), matching the
jobs store in cron.jobs (which uses get_default_hermes_root). The tick lock
is storage-coordination — it must live next to the single jobs.json so that
tickers running under different profiles share one lock and can't
double-fire the relocated store (#32091). Execution context (.env,
config.yaml, scripts) stays profile-aware via _get_hermes_home().
"""
from hermes_constants import get_default_hermes_root
lock_dir = (_hermes_home or get_default_hermes_root()) / "cron"
return lock_dir, lock_dir / ".tick.lock"
@@ -1849,6 +1857,32 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
os.environ["TERMINAL_CWD"] = _job_workdir
logger.info("Job '%s': using workdir %s", job_id, _job_workdir)
# Scope this job's execution to its owning profile's HERMES_HOME (#32091).
# The shared root store holds every profile's jobs, but a job must run with
# the .env / config.yaml / credentials of the profile that created it — not
# whichever profile's ticker happened to pick it up. We set both the
# in-process ContextVar override (consumed by _get_hermes_home() for the
# config/.env/script loads below) AND os.environ["HERMES_HOME"] (inherited
# by any child subprocess the agent spawns). tick() routes profile-scoped
# jobs to the single-worker sequential pool, so mutating os.environ here is
# safe — they never overlap. Restored in the finally block.
from cron.jobs import resolve_profile_home
from hermes_constants import set_hermes_home_override
_job_profile = (job.get("profile") or "default").strip() or "default"
_profile_home = resolve_profile_home(_job_profile)
_prior_hermes_home = os.environ.get("HERMES_HOME", "_UNSET_")
_hermes_home_token = None
if _profile_home is not None and _profile_home != _get_hermes_home().resolve():
os.environ["HERMES_HOME"] = str(_profile_home)
_hermes_home_token = set_hermes_home_override(str(_profile_home))
logger.info("Job '%s': executing under profile %r (HERMES_HOME=%s)",
job_id, _job_profile, _profile_home)
elif _profile_home is None and _job_profile != "default":
logger.warning(
"Job '%s': profile %r no longer exists — running under the "
"ticker's profile instead", job_id, _job_profile,
)
try:
# Re-read .env and config.yaml fresh every run so provider/key
# changes take effect without a gateway restart.
@@ -2260,6 +2294,19 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
os.environ.pop("TERMINAL_CWD", None)
else:
os.environ["TERMINAL_CWD"] = _prior_terminal_cwd
# Restore HERMES_HOME to the ticker's value when this job overrode it
# for profile-scoped execution (#32091). Mirrors the TERMINAL_CWD
# restore above; the sequential pool guarantees no overlap.
if _hermes_home_token is not None:
try:
from hermes_constants import reset_hermes_home_override
reset_hermes_home_override(_hermes_home_token)
except Exception:
pass
if _prior_hermes_home == "_UNSET_":
os.environ.pop("HERMES_HOME", None)
else:
os.environ["HERMES_HOME"] = _prior_hermes_home
# Clean up ContextVar session/delivery state for this job.
clear_session_vars(_ctx_tokens)
for _var_name in _cron_delivery_vars:
@@ -2465,12 +2512,26 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
body."""
return run_one_job(job, adapters=adapters, loop=loop, verbose=verbose)
# Partition due jobs: those with a per-job workdir mutate
# os.environ["TERMINAL_CWD"] inside run_job, which is process-global —
# so they MUST run sequentially to avoid corrupting each other. Jobs
# without a workdir leave env untouched and stay parallel-safe.
sequential_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()]
parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()]
# Partition due jobs: those that mutate process-global os.environ
# inside run_job MUST run sequentially to avoid corrupting each other.
# Two cases mutate env:
# - a per-job workdir sets os.environ["TERMINAL_CWD"].
# - a per-job profile whose HERMES_HOME differs from the ticker's
# sets os.environ["HERMES_HOME"] to scope execution (#32091).
# Jobs that need neither leave env untouched and stay parallel-safe.
def _needs_sequential(j: dict) -> bool:
if (j.get("workdir") or "").strip():
return True
prof = (j.get("profile") or "default").strip() or "default"
try:
from cron.jobs import resolve_profile_home
phome = resolve_profile_home(prof)
except Exception:
phome = None
return phome is not None and phome != _get_hermes_home().resolve()
sequential_jobs = [j for j in due_jobs if _needs_sequential(j)]
parallel_jobs = [j for j in due_jobs if not _needs_sequential(j)]
_results: list = []
_all_futures: list = []
+2 -2
View File
@@ -36,13 +36,13 @@ import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_constants import get_hermes_home
from hermes_constants import get_default_hermes_root
from hermes_time import now as _hermes_now
from utils import atomic_replace
logger = logging.getLogger(__name__)
CRON_DIR = get_hermes_home().resolve() / "cron"
CRON_DIR = get_default_hermes_root().resolve() / "cron"
SUGGESTIONS_FILE = CRON_DIR / "suggestions.json"
# In-process lock protecting load->modify->save cycles (the background review
+1 -84
View File
@@ -300,90 +300,7 @@ enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md`
---
## 7. Per-instance delivery & the management plane (Phase 6)
Phases 1–5 treat the connector as a single-tenant front: inbound events for a
tenant fan out to that tenant's gateway socket(s). **Phase 6 makes delivery
per-INSTANCE** — a shared bot can front many users/agents in one tenant (one
Discord guild, one Telegram bot) without cross-delivery — and adds a small
**management plane** the agent (or a managed Portal) uses to declare who-sees-what
and what's-relevant. All of this lives **connector-side**; the gateway's only new
responsibility is to **declare its relevance policy** at boot (§7.3).
### 7.1 The delivery gate (connector-side, informational)
For each inbound event the connector decides which instances receive it by
composing three AND-ed filters. The gateway does not implement these — they run
in the connector — but they define the delivery semantics the gateway relies on:
| Layer | Question | Source of truth |
| --- | --- | --- |
| **owner / scope ∧ principal** | May this instance *see* this author here? | per-user `user_id → instance` bindings (the owner floor) + per-instance `(guild, channel)` scope grants + an `owner-only` / `allow-list` / `any` principal policy. |
| **visibility floor** | Can the instance's bound owner actually `VIEW_CHANNEL` this in Discord? | live Discord ACL (effective permissions), fail-closed. Narrows an over-broad scope grant downward. |
| **relevance** | *Given* it may see it, should the agent engage? | the relevance policy declared in §7.3 (address-gating / free-response / allow-bots). |
The composition only ever **narrows** delivery (`deliver ⇔ authorized ∧ visible
∧ relevant`); the **owner floor bypasses the relevance layer** (an author's own
message always reaches their own instance — you don't @mention your own agent).
A message authored by an unbound user reaches no instance (fail-closed). The
full design + invariants live in the connector repo
(`NousResearch/gateway-gateway`); this section is the gateway-facing summary.
### 7.2 Management routes (connector-side, authenticated)
The connector mounts authenticated management routes. They share the **same
dual-auth** as the WS upgrade: either a managed NAS-signed `aud=agent:{instanceId}`
RS256 JWT, **or** the gateway's own per-gateway secret bearer (§6.1
`make_upgrade_token`). In both cases the connector resolves the authoritative
`{tenant, instanceId}` from its **stored** record — **never** from the request
body (a body-asserted `instanceId` is ignored).
| Route | Purpose |
| --- | --- |
| `POST /manage/link` | Issue a short-lived code to bind a platform account to the authenticated instance (the `/link <code>` flow; the connector reads the authentic `user_id` off the inbound event). |
| `POST /manage/scope`, `/manage/scope/release` | Claim / release a `(guild, channel)` scope for the authenticated instance. A channel is owned by at most one instance (non-overlap is a PK constraint). |
| `POST /manage/principal` | Set the instance's principal policy (`owner-only` \| `allow-list` \| `any`). |
| `POST /manage/dm-default` | Set the user's DM-default instance (DM tie-break when a user linked more than one). |
| `POST /relay/policy` | Declare the instance's **relevance policy** (§7.3). |
These are connector-owned (the management plane is not part of the gateway's
agent path); the gateway only calls `POST /relay/policy` (§7.3). The others are
driven by the managed Portal / `hermes` CLI.
### 7.3 Relevance-policy declaration (the gateway's responsibility)
The relevance layer (§7.1) is the per-tenant parity for the gateway's own
behaviour knobs (`require_mention`, `free_response_channels`,
`{PLATFORM}_ALLOW_BOTS`). So the **same** behaviour governs relay delivery, the
gateway projects those knobs into a **platform-agnostic** policy and POSTs it to
`POST /relay/policy` at boot (after its per-gateway secret is resolved).
Body (`gateway/relay/__init__.py` `relay_relevance_policy()` → `send_relay_policy()`):
| Field | Type | Projected from | Meaning |
| --- | --- | --- | --- |
| `platform` | string | the fronted platform (`relay_platform_identity`) | which platform this policy applies to. |
| `requireAddress` | bool | `require_mention` | a non-owner message must @mention / reply-to the bot to be relevant. |
| `freeResponseScopes` | string[] | `free_response_channels` | scope (channel) ids where `requireAddress` is waived. Same scope vocabulary as §7.1's scope grants. |
| `allowOtherBots` | bool | `{PLATFORM}_ALLOW_BOTS ∈ {mentions, all}` | admit bot-authored messages (default off). |
Auth is the per-gateway upgrade token (§6.1), so the connector attaches the
policy to the authenticated instance. The gateway is the **source of truth** and
re-declares **every boot** (a full replace, mirroring the `routeKeys` upsert at
provision — self-healing). When the projected policy is all-default the gateway
sends nothing (the connector's absent-row default already matches). The POST is
**fail-soft**: a failure logs and boot proceeds — relevance is an optimization
layered on the authorization gate (§7.1), never a boot dependency. There is **no
new gateway inbound surface** and **no new credential** — it reuses the
per-gateway secret and the same host as `/relay/provision`.
> A relevance drop happens **before** the connector wakes a scaled-to-zero agent
> (Phase 5), so excluded chatter never spins an agent up — relevance is the
> primary scale-to-zero lever as well as a correctness filter.
---
## 8. Versioning policy
## 7. Versioning policy
- `contract_version` is an int; bump **only** for additive changes during the
experimental phase (new optional fields, new `op`s).
-190
View File
@@ -170,100 +170,6 @@ def _provision_url(relay_dial_url: str) -> str:
return f"{raw}/relay/provision"
def _policy_url(relay_dial_url: str) -> str:
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…/relay/policy`` POST URL.
Same host derivation as ``_provision_url``; the connector mounts the
relevance-policy update channel at ``/relay/policy`` (Phase 6 Unit ζ).
"""
raw = relay_dial_url.rstrip("/")
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://"):]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://"):]
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return f"{raw}/relay/policy"
def relay_relevance_policy() -> Optional[dict]:
"""Project this gateway's RELEVANCE config into the connector's generic vocabulary.
The connector's relevance gate (Phase 6 Unit ζ) reasons over a
platform-agnostic policy — ``requireAddress`` / ``freeResponseScopes`` /
``allowOtherBots`` — NOT over Discord/Telegram words. This is the gateway
side of that contract: it reads the agent's existing relevance knobs and
emits the generic shape the connector stores per-instance.
Mapping (the connector vocabulary ← the gateway's existing config):
- ``requireAddress`` ← the platform's ``require_mention`` (the agent
only engages a non-owner message that @mentions it / replies to it).
- ``freeResponseScopes`` ← the platform's ``free_response_channels`` (the
channel/scope ids where ``require_mention`` is waived — same scope
vocabulary the connector's δ scope grants + ε floor use).
- ``allowOtherBots`` ← ``{PLATFORM}_ALLOW_BOTS`` in {"mentions","all"}
(whether bot-authored messages are admitted; default off).
Read from the relay platform's config block (the platform the connector
fronts, e.g. ``discord:``), falling back to the bridged top-level keys, then
the ``{PLATFORM}_*`` env. Returns the generic dict, or None when relay isn't
configured or the platform exposes no relevance knobs (⇒ the connector's
quiet default already matches, so there's nothing to declare).
"""
platform, _bot_id = relay_platform_identity()
if not platform or platform == "relay":
# No concrete fronted platform resolved ⇒ nothing platform-specific to project.
return None
# Resolve the platform's config block + the bridged top-level keys.
require_mention = None
free_response: list[str] = []
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = _load_gateway_config() or {}
plat_cfg = cfg.get(platform)
if not isinstance(plat_cfg, dict):
plat_cfg = ((cfg.get("gateway") or {}).get("platforms") or {}).get(platform)
if not isinstance(plat_cfg, dict):
plat_cfg = (cfg.get("platforms") or {}).get(platform)
plat_cfg = plat_cfg if isinstance(plat_cfg, dict) else {}
if "require_mention" in plat_cfg:
require_mention = plat_cfg.get("require_mention")
elif cfg.get("require_mention") is not None:
require_mention = cfg.get("require_mention")
frc = plat_cfg.get("free_response_channels")
if frc is None:
frc = cfg.get("free_response_channels")
if isinstance(frc, (list, tuple)):
free_response = [str(c).strip() for c in frc if str(c).strip()]
elif isinstance(frc, str) and frc.strip():
free_response = [c.strip() for c in frc.split(",") if c.strip()]
except Exception: # noqa: BLE001 - config absence/parse must never crash boot
pass
# allow_other_bots ← {PLATFORM}_ALLOW_BOTS in {"mentions","all"} (same gate as
# the gateway's own authz_mixin DISCORD_ALLOW_BOTS bypass).
allow_bots_env = os.environ.get(f"{platform.upper()}_ALLOW_BOTS", "").lower().strip()
allow_other_bots = allow_bots_env in {"mentions", "all"}
require_address = bool(require_mention) if require_mention is not None else False
# Nothing non-default to declare ⇒ let the connector keep its quiet default
# (matches absence-of-row semantics on the connector side).
if not require_address and not free_response and not allow_other_bots:
return None
return {
"platform": platform,
"requireAddress": require_address,
"freeResponseScopes": free_response,
"allowOtherBots": allow_other_bots,
}
def _post_provision(
*,
provision_url: str,
@@ -440,102 +346,6 @@ def self_provision_relay() -> bool:
return True
def _post_policy(*, policy_url: str, token: str, policy: dict, timeout: float = 15.0) -> int:
"""POST the relevance policy to the connector's ``/relay/policy``; return the HTTP status.
Authenticated with the gateway's own per-gateway upgrade token (the SAME
bearer shape as the WS upgrade — ``make_upgrade_token``), so the connector
resolves ``{tenant, instanceId}`` from its stored secret record, never the
body. Raises RuntimeError on transport failure (the caller treats any
failure as non-fatal — relevance is an optimization, not a boot dependency).
"""
import json
import urllib.error
import urllib.request
data = json.dumps(policy).encode("utf-8")
req = urllib.request.Request(
policy_url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return int(resp.status)
except urllib.error.HTTPError as exc:
return int(exc.code)
except urllib.error.URLError as exc:
raise RuntimeError(f"could not reach connector: {exc.reason}") from exc
def send_relay_policy() -> bool:
"""Declare this gateway's relevance policy to the connector (Phase 6 Unit ζ).
Runs at boot AFTER the per-gateway secret is resolved (self-provisioned or
pinned), projecting the agent's relevance config into the generic vocabulary
(``relay_relevance_policy``) and POSTing it to ``/relay/policy`` with the
gateway's own upgrade token. The connector stores it per-instance and the
relevance gate enforces it on delivery — so the SAME mention-gating /
free-response / allow-bots behavior the agent applies directly also governs
relay delivery, and excluded traffic never wakes a scaled-to-zero agent.
Self-healing: the agent is the source of truth and re-declares every boot
(mirrors the ``routeKeys`` upsert at provision). Idempotent — a full replace.
NEVER raises and NEVER blocks boot: relevance is an optimization layered on
the δ/ε authorization gate (which already protects isolation), so a failed
declaration just means the connector keeps the prior/quiet policy. Returns
True iff the connector accepted the policy (HTTP 200).
"""
import logging
logger = logging.getLogger("gateway.relay")
dial_url = relay_url()
if not dial_url:
return False
gateway_id, secret = relay_connection_auth()
if not gateway_id or not secret:
# No resolved per-gateway secret (unenrolled / provision failed) ⇒ we
# can't authenticate the policy POST; skip quietly (the WS upgrade would
# be unauthenticated too, so there's no instance to attach a policy to).
return False
policy = relay_relevance_policy()
if policy is None:
# Nothing non-default to declare ⇒ the connector's quiet default already
# matches; don't write a redundant row.
logger.info("relay policy: no non-default relevance config to declare; using connector default")
return False
try:
from gateway.relay.auth import make_upgrade_token
token = make_upgrade_token(gateway_id, secret)
status = _post_policy(policy_url=_policy_url(dial_url), token=token, policy=policy)
except Exception as exc: # noqa: BLE001 - boot must survive a policy-declare failure
logger.warning("relay policy declaration failed (%s); connector keeps prior/default policy", exc)
return False
if status == 200:
logger.info(
"relay policy declared (platform=%s require_address=%s free_scopes=%d allow_bots=%s)",
policy.get("platform"),
policy.get("requireAddress"),
len(policy.get("freeResponseScopes") or []),
policy.get("allowOtherBots"),
)
return True
logger.warning("relay policy declaration returned HTTP %s; connector keeps prior/default policy", status)
return False
def register_relay_adapter(force: bool = False, url: Optional[str] = None) -> bool:
"""Register the generic ``relay`` platform via the platform registry.
-6
View File
@@ -5508,7 +5508,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
register_relay_adapter,
relay_url,
self_provision_relay,
send_relay_policy,
)
# Boot-time relay self-provision: resolve the agent's NAS token ->
@@ -5520,11 +5519,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if register_relay_adapter():
logger.info("relay adapter registered (connector at %s)", relay_url())
# Declare this gateway's relevance policy (mention-gating /
# free-response / allow-bots) to the connector so the SAME
# behavior governs relay delivery (Phase 6 Unit ζ). Runs after
# the secret is resolved; never raises, never blocks boot.
send_relay_policy()
except Exception:
logger.warning(
"relay adapter registration failed at gateway startup", exc_info=True,
+7
View File
@@ -120,6 +120,9 @@ def cron_list(show_all: bool = False):
workdir = job.get("workdir")
if workdir:
print(f" Workdir: {workdir}")
_prof = job.get("profile")
if _prof and _prof != "default":
print(f" Profile: {_prof}")
# Execution history
last_status = job.get("last_status")
@@ -259,6 +262,7 @@ def cron_create(args):
script=getattr(args, "script", None),
workdir=getattr(args, "workdir", None),
no_agent=getattr(args, "no_agent", False) or None,
profile=getattr(args, "profile", None),
)
if not result.get("success"):
print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED))
@@ -275,6 +279,9 @@ def cron_create(args):
print(" Mode: no-agent (script stdout delivered directly)")
if job_data.get("workdir"):
print(f" Workdir: {job_data['workdir']}")
_prof = job_data.get("profile")
if _prof and _prof != "default":
print(f" Profile: {_prof}")
print(f" Next run: {result['next_run_at']}")
return 0
-62
View File
@@ -12507,33 +12507,6 @@ def main():
action="store_true",
help="Emit the raw structured payload as JSON (same shape as `tools/call`).",
)
computer_use_perms = computer_use_sub.add_parser(
"permissions",
help="Check or grant macOS Accessibility + Screen Recording (macOS)",
description=(
"Computer Use drives the Mac through cua-driver, whose TCC grants\n"
"attach to cua-driver's own identity (com.trycua.driver) — not the\n"
"terminal or the Hermes app. `status` reports the driver's grant\n"
"state; `grant` launches CuaDriver via LaunchServices so the macOS\n"
"permission dialog is attributed to the process that does the work."
),
)
computer_use_perms_sub = computer_use_perms.add_subparsers(
dest="computer_use_perms_action"
)
computer_use_perms_status = computer_use_perms_sub.add_parser(
"status",
help="Report Accessibility + Screen Recording grant state (read-only)",
)
computer_use_perms_status.add_argument(
"--json",
action="store_true",
help="Emit the normalized permission payload as JSON.",
)
computer_use_perms_sub.add_parser(
"grant",
help="Request the grants (opens the dialog attributed to CuaDriver)",
)
def cmd_computer_use(args):
action = getattr(args, "computer_use_action", None)
@@ -12591,41 +12564,6 @@ def main():
json_output=bool(getattr(args, "json", False)),
)
sys.exit(code)
if action == "permissions":
perms_action = getattr(args, "computer_use_perms_action", None)
if perms_action == "grant":
from tools.computer_use.permissions import request_permissions_grant
sys.exit(request_permissions_grant())
if perms_action == "status":
import json as _json
from tools.computer_use.permissions import computer_use_status
st = computer_use_status()
if bool(getattr(args, "json", False)):
print(_json.dumps(st, indent=2, sort_keys=True))
sys.exit(0 if st["ready"] else 1)
if not st["platform_supported"]:
print(f"Computer Use is not supported on {st['platform']}.")
sys.exit(1)
if not st["installed"]:
print("cua-driver: not installed. Run: hermes computer-use install")
sys.exit(1)
glyph = lambda v: "✅" if v is True else ("❌" if v is False else "•") # noqa: E731
print(f"cua-driver: {st['version'] or 'installed'} ({st['platform']})")
if st["can_grant"]: # macOS TCC permissions
print(f" {glyph(st['accessibility'])} Accessibility")
print(f" {glyph(st['screen_recording'])} Screen Recording")
if not st["ready"]:
print(" Grant: hermes computer-use permissions grant")
else: # no TCC model — readiness is driver health
print(f" {glyph(st['ready'])} driver health (no permission toggles on {st['platform']})")
for c in st["checks"]:
if c["status"] != "ok":
print(f" ⚠ {c['label']}: {c['message']}")
if st["error"]:
print(f" ⚠ {st['error']}")
sys.exit(0 if st["ready"] else 1)
computer_use_perms.print_help()
return
# No subcommand → show help
computer_use_parser.print_help()
-83
View File
@@ -1,83 +0,0 @@
"""HTTP routes for memory-provider OAuth connect, mounted by ``web_server``.
Kept out of ``web_server.py`` so the memory feature's surface stays in the
memory layer. Dispatch is by convention: a provider's flow lives at
``plugins.memory.<provider>.oauth_flow`` exposing ``start_loopback_flow_background``
and ``get_flow_status``; a provider without that module simply 404s. No provider
is named here.
"""
from __future__ import annotations
import importlib
from contextlib import contextmanager
from typing import Optional
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/api/memory/providers")
def _resolve_flow(provider: str):
"""Return a provider's OAuth flow module by convention, or raise 404."""
if not provider.isidentifier():
raise HTTPException(status_code=404, detail=f"unknown memory provider {provider!r}")
try:
return importlib.import_module(f"plugins.memory.{provider}.oauth_flow")
except ImportError:
raise HTTPException(status_code=404, detail=f"{provider} does not support OAuth connect")
@contextmanager
def _scope_to_profile(profile: Optional[str]):
"""Scope config resolution to ``profile`` so the flow's eager path resolve
targets that profile's honcho.json. None/""/"current" leaves it untouched."""
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
yield
return
from hermes_cli import profiles as profiles_mod
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
try:
profiles_mod.validate_profile_name(requested)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not profiles_mod.profile_exists(requested):
raise HTTPException(status_code=404, detail=f"Profile '{requested}' does not exist.")
token = set_hermes_home_override(str(profiles_mod.get_profile_dir(requested)))
try:
yield
finally:
reset_hermes_home_override(token)
@router.post("/{provider}/oauth/start")
async def start_memory_oauth(provider: str, profile: Optional[str] = None):
"""Begin a provider's zero-CLI OAuth flow — opens the browser and captures
the grant via the loopback listener. Returns immediately; poll status."""
flow = _resolve_flow(provider)
try:
# The flow resolves its config path eagerly inside this scope; the worker
# thread it spawns outlives the request and the override.
with _scope_to_profile(profile):
return flow.start_loopback_flow_background()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to start {provider} OAuth: {exc}")
@router.get("/{provider}/oauth/status")
async def memory_oauth_status(provider: str, profile: Optional[str] = None):
"""Poll a provider's OAuth flow: idle | pending | connected | error."""
flow = _resolve_flow(provider)
try:
with _scope_to_profile(profile):
return flow.get_flow_status()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to read {provider} OAuth status: {exc}")
+4
View File
@@ -70,6 +70,10 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
"--workdir",
help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).",
)
cron_create.add_argument(
"--profile",
help="Hermes profile the job should EXECUTE under (its .env / config.yaml / credentials). Defaults to the profile that created the job. Jobs live in one shared root store (#32091); this scopes a job's runtime environment to the named profile so it runs with that profile's permissions.",
)
# cron edit
cron_edit = cron_subparsers.add_parser(
-63
View File
@@ -234,11 +234,6 @@ def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock:
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
# Memory-provider OAuth connect routes live in the memory layer, not here.
from hermes_cli.memory_oauth import router as _memory_oauth_router # noqa: E402
app.include_router(_memory_oauth_router)
# ---------------------------------------------------------------------------
# Session token for protecting sensitive endpoints (reveal).
# The desktop shell mints the token and injects it via
@@ -8354,7 +8349,6 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
# Register the mcp-install action log so /api/actions/mcp-install/status works.
_ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log")
_ACTION_LOG_FILES.setdefault("computer-use-grant", "action-computer-use-grant.log")
# ---------------------------------------------------------------------------
@@ -10677,63 +10671,6 @@ async def run_toolset_post_setup(
return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key}
# ---------------------------------------------------------------------------
# Computer Use (cua-driver) — cross-platform readiness + macOS permission grant
#
# cua-driver runs on macOS, Windows, and Linux. The desktop card reflects
# per-OS readiness: on macOS the Accessibility + Screen Recording TCC grants
# (which attach to cua-driver's OWN identity, com.trycua.driver — not Hermes,
# so no app entitlement is involved); elsewhere, driver health from
# `cua-driver doctor`. The grant flow is macOS-only (no TCC toggles to request
# on Windows/Linux).
# ---------------------------------------------------------------------------
@app.get("/api/tools/computer-use/status")
async def get_computer_use_status(profile: Optional[str] = None):
"""Cross-platform Computer Use readiness for the desktop card.
See ``tools.computer_use.permissions.computer_use_status`` for the payload
shape. Read-only and fast (shells ``cua-driver doctor`` + macOS
``permissions status``).
"""
from tools.computer_use.permissions import computer_use_status
with _profile_scope(profile):
return computer_use_status()
@app.post("/api/tools/computer-use/permissions/grant")
async def grant_computer_use_permissions(profile: Optional[str] = None):
"""Spawn ``hermes computer-use permissions grant`` as a background action.
macOS-only: ``cua-driver permissions grant`` launches CuaDriver via
LaunchServices so the TCC dialog is attributed to com.trycua.driver, then
waits for approval. The frontend polls ``GET /api/actions/computer-use-
grant/status`` and re-reads ``/status`` once it exits. Windows/Linux have
no TCC toggles to grant, so this returns 400 there.
"""
if sys.platform != "darwin":
raise HTTPException(
status_code=400,
detail="Computer Use permission grants are a macOS concept.",
)
try:
proc = _spawn_hermes_action(
_profile_cli_args(profile)
+ ["computer-use", "permissions", "grant"],
"computer-use-grant",
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn computer-use permissions grant")
raise HTTPException(
status_code=500, detail=f"Failed to request permissions: {exc}"
)
return {"ok": True, "pid": proc.pid, "name": "computer-use-grant"}
# ---------------------------------------------------------------------------
# Raw YAML config endpoint
# ---------------------------------------------------------------------------
+4 -21
View File
@@ -7,8 +7,7 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi
## Requirements
- `pip install honcho-ai`
- A Honcho Cloud account — connect via OAuth sign-in or an API key from
[app.honcho.dev](https://app.honcho.dev) — or a self-hosted instance
- Honcho API key from [app.honcho.dev](https://app.honcho.dev), or a self-hosted instance
## Setup
@@ -17,11 +16,6 @@ hermes memory setup honcho # configure Honcho directly (works on a fresh insta
hermes memory setup # generic picker, choose Honcho from the list
```
For cloud, the wizard asks **OAuth or API key**. OAuth opens a browser
sign-in and stores the grant itself — nothing to copy; tokens refresh
automatically. The desktop app offers the same flow as a **Connect** link
next to the memory-provider dropdown.
Or manually:
```bash
hermes config set memory.provider honcho
@@ -83,10 +77,6 @@ When `dialecticDepthLevels` is not set, each pass uses a proportional level rela
Override with `dialecticDepthLevels`: an explicit array of reasoning level strings per pass.
### Query-Adaptive Reasoning Level
The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at `reasoningLevelCap` (default `"high"`). Disable with `reasoningHeuristic: false` to pin every auto call to `dialecticReasoningLevel`.
### Three Orthogonal Dialectic Knobs
| Knob | Controls | Type |
@@ -133,8 +123,7 @@ For every key, resolution order is: **host block > root > env var > default**.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `apiKey` | string | — | API key. Falls back to `HONCHO_API_KEY` env var. When connected via OAuth, holds the auto-refreshing access token instead |
| `oauth` | object | — | OAuth grant (refresh token, expiry, client, token endpoint). Written by the Connect/sign-in flows and rotated automatically — not hand-edited. Optional: an API key alone works without it |
| `apiKey` | string | — | API key. Falls back to `HONCHO_API_KEY` env var |
| `baseUrl` | string | — | Base URL for self-hosted Honcho. Local URLs auto-skip API key auth |
| `environment` | string | `"production"` | SDK environment mapping |
| `enabled` | bool | auto | Master toggle. Auto-enables when `apiKey` or `baseUrl` present |
@@ -185,7 +174,7 @@ Pick **[e]** at the prompt to set the three keys directly instead of going throu
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `recallMode` | string | `"hybrid"` | `"hybrid"` (auto-inject + tools), `"context"` (auto-inject only, tools hidden), `"tools"` (tools only, no injection). Legacy `"auto"` → `"hybrid"` |
| `observationMode` | string | `"directional"` | Preset: `"directional"` (all on) or `"unified"` (user observes self, AI observes others). Use `observation` object for granular control |
| `observationMode` | string | `"directional"` | Preset: `"directional"` (all on) or `"unified"` (shared pool). Use `observation` object for granular control |
| `observation` | object | — | Per-peer observation config (see Observation section) |
### Write Behavior
@@ -266,8 +255,6 @@ Host key is derived from the active Hermes profile: `hermes` (default) or `herme
| `dialecticDynamic` | bool | `true` | When `true`, model can override reasoning level per-call via `honcho_reasoning` tool. When `false`, always uses `dialecticReasoningLevel` |
| `dialecticMaxChars` | int | `600` | Max chars of dialectic result injected into system prompt |
| `dialecticMaxInputChars` | int | `10000` | Max chars for dialectic query input to `.chat()`. Honcho cloud limit: 10k |
| `reasoningHeuristic` | bool | `true` | Query-adaptive: auto-scale the auto-injected dialectic's level up by query length (+1 at ≥120 chars, +2 at ≥400), clamped at `reasoningLevelCap`. `false` pins every auto call to `dialecticReasoningLevel` |
| `reasoningLevelCap` | string | `"high"` | Ceiling for `reasoningHeuristic` scaling: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
### Token Budgets
@@ -283,6 +270,7 @@ Host key is derived from the active Hermes profile: `hermes` (default) or `herme
| `contextCadence` | int | `1` | Minimum turns between base context refreshes (session summary + representation + card) |
| `dialecticCadence` | int | `1` | Minimum turns between dialectic `.chat()` firings |
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context on the first user message only, skip from turn 2 onward) |
| `reasoningLevelCap` | string | — | Hard cap on reasoning level: `"minimal"`, `"low"`, `"medium"`, `"high"` |
### Observation (Granular)
@@ -321,11 +309,6 @@ Presets:
| `HONCHO_BASE_URL` | `baseUrl` |
| `HONCHO_ENVIRONMENT` | `environment` |
| `HERMES_HONCHO_HOST` | Host key override |
| `HONCHO_OAUTH_DASHBOARD` | OAuth authorize origin (default: cloud dashboard; local-dev `localhost:3000`) |
| `HONCHO_OAUTH_AUTHORIZE_URL` | Full authorize URL (overrides the dashboard origin) |
| `HONCHO_OAUTH_TOKEN_URL` | Token endpoint (default: cloud API; local-dev `localhost:8000`) |
| `HONCHO_OAUTH_CLIENT_ID` | OAuth client (default `hermes-agent`) |
| `HONCHO_OAUTH_SCOPE` | Requested scope (default `write`) |
## CLI Commands
+14 -72
View File
@@ -622,67 +622,21 @@ def cmd_setup(args) -> None:
)
else:
print("\n No local JWT set. Local no-auth ready.")
use_oauth = False
if not is_local:
# --- Cloud: OAuth (browser) or API key ---
else:
# --- Cloud: set default base URL, require API key ---
cfg.pop("baseUrl", None) # cloud uses SDK default
# Detect an existing OAuth grant so re-running setup reflects it instead
# of looking like a fresh connect.
from plugins.memory.honcho.oauth import OAuthCredential
existing_oauth = OAuthCredential.from_host_block(hermes_host)
current_key = cfg.get("apiKey", "")
masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set")
print(f"\n Current API key: {masked}")
new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True)
if new_key:
cfg["apiKey"] = new_key
print("\n Auth method:")
if existing_oauth is not None:
print(f" (currently connected via OAuth — client {existing_oauth.client_id})")
print(" oauth -- sign in via browser (recommended)")
print(" apikey -- paste an API key from https://app.honcho.dev")
method = _prompt("OAuth or API key?", default="oauth").strip().lower()
use_oauth = method in {"oauth", "o"}
if use_oauth:
# Sign in now, up front — the browser link is the whole point, so
# don't bury it behind the identity prompts. The grant's tokens are
# merged into the in-memory cfg so the wizard's final save preserves
# them; settings stay wizard-owned (apply_config=False).
from plugins.memory.honcho.oauth_flow import authorize_via_loopback
def _open(url: str) -> None:
print(f"\n Open this link to authorize (waiting up to 5 minutes):\n\n {url}\n")
import webbrowser
webbrowser.open(url)
print("\n Starting browser sign-in…")
try:
cred = authorize_via_loopback(
config_path=write_path,
source="hermes-cli",
apply_config=False,
open_url=_open,
)
except Exception as e:
print(f" OAuth sign-in failed: {e}")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
hermes_host["apiKey"] = cred.access_token
hermes_host["oauth"] = cred.oauth_block()
# Default the peer prompt to the name entered at consent.
if cred.consent_peer_name:
hermes_host["peerName"] = cred.consent_peer_name
print(" Authorized — token saved. Let's finish configuring.\n")
else:
current_key = cfg.get("apiKey", "")
masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set")
print(f"\n Current API key: {masked}")
new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True)
if new_key:
cfg["apiKey"] = new_key
if not cfg.get("apiKey"):
print("\n No API key configured. Get yours at https://app.honcho.dev")
print(" Run 'hermes honcho setup' again once you have a key.\n")
return
if not cfg.get("apiKey"):
print("\n No API key configured. Get yours at https://app.honcho.dev")
print(" Run 'hermes honcho setup' again once you have a key.\n")
return
# --- 3. Identity ---
current_peer = hermes_host.get("peerName") or cfg.get("peerName", "")
@@ -832,7 +786,7 @@ def cmd_setup(args) -> None:
current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional")
print("\n Observation mode:")
print(" directional -- all observations on, each AI peer builds its own view (default)")
print(" unified -- user observes self, AI observes others only")
print(" unified -- shared pool, user observes self, AI observes others only")
new_obs = _prompt("Observation mode", default=current_obs)
if new_obs in {"unified", "directional"}:
hermes_host["observationMode"] = new_obs
@@ -1063,12 +1017,6 @@ def cmd_status(args) -> None:
api_key = hcfg.api_key or ""
masked = f"...{api_key[-8:]}" if len(api_key) > 8 else ("set" if api_key else "not set")
# Auth line distinguishes an OAuth grant (refreshable) from a static API key
# — the OAuth access token is also stored under apiKey, so masking alone hides it.
from plugins.memory.honcho.oauth import OAuthCredential
host_block = (getattr(hcfg, "raw", None) or {}).get("hosts", {}).get(hcfg.host) or {}
cred = OAuthCredential.from_host_block(host_block)
profile = _active_profile_name()
profile_label = f" [{hcfg.host}]" if profile != "default" else ""
@@ -1077,13 +1025,7 @@ def cmd_status(args) -> None:
print(f" Profile: {profile}")
print(f" Host: {hcfg.host}")
print(f" Enabled: {hcfg.enabled}")
if cred is not None:
import time as _time
remaining = int(cred.expires_at - _time.time())
token_state = f"valid {remaining // 60}m" if remaining > 0 else "expired — refreshes on next use"
print(f" Auth: OAuth ({cred.client_id}, token {token_state})")
else:
print(f" Auth: API key ({masked})")
print(f" API key: {masked}")
print(f" Workspace: {hcfg.workspace_id}")
# Config paths — show where config was read from and where writes go
+22 -60
View File
@@ -679,11 +679,10 @@ class HonchoClientConfig:
"""Resolve Honcho session name.
Resolution order:
1. Gateway session key (stable per-chat identifier from gateway platforms)
2. per-session strategy — Hermes session_id ({timestamp}_{hex}); authoritative,
so a generated title never remaps a live conversation
3. Manual directory override from sessions map
4. Hermes session title (from /title command; non-per-session)
1. Manual directory override from sessions map
2. Hermes session title (from /title command)
3. Gateway session key (stable per-chat identifier from gateway platforms)
4. per-session strategy — Hermes session_id ({timestamp}_{hex})
5. per-repo strategy — git repo root directory name
6. per-directory strategy — directory basename
7. global strategy — workspace name
@@ -693,27 +692,12 @@ class HonchoClientConfig:
if not cwd:
cwd = os.getcwd()
# Gateway per-chat key wins everywhere — gateways (telegram/discord/…)
# need per-chat isolation no cwd/strategy name can provide.
if gateway_session_key:
sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', gateway_session_key).strip('-')
if sanitized:
return self._enforce_session_id_limit(sanitized, gateway_session_key)
# per-session: the run's session_id IS the identity — resolve before the
# cwd map / title so an auto-generated title can't remap a live
# conversation onto a second Honcho session mid-stream.
if self.session_strategy == "per-session" and session_id:
if self.session_peer_prefix and self.peer_name:
return f"{self.peer_name}-{session_id}"
return session_id
# Manual override (cwd → name), for non-per-session strategies.
# Manual override always wins
manual = self.sessions.get(cwd)
if manual:
return manual
# /title mid-session remap (non-per-session).
# /title mid-session remap
if session_title:
sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', session_title).strip('-')
if sanitized:
@@ -721,6 +705,22 @@ class HonchoClientConfig:
return f"{self.peer_name}-{sanitized}"
return sanitized
# Gateway session key: stable per-chat identifier passed by the gateway
# (e.g. "agent:main:telegram:dm:8439114563"). Sanitize colons to hyphens
# for Honcho session ID compatibility. This takes priority over strategy-
# based resolution because gateway platforms need per-chat isolation that
# cwd-based strategies cannot provide.
if gateway_session_key:
sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', gateway_session_key).strip('-')
if sanitized:
return self._enforce_session_id_limit(sanitized, gateway_session_key)
# per-session: inherit Hermes session_id (new Honcho session each run)
if self.session_strategy == "per-session" and session_id:
if self.session_peer_prefix and self.peer_name:
return f"{self.peer_name}-{session_id}"
return session_id
# per-repo: one Honcho session per git repository
if self.session_strategy == "per-repo":
base = self._git_repo_name(cwd) or Path(cwd).name
@@ -742,39 +742,6 @@ class HonchoClientConfig:
_honcho_client_slot: SingletonSlot = SingletonSlot()
def _apply_fresh_oauth_token(config: HonchoClientConfig) -> None:
"""Refresh a near-expiry OAuth grant and point ``config.api_key`` at it.
No-op for static API keys or when refresh fails (fail-open: the stale token
is left in place and the existing 401 handling degrades gracefully).
"""
try:
from plugins.memory.honcho import oauth
token, _ = oauth.ensure_fresh_token(resolve_config_path(), config.host)
if token:
config.api_key = token
except Exception:
logger.warning("Honcho OAuth pre-build refresh failed", exc_info=True)
def _refresh_cached_oauth(client: "Honcho", config: HonchoClientConfig | None) -> None:
"""Rotate the cached client's Bearer in place when its OAuth token is stale.
If the SDK shape changed and the in-place rotation can't apply, the slot is
reset so the next acquisition rebuilds with the fresh token.
"""
try:
from plugins.memory.honcho import oauth
host = config.host if config is not None else resolve_active_host()
token, refreshed = oauth.ensure_fresh_token(resolve_config_path(), host)
if refreshed and token and not oauth.apply_token_to_client(client, token):
_honcho_client_slot.reset()
except Exception:
logger.warning("Honcho OAuth cached refresh failed", exc_info=True)
def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
"""Get or create the Honcho client singleton.
@@ -787,16 +754,11 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
"""
cached = _honcho_client_slot.peek()
if cached is not None:
_refresh_cached_oauth(cached, config)
return cached
if config is None:
config = HonchoClientConfig.from_global_config()
# Refresh a near-expiry OAuth grant before the first build so the client
# starts with a live access token rather than 401ing an hour in.
_apply_fresh_oauth_token(config)
if not config.api_key and not config.base_url:
raise ValueError(
"Honcho API key not found. "
-371
View File
@@ -1,371 +0,0 @@
"""OAuth credential storage and refresh for the Honcho memory provider.
An access token authenticates exactly like a scoped API key, so it is stored
as the host's ``apiKey``; this module exchanges the refresh token before
expiry to keep it live.
Refresh tokens rotate with single-use reuse detection: a replayed stale token
revokes the whole grant. So every refresh must persist the rotated token
atomically and be serialized — and a failed refresh never raises into the
agent (stale token stays; the fail-open path absorbs the eventual 401).
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
logger = logging.getLogger(__name__)
ACCESS_TOKEN_PREFIX = "hch-at-"
REFRESH_TOKEN_PREFIX = "hch-rt-"
# Refresh this many seconds before the access token actually expires, so an
# in-flight request never races the expiry boundary.
_REFRESH_SKEW_SECONDS = 120
# Default HTTP timeout for the token exchange. Kept short — the refresh happens
# on the path to a memory call, and a stalled auth server must not hang it.
_REFRESH_TIMEOUT_SECONDS = 15.0
# Serializes refresh across threads sharing one process's config. Re-checked
# under the lock (double-checked) so racing callers don't replay a rotated
# refresh token and trip reuse detection.
_refresh_lock = threading.Lock()
@contextmanager
def _config_refresh_lock(path: Path):
"""Machine-wide advisory lock around read-refresh-persist.
The in-process ``_refresh_lock`` can't stop a second process (a sibling
Hermes profile or the desktop app sharing this honcho.json) from replaying
the single-use refresh token and tripping reuse-detection — which revokes
the whole grant. An OS file lock on ``<config>.lock`` serializes rotation
across processes; best-effort, so a platform without flock degrades to
in-process serialization only.
"""
lock_path = Path(f"{path}.lock")
fh = None
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
fh = open(lock_path, "a+b")
if os.name == "nt":
import msvcrt
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
except Exception:
logger.debug("Honcho OAuth cross-process lock unavailable; in-process only", exc_info=True)
if fh is not None:
fh.close()
fh = None
try:
yield
finally:
if fh is not None:
try:
if os.name == "nt":
import msvcrt
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
except Exception:
pass
fh.close()
# In-memory expiry cache keyed by (config path, host) → (expires_at, access).
# Lets the hot path (every memory access calls this) skip the honcho.json read
# while the token is comfortably live; disk is only touched near expiry, on a
# cache miss, or when an explicit ``raw`` is supplied. Single-key dict ops are
# atomic under the GIL, so no separate lock is needed. An access token stays
# valid until its own expiry regardless of out-of-band rotation, so a stale
# cache entry can't break auth — it just defers picking up external changes
# until the token nears expiry and disk is read again.
_expiry_cache: dict[tuple[str, str], tuple[float, str]] = {}
def is_oauth_access_token(value: str | None) -> bool:
"""True when ``value`` is an OAuth access token (vs a static API key)."""
return bool(value) and value.startswith(ACCESS_TOKEN_PREFIX)
@dataclass
class OAuthCredential:
"""An OAuth grant as stored in a honcho.json host block.
``access_token`` mirrors the host's ``apiKey``; the remaining fields live in
the host's ``oauth`` sub-block. ``expires_at`` is absolute epoch seconds.
"""
access_token: str
refresh_token: str
expires_at: float
client_id: str
token_endpoint: str
scope: str = "write"
token_type: str = "Bearer"
# Transient consent peer name — set only on a fresh grant, never persisted.
consent_peer_name: str | None = None
@classmethod
def from_host_block(cls, block: dict[str, Any]) -> "OAuthCredential | None":
"""Build a credential from a honcho.json host block, or None if incomplete."""
oauth = block.get("oauth")
access = block.get("apiKey")
if not isinstance(oauth, dict) or not is_oauth_access_token(access):
return None
refresh = oauth.get("refreshToken")
endpoint = oauth.get("tokenEndpoint")
client_id = oauth.get("clientId")
if not (refresh and endpoint and client_id):
return None
try:
expires_at = float(oauth.get("expiresAt", 0))
except (TypeError, ValueError):
expires_at = 0.0
return cls(
access_token=access,
refresh_token=str(refresh),
expires_at=expires_at,
client_id=str(client_id),
token_endpoint=str(endpoint),
scope=str(oauth.get("scope", "write")),
token_type=str(oauth.get("tokenType", "Bearer")),
)
def oauth_block(self) -> dict[str, Any]:
"""The ``oauth`` sub-block to persist (the access token lives in apiKey)."""
return {
"refreshToken": self.refresh_token,
"expiresAt": int(self.expires_at),
"clientId": self.client_id,
"tokenEndpoint": self.token_endpoint,
"scope": self.scope,
"tokenType": self.token_type,
}
def is_expired(self, *, now: float, skew: float = _REFRESH_SKEW_SECONDS) -> bool:
"""True when the access token is within ``skew`` seconds of expiry."""
return now >= (self.expires_at - skew)
# Indirection so tests can drive the exchange without a live server.
def _http_post_form(url: str, data: dict[str, str], timeout: float) -> dict[str, Any]:
"""POST form-encoded ``data`` to ``url`` and return the parsed JSON body."""
import httpx
resp = httpx.post(url, data=data, timeout=timeout)
resp.raise_for_status()
return resp.json()
def _exchange_refresh_token(cred: OAuthCredential, *, now: float) -> OAuthCredential:
"""Run the refresh_token grant and return the rotated credential.
Raises on any transport/protocol failure; callers fail open.
"""
body = _http_post_form(
cred.token_endpoint,
{
"grant_type": "refresh_token",
"client_id": cred.client_id,
"refresh_token": cred.refresh_token,
},
_REFRESH_TIMEOUT_SECONDS,
)
access = body.get("access_token")
refresh = body.get("refresh_token")
if not is_oauth_access_token(access) or not refresh:
raise ValueError("refresh response missing access_token/refresh_token")
try:
expires_in = int(body.get("expires_in", 0))
except (TypeError, ValueError):
expires_in = 0
return OAuthCredential(
access_token=access,
refresh_token=str(refresh),
expires_at=now + expires_in,
client_id=cred.client_id,
token_endpoint=cred.token_endpoint,
scope=str(body.get("scope", cred.scope)),
token_type=str(body.get("token_type", cred.token_type)),
)
def _read_config(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _atomic_write_config(path: Path, raw: dict[str, Any]) -> None:
"""Write ``raw`` to ``path`` atomically, preserving 0600 on the new file."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.tmp")
text = json.dumps(raw, indent=2) + "\n"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
except Exception:
tmp.unlink(missing_ok=True)
raise
os.replace(tmp, path)
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge ``overlay`` into ``base`` (overlay wins on scalars/lists)."""
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
_deep_merge(base[key], value)
else:
base[key] = value
return base
def _persist_credential(path: Path, host: str, cred: OAuthCredential) -> None:
"""Persist ``cred`` into ``host``'s block (apiKey + oauth), leaving all else intact."""
raw = _read_config(path)
hosts = raw.setdefault("hosts", {})
block = hosts.setdefault(host, {})
block["apiKey"] = cred.access_token
block["oauth"] = cred.oauth_block()
_atomic_write_config(path, raw)
_expiry_cache[(str(path), host)] = (cred.expires_at, cred.access_token)
def ensure_fresh_token(
path: Path,
host: str,
raw: dict[str, Any] | None = None,
*,
now: float | None = None,
) -> tuple[str | None, bool]:
"""Return ``(access_token, refreshed)`` for ``host``, refreshing if near expiry.
Returns ``(None, False)`` when the host has no OAuth credential (e.g. a plain
API key) so callers leave the existing token untouched. Refresh failures are
swallowed: the current (possibly stale) token is returned with
``refreshed=False`` and the fail-open path handles any resulting 401.
"""
now = time.time() if now is None else now
key = (str(path), host)
# Hot path: trust the cached expiry while the token is well clear of the
# skew window — no disk read. Bypassed when an explicit ``raw`` is supplied.
if raw is None:
cached = _expiry_cache.get(key)
if cached is not None and now < cached[0] - _REFRESH_SKEW_SECONDS:
return cached[1], False
source = raw if raw is not None else _read_config(path)
block = (source.get("hosts") or {}).get(host) or {}
cred = OAuthCredential.from_host_block(block)
if cred is None:
_expiry_cache.pop(key, None)
return None, False
_expiry_cache[key] = (cred.expires_at, cred.access_token)
if not cred.is_expired(now=now):
return cred.access_token, False
with _refresh_lock, _config_refresh_lock(path):
# Re-read under both locks: another thread or process may have just
# rotated the token — adopt theirs instead of replaying the old one.
fresh_block = (_read_config(path).get("hosts") or {}).get(host) or {}
current = OAuthCredential.from_host_block(fresh_block) or cred
if not current.is_expired(now=now):
return current.access_token, current.access_token != cred.access_token
try:
rotated = _exchange_refresh_token(current, now=now)
except Exception as exc:
logger.warning("Honcho OAuth refresh failed for host %s: %s", host, exc)
return current.access_token, False
_persist_credential(path, host, rotated)
logger.info("Honcho OAuth token refreshed for host %s", host)
return rotated.access_token, True
def install_grant(
path: Path,
host: str,
grant: dict[str, Any],
*,
client_id: str,
token_endpoint: str,
apply_config: bool = True,
now: float | None = None,
) -> OAuthCredential:
"""Apply a fresh OAuth grant to ``path`` for ``host``.
Deep-merges the grant's ``config`` (the manifest default_config) into the
file root — preserving other hosts and root keys — then writes the host's
``apiKey`` and ``oauth`` block. ``grant`` is an OAuthTokenResponse dict
(access_token, refresh_token, expires_in, scope, config).
``apply_config=False`` skips the config merge and stores tokens only.
"""
now = time.time() if now is None else now
access = grant.get("access_token")
refresh = grant.get("refresh_token")
if not is_oauth_access_token(access) or not refresh:
raise ValueError("grant missing access_token/refresh_token")
try:
expires_in = int(grant.get("expires_in", 0))
except (TypeError, ValueError):
expires_in = 0
cred = OAuthCredential(
access_token=access,
refresh_token=str(refresh),
expires_at=now + expires_in,
client_id=client_id,
token_endpoint=token_endpoint,
scope=str(grant.get("scope", "write")),
token_type=str(grant.get("token_type", "Bearer")),
)
raw = _read_config(path)
granted_config = grant.get("config")
if isinstance(granted_config, dict):
cred.consent_peer_name = granted_config.get("peerName")
if apply_config:
_deep_merge(raw, granted_config)
_expiry_cache[(str(path), host)] = (cred.expires_at, cred.access_token)
hosts = raw.setdefault("hosts", {})
block = hosts.setdefault(host, {})
block["apiKey"] = cred.access_token
block["oauth"] = cred.oauth_block()
_atomic_write_config(path, raw)
return cred
def apply_token_to_client(client: Any, token: str) -> bool:
"""Rotate the live Honcho client's Bearer in place. Returns success.
The SDK builds its auth header per request from the HTTP client's
``api_key``, so mutating it rotates every holder of the singleton without a
rebuild. Guarded: an SDK shape change degrades to False and the caller can
fall back to resetting the client.
"""
http = getattr(client, "_http", None)
if http is None or not hasattr(http, "api_key"):
return False
http.api_key = token
return True
-431
View File
@@ -1,431 +0,0 @@
"""Browser sign-in flow for the Honcho memory provider — no CLI step.
``begin_authorization`` / ``complete_authorization`` are the transport-agnostic
core: the code can arrive via the loopback listener here or a future
``hermes://`` handler. Endpoints are env-overridable with local-dev defaults
because ``/authorize`` (dashboard) and ``/oauth/token`` (API) live on
different origins.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import secrets
import threading
import time
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Callable
from urllib.parse import parse_qs, urlencode, urlparse
from plugins.memory.honcho import oauth
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path
logger = logging.getLogger(__name__)
# The loopback redirect registered for the Hermes OAuth client. IP-literal so
# the browser can't resolve the advertised host to ::1 and miss the IPv4 bind.
LOOPBACK_HOST = "127.0.0.1"
LOOPBACK_PORT = 8765
LOOPBACK_REDIRECT_URI = f"http://{LOOPBACK_HOST}:{LOOPBACK_PORT}/callback"
# Pending authorizations live only until their callback returns; keyed by the
# CSRF ``state`` so a stray/forged callback can't complete a grant.
_PENDING_TTL_SECONDS = 600
def _display_config_path(path: object) -> str:
"""Home-relative display string for the consent screen.
The absolute path (username + home layout) never leaves the machine — it's
only shown to the user. Collapse ``$HOME`` to ``~``; for a path outside
home, send the bare filename rather than leak an arbitrary absolute path.
"""
from pathlib import Path as _Path
p = _Path(str(path))
try:
return "~/" + str(p.relative_to(_Path.home()))
except ValueError:
return p.name
@dataclass(frozen=True)
class OAuthEndpoints:
"""Resolved authorization-server URLs and client identity."""
authorize_url: str # dashboard /authorize
token_url: str # API /oauth/token
client_id: str
scope: str
# Cloud (production) hosts; dashboard serves /authorize, API serves /oauth/token.
_CLOUD_DASHBOARD = "https://app.honcho.dev"
_CLOUD_TOKEN_URL = "https://api.honcho.dev/oauth/token"
_LOCAL_DASHBOARD = "http://localhost:3000"
_LOCAL_TOKEN_URL = "http://localhost:8000/oauth/token"
# One OAuth client for every surface. Consent branding/UI adapt via the
# ``source`` query param (not a separate client_id), so there's a single grant
# identity to refresh — no clientId-vs-refresh-token desync to revoke the grant.
_DEFAULT_CLIENT_ID = "hermes-agent"
def _is_loopback_url(url: str | None) -> bool:
return bool(url) and any(h in url for h in ("localhost", "127.0.0.1", "::1"))
def resolve_endpoints(
environment: str | None = None, base_url: str | None = None
) -> OAuthEndpoints:
"""Resolve OAuth endpoints, zero-config by default.
Keys off the host's honcho ``environment`` (production → cloud, local →
localhost); a self-hosted ``base_url`` derives the token endpoint from the
API host. Env vars override every field for unusual deployments.
"""
if environment is None or base_url is None:
try:
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig.from_global_config()
environment = environment or cfg.environment
base_url = base_url if base_url is not None else cfg.base_url
except Exception:
environment = environment or "production"
is_local = (environment or "").lower() == "local" or _is_loopback_url(base_url)
default_dashboard = _LOCAL_DASHBOARD if is_local else _CLOUD_DASHBOARD
default_token = _LOCAL_TOKEN_URL if is_local else _CLOUD_TOKEN_URL
# Self-hosted API (non-loopback base_url): token rides the same host.
if base_url and not is_local:
default_token = f"{base_url.rstrip('/')}/oauth/token"
dashboard = os.environ.get("HONCHO_OAUTH_DASHBOARD", default_dashboard).rstrip("/")
return OAuthEndpoints(
authorize_url=os.environ.get("HONCHO_OAUTH_AUTHORIZE_URL", f"{dashboard}/authorize"),
token_url=os.environ.get("HONCHO_OAUTH_TOKEN_URL", default_token),
client_id=os.environ.get("HONCHO_OAUTH_CLIENT_ID", _DEFAULT_CLIENT_ID),
scope=os.environ.get("HONCHO_OAUTH_SCOPE", "write"),
)
@dataclass
class _Pending:
verifier: str
redirect_uri: str
created_at: float
_pending: dict[str, _Pending] = {}
_pending_lock = threading.Lock()
def _pkce() -> tuple[str, str]:
"""Return (verifier, S256 challenge) for an authorization-code request."""
verifier = secrets.token_urlsafe(64)
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
return verifier, challenge
def _prune_pending(now: float) -> None:
expired = [s for s, p in _pending.items() if now - p.created_at > _PENDING_TTL_SECONDS]
for state in expired:
_pending.pop(state, None)
def begin_authorization(
endpoints: OAuthEndpoints,
redirect_uri: str = LOOPBACK_REDIRECT_URI,
*,
source: str | None = None,
config_path: str | None = None,
now: float | None = None,
) -> tuple[str, str]:
"""Start an authorization: return ``(authorize_url, state)`` and stash PKCE.
``source`` tags the authorize link with the initiating surface
(``hermes-desktop`` / ``hermes-cli``) so the consent side can attribute
connects and vary behavior per surface. ``config_path`` is a home-relative
*display* string for the consent screen (never the absolute path); callers
pass the actual write path separately to ``complete_authorization``.
"""
now = time.time() if now is None else now
verifier, challenge = _pkce()
state = secrets.token_urlsafe(32)
with _pending_lock:
_prune_pending(now)
_pending[state] = _Pending(verifier=verifier, redirect_uri=redirect_uri, created_at=now)
params = {
"client_id": endpoints.client_id,
"redirect_uri": redirect_uri,
"scope": endpoints.scope,
"code_challenge": challenge,
"code_challenge_method": "S256",
"response_type": "code",
"state": state,
}
if source:
params["source"] = source
if config_path:
params["config_path"] = config_path
return f"{endpoints.authorize_url}?{urlencode(params)}", state
def complete_authorization(
endpoints: OAuthEndpoints,
code: str,
state: str,
*,
config_path: Path | None = None,
host: str | None = None,
apply_config: bool = True,
now: float | None = None,
) -> oauth.OAuthCredential:
"""Exchange ``code`` for a grant and persist it. Raises on bad state/exchange.
``apply_config=False`` stores the tokens only, skipping the grant's config
block — the CLI path, where settings stay wizard-owned.
"""
with _pending_lock:
pending = _pending.pop(state, None)
if pending is None:
raise ValueError("unknown or expired authorization state")
grant = oauth._http_post_form(
endpoints.token_url,
{
"grant_type": "authorization_code",
"client_id": endpoints.client_id,
"code": code,
"redirect_uri": pending.redirect_uri,
"code_verifier": pending.verifier,
},
oauth._REFRESH_TIMEOUT_SECONDS,
)
path = config_path or resolve_config_path()
target_host = host or resolve_active_host()
cred = oauth.install_grant(
path,
target_host,
grant,
client_id=endpoints.client_id,
token_endpoint=endpoints.token_url,
apply_config=apply_config,
now=now,
)
# Drop the singleton so the next acquisition builds with the new token.
from plugins.memory.honcho.client import reset_honcho_client
reset_honcho_client()
logger.info("Honcho OAuth grant installed for host %s", target_host)
return cred
_CALLBACK_HTML = (
b"<!doctype html><meta charset=utf-8>"
b"<title>Honcho connected</title>"
b"<body style='font:14px ui-monospace,monospace;background:#0b0e14;color:#c9d1d9;"
b"display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
b"<div>Connected to Honcho. You can close this tab and return to Hermes.</div>"
)
def _bind_loopback_server() -> tuple[HTTPServer, dict[str, str]]:
"""Bind the one-shot callback server, returning it and its capture dict.
Prefers :8765; if that's taken, falls back to an OS-assigned port. groudon's
redirect matcher relaxes the port for loopback hosts, so the fallback still
matches the seeded ``127.0.0.1`` redirect URI — the caller advertises the
actual bound port.
"""
captured: dict[str, str] = {}
class _Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 - stdlib API name
parsed = urlparse(self.path)
if parsed.path != "/callback":
self.send_response(404)
self.end_headers()
return
params = parse_qs(parsed.query)
captured["code"] = (params.get("code") or [""])[0]
captured["state"] = (params.get("state") or [""])[0]
captured["error"] = (params.get("error") or [""])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(_CALLBACK_HTML)
def log_message(self, *args): # silence stdlib request logging
return
try:
server = HTTPServer((LOOPBACK_HOST, LOOPBACK_PORT), _Handler)
except OSError:
server = HTTPServer((LOOPBACK_HOST, 0), _Handler) # OS-assigned fallback
return server, captured
def capture_loopback_code(
server: HTTPServer, captured: dict[str, str], *, timeout: float = 300.0
) -> tuple[str, str]:
"""Serve a single ``/callback`` GET on ``server`` and return ``(code, state)``.
Replies with a close-this-tab page, then stops. Raises ``TimeoutError`` if no
callback arrives within ``timeout``.
"""
server.timeout = timeout
try:
# handle_request honors server.timeout; loop until our callback lands so a
# stray probe to another path doesn't end the wait empty-handed.
deadline = time.monotonic() + timeout
while "code" not in captured and time.monotonic() < deadline:
server.handle_request()
finally:
server.server_close()
if captured.get("error"):
raise ValueError(f"authorization denied: {captured['error']}")
if "code" not in captured:
raise TimeoutError("no OAuth callback received before timeout")
return captured["code"], captured.get("state", "")
def authorize_via_loopback(
*,
config_path: Path | None = None,
host: str | None = None,
source: str | None = None,
apply_config: bool = True,
open_url: Callable[[str], None] | None = None,
timeout: float = 300.0,
) -> oauth.OAuthCredential:
"""Drive the full loopback flow: open browser → capture code → exchange → persist.
``open_url`` defaults to the system browser; tests inject a driver that
follows the authorize redirect into the loopback callback. It always
receives the authorize URL, so a CLI caller can also print it for
browserless environments.
"""
# Bind first so the advertised redirect_uri carries the actual bound port
# (which may differ from :8765 if it was taken).
server, captured = _bind_loopback_server()
redirect_uri = f"http://{LOOPBACK_HOST}:{server.server_address[1]}/callback"
endpoints = resolve_endpoints()
path = config_path or resolve_config_path()
authorize_url, state = begin_authorization(
endpoints, redirect_uri, source=source, config_path=_display_config_path(path)
)
if open_url is None:
import webbrowser
open_url = webbrowser.open
# Browser opens from a short-lived thread; the socket is already bound, so a
# fast redirect can't beat it.
opener = threading.Thread(target=lambda: open_url(authorize_url), daemon=True)
opener.start()
code, returned_state = capture_loopback_code(server, captured, timeout=timeout)
if returned_state != state:
raise ValueError("OAuth state mismatch — possible CSRF, aborting")
return complete_authorization(
endpoints,
code,
returned_state,
config_path=path,
host=host,
apply_config=apply_config,
)
# — Background launcher + status, for the desktop "Connect" button —
# The flow blocks on a browser round-trip, so the web_server endpoint kicks it
# off in a thread and the UI polls status rather than holding the request open.
@dataclass
class FlowStatus:
state: str = "idle" # idle | pending | connected | error
detail: str = ""
_status = FlowStatus()
_status_lock = threading.Lock()
_flow_thread: threading.Thread | None = None
def _detect_connection() -> tuple[bool, str | None]:
"""Report whether a credential is already stored: 'oauth', 'apikey', or none."""
try:
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig.from_global_config()
block = (cfg.raw.get("hosts") or {}).get(cfg.host) or {}
if oauth.OAuthCredential.from_host_block(block) is not None:
return True, "oauth"
if cfg.api_key:
return True, "apikey"
except Exception:
pass
return False, None
def get_flow_status() -> dict[str, object]:
with _status_lock:
state, detail = _status.state, _status.detail
connected, auth = _detect_connection()
return {"state": state, "detail": detail, "connected": connected, "auth": auth}
def _set_status(state: str, detail: str = "") -> None:
with _status_lock:
_status.state, _status.detail = state, detail
def start_loopback_flow_background(
*,
config_path: Path | None = None,
host: str | None = None,
source: str = "hermes-desktop",
timeout: float = 300.0,
) -> dict[str, str]:
"""Launch the loopback flow in a daemon thread; returns the initial status.
Idempotent while a flow is pending — a second call is a no-op so a
double-clicked button can't open two browser tabs / bind :8765 twice.
"""
global _flow_thread
# Resolve under the caller's profile scope NOW — the worker thread outlives
# the request, where a context-local HERMES_HOME override can't reach.
config_path = config_path or resolve_config_path()
host = host or resolve_active_host()
with _status_lock:
if _status.state == "pending" and _flow_thread and _flow_thread.is_alive():
return {"state": _status.state, "detail": _status.detail}
_status.state, _status.detail = "pending", "waiting for browser consent"
def _run() -> None:
try:
authorize_via_loopback(config_path=config_path, host=host, source=source, timeout=timeout)
_set_status("connected", "Honcho connected")
except Exception as exc:
logger.warning("Honcho OAuth loopback flow failed: %s", exc)
_set_status("error", str(exc))
_flow_thread = threading.Thread(target=_run, name="honcho-oauth-loopback", daemon=True)
_flow_thread.start()
return get_flow_status()
+3 -6
View File
@@ -154,12 +154,9 @@ class HonchoSessionManager:
@property
def honcho(self) -> Honcho:
"""Get the Honcho client, refreshing a near-expiry OAuth token in place.
Routes every access through ``get_honcho_client`` (which returns the same
cached singleton) so a long session can't outlive its 1h access token.
"""
self._honcho = get_honcho_client()
"""Get the Honcho client, initializing if needed."""
if self._honcho is None:
self._honcho = get_honcho_client()
return self._honcho
def _get_or_create_peer(self, peer_id: str) -> Any:
+3 -130
View File
@@ -303,100 +303,6 @@ def _resolve_slack_proxy_url() -> Optional[str]:
return proxy_url
# Map Slack audio mimetypes to the file extension that matches the actual
# container bytes. Critically, Slack's in-app "record a clip" voice messages
# arrive as MP4/AAC containers (``audio/mp4``, filename ``audio_message*.mp4``),
# NOT Ogg — so the extension we cache them under must be one a downstream STT
# backend (OpenAI Whisper / gpt-4o-transcribe) will accept for that container.
# OpenAI sniffs the container from the FILENAME extension, so a wrong extension
# (e.g. caching MP4 bytes as ``.ogg``) makes transcription fail outright.
# Mirrors the proven map in gateway/platforms/bluebubbles.py.
_SLACK_AUDIO_MIME_TO_EXT = {
"audio/ogg": ".ogg",
"audio/opus": ".ogg",
"audio/mpeg": ".mp3",
"audio/mp3": ".mp3",
"audio/wav": ".wav",
"audio/x-wav": ".wav",
"audio/webm": ".webm",
"audio/mp4": ".m4a",
"audio/x-m4a": ".m4a",
"audio/m4a": ".m4a",
"audio/aac": ".m4a",
"audio/flac": ".flac",
"audio/x-flac": ".flac",
}
# Extensions OpenAI/Whisper-family STT backends accept (kept in sync with
# tools/transcription_tools.SUPPORTED_FORMATS).
_SLACK_STT_SUPPORTED_EXTS = frozenset(
{".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".aac", ".flac"}
)
# Cached-extension → reported ``audio/*`` mimetype. Used when re-routing a
# ``video/mp4``-mislabeled voice clip onto the audio path so the reported
# media_type stays coherent with the bytes we actually cached (the gateway's
# STT gate keys on the ``audio/`` prefix + the cached filename extension, but a
# matching mimetype avoids surprising any consumer that inspects it). Anything
# unmapped falls back to ``audio/mp4`` — Slack voice clips are MP4/AAC.
_SLACK_EXT_TO_AUDIO_MIME = {
".mp4": "audio/mp4",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".mpeg": "audio/mpeg",
".mpga": "audio/mpeg",
".wav": "audio/wav",
".webm": "audio/webm",
".ogg": "audio/ogg",
".aac": "audio/aac",
".flac": "audio/flac",
}
def _resolve_slack_audio_ext(file_obj: Dict[str, Any], mimetype: str) -> str:
"""Pick the cache extension that matches an inbound Slack audio file's bytes.
Resolution order (mirrors the video branch + bluebubbles.py):
1. The real extension from the uploaded filename, when it's a format a
Whisper-family STT backend accepts (so ``audio_message.mp4`` →
``.mp4``, ``clip.m4a`` → ``.m4a``).
2. A mimetype → extension lookup (so ``audio/mp4`` → ``.m4a``).
3. ``.m4a`` as a last resort — never ``.ogg``, which was the original bug:
MP4/AAC voice messages cached as ``.ogg`` are rejected by OpenAI because
the bytes don't match the container the extension claims.
"""
name = (file_obj.get("name") or "").strip()
_, name_ext = os.path.splitext(name)
name_ext = name_ext.lower()
if name_ext in _SLACK_STT_SUPPORTED_EXTS:
return name_ext
mime_key = (mimetype or "").split(";", 1)[0].strip().lower()
if mime_key in _SLACK_AUDIO_MIME_TO_EXT:
return _SLACK_AUDIO_MIME_TO_EXT[mime_key]
return ".m4a"
def _is_slack_voice_clip(file_obj: Dict[str, Any]) -> bool:
"""Return True when a Slack file is an audio-only voice clip.
Slack's in-app voice recordings are audio-only MP4 containers, but Slack
sometimes reports them with a ``video/mp4`` mimetype, which would otherwise
route them to video understanding instead of speech-to-text. Detect them by
Slack's stable markers — the ``slack_audio`` subtype and the
``audio_message*`` filename pattern — so genuine videos are left untouched.
"""
subtype = (file_obj.get("subtype") or "").strip().lower()
if subtype == "slack_audio":
# slack_audio is always audio-only. (slack_video clips carry a real
# video track, so they are deliberately NOT matched here.)
return True
name = (file_obj.get("name") or "").strip().lower()
return name.startswith("audio_message")
class SlackAdapter(BasePlatformAdapter):
"""
Slack bot adapter using Socket Mode.
@@ -2731,7 +2637,9 @@ class SlackAdapter(BasePlatformAdapter):
)
elif mimetype.startswith("audio/") and url:
try:
ext = _resolve_slack_audio_ext(f, mimetype)
ext = "." + mimetype.split("/")[-1].split(";")[0]
if ext not in {".ogg", ".mp3", ".wav", ".webm", ".m4a"}:
ext = ".ogg"
cached = await self._download_slack_file(
url, ext, audio=True, team_id=team_id
)
@@ -2749,41 +2657,6 @@ class SlackAdapter(BasePlatformAdapter):
e,
exc_info=True,
)
elif mimetype.startswith("video/") and url and _is_slack_voice_clip(f):
# Slack in-app voice clips are audio-only MP4 containers that
# Slack sometimes mislabels with a ``video/mp4`` mimetype.
# Cache them as audio and report an ``audio/*`` type so the
# gateway routes them to speech-to-text instead of video
# understanding. Without this, voice messages recorded in Slack
# never get transcribed.
try:
ext = _resolve_slack_audio_ext(f, mimetype)
cached = await self._download_slack_file(
url, ext, audio=True, team_id=team_id
)
media_urls.append(cached)
# Report a coherent audio mimetype matching the cached
# extension so downstream STT routing recognizes it.
media_types.append(
_SLACK_EXT_TO_AUDIO_MIME.get(ext, "audio/mp4")
)
logger.debug(
"[Slack] Cached voice clip (mislabeled %s) as audio: %s",
mimetype,
cached,
)
except Exception as e: # pragma: no cover - defensive logging
detail = self._describe_slack_download_failure(e, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning(
"[Slack] Failed to cache voice clip from %s: %s",
url,
e,
exc_info=True,
)
elif mimetype.startswith("video/") and url:
try:
original_filename = f.get("name", "")
-1
View File
@@ -45,7 +45,6 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"jeevesassistant00@gmail.com": "jeeves-assistant", # PR #50771 (computer-use CuaDriver vision capture routing)
"21178861+ScotterMonk@users.noreply.github.com": "ScotterMonk", # PR #50145 salvage (cron output truncation: adapter-aware chunking, #50126)
"rrandqua@gmail.com": "TutkuEroglu", # PR #50481 salvage (AGENTS.md stale token-lock adapter path)
"f@trycua.com": "f-trycua", # PR #50507 salvage (cross-platform computer_use; supersedes #44221/#30660)
-29
View File
@@ -206,35 +206,6 @@ class TestProjectFacts:
assert "Project: package.json" in block
assert "Verify:" not in block
def test_detect_project_facts_structured(self, tmp_path):
(tmp_path / "package.json").write_text(
json.dumps({"scripts": {"test": "vitest", "dev": "vite"}})
)
(tmp_path / "pnpm-lock.yaml").write_text("")
facts = cc.detect_project_facts(tmp_path)
assert facts.manifests == ["package.json"]
assert facts.package_managers == ["pnpm"]
assert facts.verify_commands == ["pnpm run test"] # dev excluded
assert facts.context_files == []
def test_project_facts_for_matches_prompt_block(self, tmp_path):
# Invariant: the structured facts the UI consumes must not drift from the
# commands the prompt snapshot renders — one detector feeds both.
_git_init(tmp_path)
(tmp_path / "package.json").write_text(
json.dumps({"scripts": {"test": "vitest", "lint": "eslint ."}})
)
(tmp_path / "pnpm-lock.yaml").write_text("")
facts = cc.project_facts_for(tmp_path)
assert facts is not None
verify_line = cc.build_coding_workspace_block(tmp_path).split("Verify:")[1].splitlines()[0]
assert facts["verifyCommands"]
for cmd in facts["verifyCommands"]:
assert cmd in verify_line
def test_project_facts_for_none_outside_workspace(self, tmp_path):
assert cc.project_facts_for(tmp_path) is None
# ── $HOME dotfiles guard ────────────────────────────────────────────────────
-110
View File
@@ -1,110 +0,0 @@
"""Tests for agent.oneshot — shared one-off (stateless) LLM requests."""
from unittest.mock import MagicMock, patch
import pytest
from agent.oneshot import (
PROMPT_TEMPLATES,
render_template,
run_oneshot,
_strip_code_fence,
_truncate,
)
class TestRenderTemplate:
def test_unknown_template_raises(self):
with pytest.raises(KeyError):
render_template("does-not-exist", {})
def test_commit_message_template_is_registered(self):
assert "commit_message" in PROMPT_TEMPLATES
def test_commit_message_includes_diff_and_recent(self):
instructions, user = render_template(
"commit_message",
{"diff": "diff --git a/x b/x\n+new", "recent_commits": "feat: a\nfix: b"},
)
# Instructions describe the contract (conventional commits), not a snapshot.
assert "Conventional Commits" in instructions
assert "diff --git a/x b/x" in user
assert "feat: a" in user
def test_commit_message_diff_with_braces_passes_through(self):
# Templates must not use str.format — code payloads carry literal { }.
_, user = render_template("commit_message", {"diff": "x = {a: 1}"})
assert "x = {a: 1}" in user
def test_commit_message_handles_missing_variables(self):
instructions, user = render_template("commit_message", {})
assert instructions
assert "no textual diff available" in user
def test_commit_message_avoid_forces_new_message(self):
# Passing the previous message must instruct the model not to repeat it,
# so "regenerate" yields a different result even on greedy models.
_, plain = render_template("commit_message", {"diff": "d"})
_, regen = render_template("commit_message", {"diff": "d", "avoid": "feat: prior"})
assert "feat: prior" in regen
assert "do not repeat" in regen
assert "feat: prior" not in plain
class TestRunOneshot:
def _mock_response(self, content):
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = content
resp.choices[0].message.reasoning = None
resp.choices[0].message.reasoning_content = None
resp.choices[0].message.reasoning_details = None
return resp
def test_template_path_calls_llm_with_rendered_prompt(self):
with patch(
"agent.oneshot.call_llm",
return_value=self._mock_response("feat: add thing"),
) as llm:
out = run_oneshot(template="commit_message", variables={"diff": "d"})
assert out == "feat: add thing"
messages = llm.call_args.kwargs["messages"]
assert messages[0]["role"] == "system"
assert messages[1]["role"] == "user"
def test_explicit_instructions_path(self):
with patch(
"agent.oneshot.call_llm",
return_value=self._mock_response("hello"),
) as llm:
out = run_oneshot(instructions="be brief", user_input="say hi")
assert out == "hello"
messages = llm.call_args.kwargs["messages"]
assert messages[0]["content"] == "be brief"
assert messages[1]["content"] == "say hi"
def test_requires_template_or_prompt(self):
with pytest.raises(ValueError):
run_oneshot()
def test_strips_wrapping_code_fence(self):
with patch(
"agent.oneshot.call_llm",
return_value=self._mock_response("```\nfix: bug\n```"),
):
assert run_oneshot(instructions="x", user_input="y") == "fix: bug"
class TestHelpers:
def test_truncate_under_limit_unchanged(self):
assert _truncate("short", 100) == "short"
def test_truncate_over_limit_marks_truncation(self):
out = _truncate("x" * 200, 50)
assert out.endswith("…(truncated)")
assert len(out) < 200
def test_strip_code_fence_without_fence_is_noop(self):
assert _strip_code_fence("plain text") == "plain text"
+4 -1
View File
@@ -14,7 +14,10 @@ import pytest
def temp_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME so jobs.json doesn't touch the real store."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# cron.jobs caches no home at import; get_hermes_home() reads the env live.
# NOTE: cron.jobs resolves its store paths (JOBS_FILE, CRON_DIR) from
# get_default_hermes_root() at IMPORT time, so setting HERMES_HOME here does
# not re-point an already-imported module's store. These tests exercise the
# claim logic on in-memory job dicts and don't depend on the on-disk path.
yield tmp_path
+241
View File
@@ -0,0 +1,241 @@
"""Regression tests for #32091 — profile-scoped cron jobs orphaned.
Cron storage (CRON_DIR/JOBS_FILE) must anchor at the *default root* Hermes
home, not the active profile's home. Otherwise a job created from a
profile-scoped agent session writes to ~/.hermes/profiles/<p>/cron/jobs.json,
while the profile-less gateway reads only ~/.hermes/cron/jobs.json — the job
is silently orphaned (looks healthy in `list`, never fires).
"""
import importlib
import os
from pathlib import Path
def test_cron_storage_anchors_at_root_under_profile(tmp_path, monkeypatch):
"""Under a profile HERMES_HOME (<root>/profiles/<name>), the cron store
resolves to <root>/cron, NOT <root>/profiles/<name>/cron."""
root = tmp_path / "hermes_home"
profile_home = root / "profiles" / "myprofile"
profile_home.mkdir(parents=True)
# Pretend the platform default root IS our tmp root, and the active
# HERMES_HOME is a profile under it (the #32091 scenario).
import hermes_constants
monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home",
lambda: root)
monkeypatch.setenv("HERMES_HOME", str(profile_home))
# get_default_hermes_root must return the ROOT, not the profile dir.
assert hermes_constants.get_default_hermes_root().resolve() == root.resolve()
# ...while get_hermes_home (used elsewhere) follows the profile override.
assert hermes_constants.get_hermes_home().resolve() == profile_home.resolve()
# cron/jobs.py computes HERMES_DIR from get_default_hermes_root at import,
# so a fresh import under this env anchors the store at <root>/cron.
import cron.jobs as jobs
importlib.reload(jobs)
try:
assert jobs.HERMES_DIR.resolve() == root.resolve()
assert jobs.JOBS_FILE.resolve() == (root / "cron" / "jobs.json").resolve()
# The orphan path (<profile>/cron/jobs.json) must NOT be the store.
assert jobs.JOBS_FILE.resolve() != (profile_home / "cron" / "jobs.json").resolve()
finally:
# Restore module state for other tests (reload under the real env).
monkeypatch.undo()
importlib.reload(jobs)
def test_cron_storage_unaffected_when_no_profile(tmp_path, monkeypatch):
"""With no profile (HERMES_HOME == root), behavior is unchanged: store at
<root>/cron."""
root = tmp_path / "hermes_home"
root.mkdir(parents=True)
import hermes_constants
monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home",
lambda: root)
monkeypatch.setenv("HERMES_HOME", str(root))
import cron.jobs as jobs
importlib.reload(jobs)
try:
assert jobs.JOBS_FILE.resolve() == (root / "cron" / "jobs.json").resolve()
finally:
monkeypatch.undo()
importlib.reload(jobs)
def test_tick_lock_anchors_at_root_under_profile(tmp_path, monkeypatch):
"""The cron tick lock must live at <root>/cron/.tick.lock, NOT the profile
dir — otherwise tickers under different profiles grab different locks and
double-fire the (now root-anchored) jobs store (#32091)."""
import importlib
root = tmp_path / "hermes_home"
profile_home = root / "profiles" / "p"
profile_home.mkdir(parents=True)
import hermes_constants
monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", lambda: root)
monkeypatch.setenv("HERMES_HOME", str(profile_home))
import cron.scheduler as sched
importlib.reload(sched)
try:
# _hermes_home override is None -> uses get_default_hermes_root()
sched._hermes_home = None
lock_dir, lock_file = sched._get_lock_paths()
assert lock_dir.resolve() == (root / "cron").resolve()
assert lock_file.resolve() == (root / "cron" / ".tick.lock").resolve()
assert lock_dir.resolve() != (profile_home / "cron").resolve()
finally:
monkeypatch.undo()
importlib.reload(sched)
def test_get_default_hermes_root_docker_layouts(tmp_path, monkeypatch):
"""get_default_hermes_root resolves the root for Docker/custom HERMES_HOME
(outside ~/.hermes), so cron storage works in containers."""
import hermes_constants
native = tmp_path / "native_home"
monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", lambda: native)
# Docker custom root (outside native): HERMES_HOME itself IS the root.
monkeypatch.setenv("HERMES_HOME", "/opt/data")
assert hermes_constants.get_default_hermes_root() == Path("/opt/data")
# Docker profile layout: <custom>/profiles/<name> -> <custom>.
monkeypatch.setenv("HERMES_HOME", "/opt/data/profiles/coder")
assert hermes_constants.get_default_hermes_root() == Path("/opt/data")
# ---------------------------------------------------------------------------
# Per-job profile EXECUTION scoping (#32091 follow-up).
#
# The storage half of #32091 (above) moved every profile's jobs into one shared
# root store. But a job must still EXECUTE under its owning profile's
# environment (.env / config.yaml / credentials) — not whichever profile's
# ticker picks it up. These tests cover the execution-scoping half.
# ---------------------------------------------------------------------------
def _profile_env(tmp_path, monkeypatch, active="default"):
"""Set up a root home with a 'donna' profile dir and point the platform
default at it. Returns (root, donna_home). ``active`` selects which
HERMES_HOME the process runs under."""
root = tmp_path / "hermes_home"
(root / "cron").mkdir(parents=True)
donna_home = root / "profiles" / "donna"
(donna_home / "cron").mkdir(parents=True)
import hermes_constants
monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home",
lambda: root)
monkeypatch.setenv("HERMES_HOME", str(root if active == "default" else donna_home))
return root, donna_home
def test_create_job_autocaptures_active_profile(tmp_path, monkeypatch):
"""A job created from inside a profile session is tagged with that profile,
so the scheduler can later scope its execution back to it."""
root, donna_home = _profile_env(tmp_path, monkeypatch, active="donna")
import cron.jobs as jobs
importlib.reload(jobs)
try:
job = jobs.create_job(prompt="audit", schedule="every 1h", name="a")
# auto-captured from the active (donna) session
assert job["profile"] == "donna"
# and it landed in the SHARED ROOT store, not donna's profile-local one
assert jobs.JOBS_FILE.resolve() == (root / "cron" / "jobs.json").resolve()
assert jobs.JOBS_FILE.exists()
assert not (donna_home / "cron" / "jobs.json").exists()
finally:
monkeypatch.undo()
importlib.reload(jobs)
def test_create_job_explicit_profile_override(tmp_path, monkeypatch):
"""An explicit profile= wins over the auto-captured active profile."""
root, donna_home = _profile_env(tmp_path, monkeypatch, active="default")
(root / "profiles" / "ops" / "cron").mkdir(parents=True)
import cron.jobs as jobs
importlib.reload(jobs)
try:
job = jobs.create_job(prompt="x", schedule="every 2h", profile="ops")
assert job["profile"] == "ops"
finally:
monkeypatch.undo()
importlib.reload(jobs)
def test_resolve_profile_home_maps_names(tmp_path, monkeypatch):
"""resolve_profile_home maps default/named profiles to homes and returns
None for a missing profile."""
root, donna_home = _profile_env(tmp_path, monkeypatch, active="default")
import cron.jobs as jobs
importlib.reload(jobs)
try:
assert jobs.resolve_profile_home("default").resolve() == root.resolve()
assert jobs.resolve_profile_home("").resolve() == root.resolve()
assert jobs.resolve_profile_home("donna").resolve() == donna_home.resolve()
assert jobs.resolve_profile_home("ghost") is None
finally:
monkeypatch.undo()
importlib.reload(jobs)
def test_normalize_backfills_legacy_profile_to_default(tmp_path, monkeypatch):
"""A pre-feature job with no profile field reads back as 'default'."""
import cron.jobs as jobs
legacy = {"id": "l1", "name": "old", "prompt": "x",
"schedule": {"kind": "interval", "minutes": 60}}
assert jobs._normalize_job_record(legacy)["profile"] == "default"
def test_run_job_scopes_execution_to_job_profile(tmp_path, monkeypatch):
"""The decisive test: a ticker running as the ROOT profile executes a
job tagged profile='donna' with HERMES_HOME pointed at donna's home
(both the env var and the in-process override), then restores the
ticker's env afterward."""
from unittest.mock import MagicMock, patch
root, donna_home = _profile_env(tmp_path, monkeypatch, active="default")
(donna_home / "config.yaml").write_text("model:\n default: openrouter/test\n")
import hermes_constants
import cron.jobs as jobs
import cron.scheduler as sched
importlib.reload(jobs)
importlib.reload(sched)
captured = {}
def fake_run_conversation(prompt, *a, **k):
captured["env"] = os.environ.get("HERMES_HOME")
captured["override"] = hermes_constants.get_hermes_home_override()
captured["resolved"] = str(hermes_constants.get_hermes_home())
return {"final_response": "done", "completed": True, "failed": False,
"turn_exit_reason": "text_response(finish_reason=stop)"}
job = {"id": "j-donna", "name": "donna-audit", "prompt": "audit",
"profile": "donna", "schedule": {"kind": "interval", "minutes": 60},
"deliver": "local", "model": "openrouter/test"}
before = os.environ.get("HERMES_HOME")
try:
fake_agent = MagicMock()
fake_agent.run_conversation.side_effect = fake_run_conversation
with patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \
patch("hermes_state.SessionDB", return_value=MagicMock()), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={"api_key": "k", "base_url": "https://x/v1",
"provider": "openrouter", "api_mode": "chat_completions"}), \
patch("run_agent.AIAgent", return_value=fake_agent):
success, output, final, err = sched.run_job(job)
assert success is True, (success, err)
# During execution the job ran AS donna:
assert captured["env"] == str(donna_home)
assert captured["override"] == str(donna_home)
assert captured["resolved"] == str(donna_home)
# After the job, the ticker's HERMES_HOME is restored (no leak):
assert os.environ.get("HERMES_HOME") == before
finally:
monkeypatch.undo()
importlib.reload(jobs)
importlib.reload(sched)
@@ -1,192 +0,0 @@
"""Unit tests for the gateway-side relay relevance-policy declaration (Phase 6 ζ).
Covers gateway.relay.relay_relevance_policy() (the projection of the agent's
mention-gating / free-response / allow-bots config into the connector's generic
vocabulary) and send_relay_policy() (the boot-time POST to /relay/policy). The
connector HTTP POST is monkeypatched; the cross-repo E2E (connector repo,
gateway_policy_driver.py) exercises the real route. These prove the PROJECTION
mapping, the auth/skip logic, and the fail-soft boot behaviour.
"""
from __future__ import annotations
import pytest
import gateway.relay as relay
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for k in (
"GATEWAY_RELAY_URL",
"GATEWAY_RELAY_ID",
"GATEWAY_RELAY_SECRET",
"GATEWAY_RELAY_PLATFORM",
"GATEWAY_RELAY_BOT_ID",
"DISCORD_ALLOW_BOTS",
):
monkeypatch.delenv(k, raising=False)
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False)
# --------------------------------------------------------------------------
# relay_relevance_policy() — the projection
# --------------------------------------------------------------------------
def test_projection_maps_require_mention_and_free_response(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_PLATFORM", "discord")
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True, "free_response_channels": ["c-support", "c-help"]}},
raising=False,
)
pol = relay.relay_relevance_policy()
assert pol == {
"platform": "discord",
"requireAddress": True,
"freeResponseScopes": ["c-support", "c-help"],
"allowOtherBots": False,
}
def test_projection_allow_other_bots_from_env(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_PLATFORM", "discord")
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True}},
raising=False,
)
pol = relay.relay_relevance_policy()
assert pol is not None and pol["allowOtherBots"] is True
def test_projection_comma_string_free_response(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_PLATFORM", "discord")
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"free_response_channels": "c1, c2 ,c3"}},
raising=False,
)
pol = relay.relay_relevance_policy()
assert pol is not None and pol["freeResponseScopes"] == ["c1", "c2", "c3"]
def test_projection_falls_back_to_top_level_require_mention(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_PLATFORM", "discord")
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"require_mention": True}, # top-level, no discord: block
raising=False,
)
pol = relay.relay_relevance_policy()
assert pol is not None and pol["requireAddress"] is True
def test_projection_none_when_all_default(monkeypatch):
# No require_mention, no free-response, no allow-bots ⇒ nothing to declare
# (the connector's quiet default already matches).
monkeypatch.setenv("GATEWAY_RELAY_PLATFORM", "discord")
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {"discord": {}}, raising=False)
assert relay.relay_relevance_policy() is None
def test_projection_none_when_platform_unresolved(monkeypatch):
# Default platform "relay" ⇒ no concrete fronted platform ⇒ nothing to project.
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True}},
raising=False,
)
assert relay.relay_relevance_policy() is None
# --------------------------------------------------------------------------
# send_relay_policy() — the boot-time declaration
# --------------------------------------------------------------------------
def _arm(monkeypatch, *, url="wss://connector.example/relay"):
monkeypatch.setenv("GATEWAY_RELAY_URL", url)
monkeypatch.setenv("GATEWAY_RELAY_ID", "gw-x")
monkeypatch.setenv("GATEWAY_RELAY_SECRET", "s" * 48)
monkeypatch.setenv("GATEWAY_RELAY_PLATFORM", "discord")
def test_send_posts_projected_policy_with_token(monkeypatch):
_arm(monkeypatch)
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True, "free_response_channels": ["c-support"]}},
raising=False,
)
captured = {}
def _fake_post(*, policy_url, token, policy, timeout=15.0):
captured["policy_url"] = policy_url
captured["token"] = token
captured["policy"] = policy
return 200
monkeypatch.setattr(relay, "_post_policy", _fake_post)
assert relay.send_relay_policy() is True
assert captured["policy_url"] == "https://connector.example/relay/policy"
assert captured["token"] # a real upgrade token was minted
assert captured["policy"]["requireAddress"] is True
assert captured["policy"]["freeResponseScopes"] == ["c-support"]
def test_send_skips_when_no_secret(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_URL", "wss://connector.example/relay")
monkeypatch.setenv("GATEWAY_RELAY_PLATFORM", "discord")
# no GATEWAY_RELAY_ID / SECRET
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True}},
raising=False,
)
called = {"n": 0}
monkeypatch.setattr(relay, "_post_policy", lambda **k: called.__setitem__("n", called["n"] + 1) or 200)
assert relay.send_relay_policy() is False
assert called["n"] == 0 # never attempted without a secret to auth with
def test_send_skips_when_nothing_to_declare(monkeypatch):
_arm(monkeypatch)
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {"discord": {}}, raising=False)
called = {"n": 0}
monkeypatch.setattr(relay, "_post_policy", lambda **k: called.__setitem__("n", called["n"] + 1) or 200)
assert relay.send_relay_policy() is False
assert called["n"] == 0 # no redundant write of the default
def test_send_fail_soft_on_transport_error(monkeypatch):
_arm(monkeypatch)
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True}},
raising=False,
)
def _boom(**kwargs):
raise RuntimeError("connector unreachable")
monkeypatch.setattr(relay, "_post_policy", _boom)
# Never raises; returns False so boot proceeds.
assert relay.send_relay_policy() is False
def test_send_fail_soft_on_non_200(monkeypatch):
_arm(monkeypatch)
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True}},
raising=False,
)
monkeypatch.setattr(relay, "_post_policy", lambda **k: 401)
assert relay.send_relay_policy() is False
def test_send_skips_when_relay_unconfigured(monkeypatch):
# No GATEWAY_RELAY_URL ⇒ relay not configured ⇒ no-op.
monkeypatch.setattr(relay, "_post_policy", lambda **k: 200)
assert relay.send_relay_policy() is False
-187
View File
@@ -1754,193 +1754,6 @@ class TestIncomingDocumentHandling:
assert "> /deploy now" in msg_event.text
# ---------------------------------------------------------------------------
# TestIncomingAudioHandling — Slack voice messages (regression)
# ---------------------------------------------------------------------------
class TestSlackAudioExtResolution:
"""Unit coverage for the inbound-audio extension resolver.
Regression for: Slack in-app voice messages are MP4/AAC containers
(``audio/mp4``, filename ``audio_message*.mp4``) that the old code cached
as ``.ogg`` (the catch-all fallback), so OpenAI STT — which sniffs the
container from the filename extension — rejected them. WhatsApp ``.ogg``
and uploaded ``.m4a`` worked because their extension happened to match.
"""
def test_slack_voice_message_mp4_keeps_real_extension(self):
"""The core bug: audio/mp4 voice message must NOT become .ogg."""
f = {"name": "audio_message.mp4", "mimetype": "audio/mp4"}
ext = _slack_mod._resolve_slack_audio_ext(f, f["mimetype"])
assert ext != ".ogg", "regression: MP4 voice message mislabeled as .ogg"
assert ext in {".mp4", ".m4a"}
assert ext in _slack_mod._SLACK_STT_SUPPORTED_EXTS
def test_whatsapp_ogg_preserved(self):
f = {"name": "voice.ogg", "mimetype": "audio/ogg"}
assert _slack_mod._resolve_slack_audio_ext(f, f["mimetype"]) == ".ogg"
def test_m4a_upload_preserved(self):
f = {"name": "clip.m4a", "mimetype": "audio/x-m4a"}
assert _slack_mod._resolve_slack_audio_ext(f, f["mimetype"]) == ".m4a"
def test_mp3_upload_preserved(self):
f = {"name": "song.mp3", "mimetype": "audio/mpeg"}
assert _slack_mod._resolve_slack_audio_ext(f, f["mimetype"]) == ".mp3"
def test_mimetype_used_when_filename_extension_missing(self):
"""No usable filename ext → fall back to the mime map, not .ogg."""
f = {"name": "", "mimetype": "audio/mp4"}
assert _slack_mod._resolve_slack_audio_ext(f, f["mimetype"]) == ".m4a"
def test_unknown_audio_defaults_to_m4a_not_ogg(self):
"""A truly unknown audio type defaults to the broadly-decodable .m4a."""
f = {"name": "weird", "mimetype": "audio/x-some-future-codec"}
ext = _slack_mod._resolve_slack_audio_ext(f, f["mimetype"])
assert ext == ".m4a"
assert ext != ".ogg"
class TestSlackVoiceClipDetection:
"""Unit coverage for the video/mp4-mislabeled voice-clip detector."""
def test_audio_message_filename_detected(self):
assert _slack_mod._is_slack_voice_clip(
{"name": "audio_message.mp4", "mimetype": "video/mp4"}
)
def test_slack_audio_subtype_detected(self):
assert _slack_mod._is_slack_voice_clip(
{"name": "clip.mp4", "subtype": "slack_audio", "mimetype": "video/mp4"}
)
def test_real_video_not_detected(self):
"""A genuine uploaded video must NOT be hijacked into the audio path."""
assert not _slack_mod._is_slack_voice_clip(
{"name": "vacation.mp4", "mimetype": "video/mp4"}
)
def test_slack_video_clip_not_detected(self):
"""slack_video clips carry a real video track — leave them as video."""
assert not _slack_mod._is_slack_voice_clip(
{"name": "screen_recording.mp4", "subtype": "slack_video"}
)
class TestIncomingAudioHandling:
def _make_event(self, files=None, text="hello"):
return {
"text": text,
"user": "U_USER",
"channel": "D123",
"channel_type": "im",
"ts": "1234567890.000001",
"files": files or [],
"blocks": [],
"attachments": [],
}
@pytest.mark.asyncio
async def test_voice_message_cached_with_correct_extension(self, adapter, tmp_path):
"""audio/mp4 voice message is cached with an STT-acceptable extension,
not the old .ogg fallback, and routed as audio."""
captured = {}
async def _fake_download(url, ext, audio=False, team_id=""):
captured["ext"] = ext
captured["audio"] = audio
path = tmp_path / f"cached{ext}"
path.write_bytes(b"\x00\x00\x00\x18ftypmp42fake mp4 bytes")
return str(path)
with patch.object(adapter, "_download_slack_file", side_effect=_fake_download):
event = self._make_event(
files=[
{
"mimetype": "audio/mp4",
"name": "audio_message.mp4",
"subtype": "slack_audio",
"url_private_download": "https://files.slack.com/audio_message.mp4",
"size": 2048,
}
]
)
await adapter._handle_slack_message(event)
assert captured.get("audio") is True
assert captured["ext"] != ".ogg", "regression: voice message cached as .ogg"
assert captured["ext"] in {".mp4", ".m4a"}
msg_event = adapter.handle_message.call_args[0][0]
assert len(msg_event.media_urls) == 1
# media_type stays audio/* so the gateway routes it to STT
assert msg_event.media_types[0].startswith("audio/")
@pytest.mark.asyncio
async def test_video_mp4_voice_clip_rerouted_to_audio(self, adapter, tmp_path):
"""A voice clip mislabeled video/mp4 is rerouted to the audio path
(cached as audio, reported as audio/*) instead of video understanding."""
captured = {}
async def _fake_download(url, ext, audio=False, team_id=""):
captured["ext"] = ext
captured["audio"] = audio
path = tmp_path / f"cached{ext}"
path.write_bytes(b"\x00\x00\x00\x18ftypmp42fake mp4 bytes")
return str(path)
with patch.object(adapter, "_download_slack_file", side_effect=_fake_download):
event = self._make_event(
files=[
{
"mimetype": "video/mp4",
"name": "audio_message.mp4",
"subtype": "slack_audio",
"url_private_download": "https://files.slack.com/audio_message.mp4",
"size": 2048,
}
]
)
await adapter._handle_slack_message(event)
assert captured.get("audio") is True
assert captured["ext"] in {".mp4", ".m4a"}
msg_event = adapter.handle_message.call_args[0][0]
assert len(msg_event.media_urls) == 1
assert msg_event.media_types[0].startswith("audio/"), (
"voice clip should route to STT, not video understanding"
)
@pytest.mark.asyncio
async def test_real_video_still_routed_as_video(self, adapter, tmp_path):
"""A genuine uploaded video must remain on the video path."""
async def _fake_download_bytes(url, team_id=""):
return b"\x00\x00\x00\x18ftypisomfake real video"
with patch.object(
adapter, "_download_slack_file_bytes", side_effect=_fake_download_bytes
):
event = self._make_event(
files=[
{
"mimetype": "video/mp4",
"name": "vacation.mp4",
"url_private_download": "https://files.slack.com/vacation.mp4",
"size": 4096,
}
]
)
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert len(msg_event.media_urls) == 1
assert msg_event.media_types[0].startswith("video/"), (
"a real video must not be hijacked into the audio path"
)
# ---------------------------------------------------------------------------
# TestMessageRouting
# ---------------------------------------------------------------------------
+5 -21
View File
@@ -155,31 +155,15 @@ class TestResolveSessionNameTitle:
result = cfg.resolve_session_name("/some/dir", session_id=None)
assert result == "dir"
def test_per_session_id_beats_title(self):
# per-session: the run's session_id is authoritative; an (auto-)generated
# title must NOT remap a live conversation onto a second Honcho session.
def test_title_beats_session_id(self):
cfg = HonchoClientConfig(session_strategy="per-session")
result = cfg.resolve_session_name("/some/dir", session_title="my-title", session_id="20260309_175514_9797dd")
assert result == "20260309_175514_9797dd"
def test_per_session_id_beats_manual_map(self):
# per-session: session_id also wins over a stale cwd map entry (e.g. the
# desktop launching from a mapped home dir).
cfg = HonchoClientConfig(session_strategy="per-session", sessions={"/some/dir": "pinned"})
result = cfg.resolve_session_name("/some/dir", session_id="20260309_175514_9797dd")
assert result == "20260309_175514_9797dd"
def test_title_still_applies_for_non_per_session(self):
# Outside per-session, /title still names the Honcho session.
cfg = HonchoClientConfig(session_strategy="per-directory")
result = cfg.resolve_session_name("/some/dir", session_title="my-title", session_id="20260309_175514_9797dd")
assert result == "my-title"
def test_gateway_key_beats_per_session_id(self):
# Gateways keep per-chat isolation even in per-session.
cfg = HonchoClientConfig(session_strategy="per-session")
result = cfg.resolve_session_name("/some/dir", gateway_session_key="agent:main:telegram:dm:42", session_id="20260309_175514_9797dd")
assert result == "agent-main-telegram-dm-42"
def test_manual_beats_session_id(self):
cfg = HonchoClientConfig(session_strategy="per-session", sessions={"/some/dir": "pinned"})
result = cfg.resolve_session_name("/some/dir", session_id="20260309_175514_9797dd")
assert result == "pinned"
def test_global_strategy_returns_workspace(self):
cfg = HonchoClientConfig(session_strategy="global", workspace_id="my-workspace")
-63
View File
@@ -234,66 +234,6 @@ class TestCmdStatus:
assert "FAILED (Invalid API key)" in out
assert "Connection... OK" not in out
def test_auth_line_detects_oauth_grant(self, monkeypatch, capsys, tmp_path):
import plugins.memory.honcho.cli as honcho_cli
cfg_path = tmp_path / "honcho.json"
cfg_path.write_text("{}")
class FakeConfig:
enabled = True
api_key = "hch-at-deadbeef"
workspace_id = "claude-code"
host = "hermes"
base_url = None
ai_peer = "hermes"
peer_name = "eri"
recall_mode = "hybrid"
user_observe_me = True
user_observe_others = False
ai_observe_me = False
ai_observe_others = True
write_frequency = "async"
session_strategy = "per-session"
context_tokens = None
dialectic_reasoning_level = "low"
reasoning_level_cap = "high"
reasoning_heuristic = True
raw = {
"hosts": {
"hermes": {
"apiKey": "hch-at-deadbeef",
"oauth": {
"refreshToken": "hch-rt-x",
"clientId": "hermes-agent",
"tokenEndpoint": "https://api.honcho.dev/oauth/token",
"expiresAt": 9999999999,
},
}
}
}
def resolve_session_name(self):
return "hermes"
monkeypatch.setattr(honcho_cli, "_read_config", lambda: {})
monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_active_profile_name", lambda: "default")
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda host=None: FakeConfig(),
)
monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda cfg: object())
monkeypatch.setattr(honcho_cli, "_show_peer_cards", lambda hcfg, client: None)
monkeypatch.setitem(__import__("sys").modules, "honcho", SimpleNamespace())
honcho_cli.cmd_status(SimpleNamespace(all=False))
out = capsys.readouterr().out
assert "Auth: OAuth (hermes-agent" in out
assert "API key:" not in out
class TestCloneHonchoForProfile:
"""Identity-key carryover during profile cloning.
@@ -449,9 +389,6 @@ class TestSetupWizardDeploymentShape:
# Scripted _prompt: pop answers in order. Default-return for unconsumed prompts.
answer_iter = iter(answers)
def _scripted_prompt(label, default=None, secret=False):
# Auth-method prompt is orthogonal to shape; auto-answer apikey so the answer lists stay shape-only.
if "OAuth" in label:
return "apikey"
try:
return next(answer_iter)
except StopIteration:
+3 -5
View File
@@ -711,17 +711,15 @@ class TestResolveSessionNameGatewayKey:
)
assert result == "agent-main-telegram-dm-8439114563"
def test_gateway_key_not_remapped_by_title(self):
"""A title never remaps a stable identifier — the gateway per-chat key
wins over the title so a generated title can't split a live conversation
onto a new Honcho session."""
def test_session_title_still_wins_over_gateway_key(self):
"""Explicit /title remap takes priority over gateway_session_key."""
config = HonchoClientConfig(session_strategy="per-session")
result = config.resolve_session_name(
session_title="my-custom-title",
session_id="20260412_171002_69bb38",
gateway_session_key="agent:main:telegram:dm:8439114563",
)
assert result == "agent-main-telegram-dm-8439114563"
assert result == "my-custom-title"
def test_per_session_fallback_without_gateway_key(self):
"""Without gateway_session_key, per-session returns session_id (CLI path)."""
-254
View File
@@ -1,254 +0,0 @@
"""Tests for plugins/memory/honcho/oauth.py — OAuth grant storage + refresh."""
import json
from pathlib import Path
import pytest
from plugins.memory.honcho import oauth
from plugins.memory.honcho.oauth import OAuthCredential
def _host_block(refresh="hch-rt-old", expires_at=10_000):
return {
"apiKey": "hch-at-old",
"oauth": {
"refreshToken": refresh,
"expiresAt": expires_at,
"clientId": "hermes-desktop",
"tokenEndpoint": "http://localhost:8000/oauth/token",
"scope": "write",
"tokenType": "Bearer",
},
}
def _write(path: Path, raw: dict) -> None:
path.write_text(json.dumps(raw), encoding="utf-8")
class TestTokenDetection:
def test_access_token_prefix(self):
assert oauth.is_oauth_access_token("hch-at-abc")
assert not oauth.is_oauth_access_token("hch-v3-abc")
assert not oauth.is_oauth_access_token("hch-rt-abc")
assert not oauth.is_oauth_access_token(None)
class TestCredentialModel:
def test_roundtrip(self):
cred = OAuthCredential.from_host_block(_host_block())
assert cred is not None
block = cred.oauth_block()
assert block["refreshToken"] == "hch-rt-old"
assert block["expiresAt"] == 10_000
assert block["clientId"] == "hermes-desktop"
def test_incomplete_block_returns_none(self):
# plain API key (no oauth sub-block)
assert OAuthCredential.from_host_block({"apiKey": "hch-v3-x"}) is None
# oauth block missing refreshToken
bad = _host_block()
del bad["oauth"]["refreshToken"]
assert OAuthCredential.from_host_block(bad) is None
def test_is_expired_respects_skew(self):
cred = OAuthCredential.from_host_block(_host_block(expires_at=1000))
assert not cred.is_expired(now=800, skew=120) # 1000-120=880 > 800
assert cred.is_expired(now=900, skew=120) # 900 >= 880
class TestEnsureFreshToken:
def test_no_oauth_credential_is_noop(self, tmp_path):
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": {"apiKey": "hch-v3-static"}}})
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=0)
assert token is None and refreshed is False
def test_fresh_token_skips_refresh(self, tmp_path, monkeypatch):
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block(expires_at=10_000)}})
monkeypatch.setattr(
oauth, "_http_post_form",
lambda *a, **k: pytest.fail("refresh must not be called when fresh"),
)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=0)
assert token == "hch-at-old" and refreshed is False
def test_fresh_token_served_from_cache_without_disk(self, tmp_path, monkeypatch):
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block(expires_at=10_000)}})
oauth._expiry_cache.clear()
# First call seeds the cache from disk.
oauth.ensure_fresh_token(path, "hermes", now=0)
# Second call must not touch disk while the token is well clear of expiry.
monkeypatch.setattr(
oauth, "_read_config",
lambda *a, **k: pytest.fail("disk must not be read while token is fresh"),
)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=100)
assert token == "hch-at-old" and refreshed is False
def test_expired_token_refreshes_and_persists_rotation(self, tmp_path, monkeypatch):
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block(expires_at=100)}})
def fake_post(url, data, timeout):
assert data["grant_type"] == "refresh_token"
assert data["refresh_token"] == "hch-rt-old"
assert data["client_id"] == "hermes-desktop"
return {
"access_token": "hch-at-new",
"refresh_token": "hch-rt-new",
"expires_in": 3600,
"scope": "write",
"token_type": "Bearer",
}
monkeypatch.setattr(oauth, "_http_post_form", fake_post)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
assert token == "hch-at-new" and refreshed is True
# Rotated refresh token + new access token + absolute expiry persisted.
saved = json.loads(path.read_text())["hosts"]["hermes"]
assert saved["apiKey"] == "hch-at-new"
assert saved["oauth"]["refreshToken"] == "hch-rt-new"
assert saved["oauth"]["expiresAt"] == 1000 + 3600
def test_refresh_failure_fails_open(self, tmp_path, monkeypatch):
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block(expires_at=100)}})
def boom(*a, **k):
raise RuntimeError("network down")
monkeypatch.setattr(oauth, "_http_post_form", boom)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
# Stale token returned, no crash, file untouched.
assert token == "hch-at-old" and refreshed is False
assert json.loads(path.read_text())["hosts"]["hermes"]["apiKey"] == "hch-at-old"
def test_double_check_uses_disk_when_already_rotated(self, tmp_path, monkeypatch):
# Simulates a concurrent thread that rotated the token on disk after our
# stale in-memory snapshot: the locked re-read must skip the HTTP call.
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block(refresh="hch-rt-fresh", expires_at=10_000)}})
stale_raw = {"hosts": {"hermes": _host_block(refresh="hch-rt-old", expires_at=100)}}
stale_raw["hosts"]["hermes"]["apiKey"] = "hch-at-stale"
monkeypatch.setattr(
oauth, "_http_post_form",
lambda *a, **k: pytest.fail("must not refresh; disk token is fresh"),
)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", stale_raw, now=1000)
assert token == "hch-at-old" # the on-disk fresh credential's access token
def test_refresh_holds_cross_process_lock(self, tmp_path, monkeypatch):
# A second opener must not grab <config>.lock mid-refresh — proving the
# rotation is serialized machine-wide so peers can't replay the token.
fcntl = pytest.importorskip("fcntl")
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block(expires_at=100)}})
seen = {}
def fake_post(url, data, timeout):
with open(f"{path}.lock", "a+b") as other:
try:
fcntl.flock(other.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(other.fileno(), fcntl.LOCK_UN)
seen["held"] = False
except OSError:
seen["held"] = True
return {"access_token": "hch-at-new", "refresh_token": "hch-rt-new",
"expires_in": 3600, "scope": "write", "token_type": "Bearer"}
monkeypatch.setattr(oauth, "_http_post_form", fake_post)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
assert refreshed is True and seen.get("held") is True
# Released afterward: a non-blocking acquire now succeeds.
with open(f"{path}.lock", "a+b") as fh:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
def test_refresh_degrades_when_lock_unavailable(self, tmp_path, monkeypatch):
# No flock (unsupported FS/platform) must not block refresh — it falls
# back to in-process serialization only.
fcntl = pytest.importorskip("fcntl")
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block(expires_at=100)}})
def no_flock(*a, **k):
raise OSError("flock unsupported")
monkeypatch.setattr(fcntl, "flock", no_flock)
monkeypatch.setattr(
oauth, "_http_post_form",
lambda *a, **k: {"access_token": "hch-at-new", "refresh_token": "hch-rt-new",
"expires_in": 3600, "scope": "write", "token_type": "Bearer"},
)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
assert token == "hch-at-new" and refreshed is True
class TestInstallGrant:
def test_deep_merges_config_and_preserves_other_hosts(self, tmp_path):
path = tmp_path / "honcho.json"
_write(path, {
"apiKey": "hch-v3-root", # root static key preserved
"hosts": {
"obsidian": {"workspace": "obsidian"},
"hermes": {"workspace": "hermes", "saveMessages": False},
},
})
grant = {
"access_token": "hch-at-fresh",
"refresh_token": "hch-rt-fresh",
"expires_in": 3600,
"scope": "write",
"config": {
"environment": "production",
"hosts": {"hermes": {"saveMessages": True, "recallMode": "hybrid"}},
},
}
cred = oauth.install_grant(
path, "hermes", grant,
client_id="hermes-desktop",
token_endpoint="http://localhost:8000/oauth/token",
now=1000,
)
assert cred.expires_at == 1000 + 3600
saved = json.loads(path.read_text())
assert saved["apiKey"] == "hch-v3-root" # untouched
assert saved["hosts"]["obsidian"] == {"workspace": "obsidian"} # untouched
h = saved["hosts"]["hermes"]
assert h["apiKey"] == "hch-at-fresh"
assert h["oauth"]["refreshToken"] == "hch-rt-fresh"
assert h["saveMessages"] is True # grant config won the deep-merge
assert h["recallMode"] == "hybrid" # new key added
assert h["workspace"] == "hermes" # pre-existing key preserved
assert saved["environment"] == "production" # root key from grant
def test_rejects_grant_without_tokens(self, tmp_path):
path = tmp_path / "honcho.json"
_write(path, {})
with pytest.raises(ValueError):
oauth.install_grant(
path, "hermes", {"access_token": "hch-at-x"}, # no refresh_token
client_id="c", token_endpoint="e",
)
class TestApplyTokenToClient:
def test_mutates_live_bearer(self):
class FakeHttp:
api_key = "hch-at-old"
class FakeClient:
_http = FakeHttp()
client = FakeClient()
assert oauth.apply_token_to_client(client, "hch-at-new") is True
assert client._http.api_key == "hch-at-new"
def test_returns_false_when_shape_unknown(self):
assert oauth.apply_token_to_client(object(), "hch-at-new") is False
-347
View File
@@ -1,347 +0,0 @@
"""End-to-end test for the zero-CLI Honcho OAuth flow against a fake AS.
Stands up a real local authorization server (no network, no browser) and drives
the full path: begin → /authorize 302 → loopback :8765 callback → token
exchange → install_grant → forced-expiry refresh with rotation. This is the
deterministic "real smoke test" for the consumer flow.
"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from plugins.memory.honcho import oauth, oauth_flow
class _FakeAS(BaseHTTPRequestHandler):
"""Minimal OAuth 2.1 AS: /authorize 302s to the callback; /oauth/token mints."""
# Rotation counter shared across requests so refresh returns a new token.
issued = {"n": 0}
def do_GET(self): # noqa: N802
parsed = urlparse(self.path)
if parsed.path != "/authorize":
self.send_response(404)
self.end_headers()
return
q = parse_qs(parsed.query)
redirect = q["redirect_uri"][0]
# The redirect must be the IP literal matching the bound host — a
# `localhost` redirect can resolve to ::1 and miss the IPv4 listener.
# Host must be the IP literal (port may fall back off :8765).
assert redirect.startswith("http://127.0.0.1:") and "/callback" in redirect, redirect
# Consent shows a home-relative display path — never an absolute path
# that would leak the username / home layout off the machine.
cp = q["config_path"][0]
assert cp.endswith("honcho.json"), q.get("config_path")
assert not cp.startswith("/"), cp
state = q["state"][0]
location = f"{redirect}?code=test-auth-code&state={state}"
self.send_response(302)
self.send_header("Location", location)
self.end_headers()
def do_POST(self): # noqa: N802
parsed = urlparse(self.path)
if parsed.path != "/oauth/token":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
form = parse_qs(self.rfile.read(length).decode())
grant_type = form["grant_type"][0]
self.issued["n"] += 1
n = self.issued["n"]
body = {
"access_token": f"hch-at-{n}",
"refresh_token": f"hch-rt-{n}",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "write",
}
if grant_type == "authorization_code":
body["config"] = {
"peerName": "lyra",
"environment": "production",
"hosts": {"hermes": {"saveMessages": True, "recallMode": "hybrid"}},
}
payload = json.dumps(body).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
return
@pytest.fixture
def fake_as(monkeypatch):
_FakeAS.issued["n"] = 0
server = HTTPServer(("127.0.0.1", 0), _FakeAS)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{port}"
monkeypatch.setenv("HONCHO_OAUTH_AUTHORIZE_URL", f"{base}/authorize")
monkeypatch.setenv("HONCHO_OAUTH_TOKEN_URL", f"{base}/oauth/token")
monkeypatch.setenv("HONCHO_OAUTH_CLIENT_ID", "hermes-desktop")
try:
yield base
finally:
server.shutdown()
server.server_close()
def _browser_driver(authorize_url: str) -> None:
"""Stand in for the user's browser: follow /authorize's 302 into the callback.
Retries the callback GET so it can't lose the race to the loopback bind.
"""
resp = httpx.get(authorize_url, follow_redirects=False)
location = resp.headers["Location"]
for _ in range(50):
try:
httpx.get(location, timeout=2)
return
except httpx.ConnectError:
time.sleep(0.05)
raise RuntimeError("loopback callback never came up")
def test_full_loopback_flow_then_refresh(tmp_path, fake_as):
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"obsidian": {"workspace": "obsidian"}}}))
cred = oauth_flow.authorize_via_loopback(
config_path=config_path,
host="hermes",
open_url=lambda url: _browser_driver(url),
timeout=10,
)
# Grant installed: token stored, config deep-merged, other host preserved.
assert cred.access_token == "hch-at-1"
saved = json.loads(config_path.read_text())
assert saved["hosts"]["hermes"]["apiKey"] == "hch-at-1"
assert saved["hosts"]["hermes"]["oauth"]["refreshToken"] == "hch-rt-1"
assert saved["hosts"]["hermes"]["recallMode"] == "hybrid"
assert saved["environment"] == "production"
assert saved["hosts"]["obsidian"] == {"workspace": "obsidian"}
# Force expiry; ensure_fresh_token refreshes against the same AS and rotates.
token, refreshed = oauth.ensure_fresh_token(
config_path, "hermes", now=saved["hosts"]["hermes"]["oauth"]["expiresAt"] + 10
)
assert refreshed is True
assert token == "hch-at-2"
rotated = json.loads(config_path.read_text())["hosts"]["hermes"]["oauth"]
assert rotated["refreshToken"] == "hch-rt-2"
def test_state_mismatch_is_rejected(fake_as, tmp_path):
endpoints = oauth_flow.resolve_endpoints()
_, state = oauth_flow.begin_authorization(endpoints)
with pytest.raises(ValueError, match="unknown or expired"):
oauth_flow.complete_authorization(
endpoints, "code", "not-the-real-state",
config_path=tmp_path / "honcho.json", host="hermes",
)
def test_source_tags_the_authorize_link(fake_as):
endpoints = oauth_flow.resolve_endpoints()
url, _ = oauth_flow.begin_authorization(endpoints, source="hermes-cli")
assert "source=hermes-cli" in url
untagged, _ = oauth_flow.begin_authorization(endpoints)
assert "source=" not in untagged
def test_client_id_defaults_to_hermes_agent(monkeypatch):
# One client for every surface; the env var overrides for unusual deployments.
monkeypatch.delenv("HONCHO_OAUTH_CLIENT_ID", raising=False)
common = {"environment": "production", "base_url": "https://api.honcho.dev"}
assert oauth_flow.resolve_endpoints(**common).client_id == "hermes-agent"
monkeypatch.setenv("HONCHO_OAUTH_CLIENT_ID", "custom-id")
assert oauth_flow.resolve_endpoints(**common).client_id == "custom-id"
def test_grant_persists_default_client_id(tmp_path, fake_as, monkeypatch):
# Drop the fixture's override so the default takes effect; the grant must
# store client_id=hermes-agent so refresh reuses the right client.
monkeypatch.delenv("HONCHO_OAUTH_CLIENT_ID", raising=False)
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {}}))
oauth_flow.authorize_via_loopback(
config_path=config_path,
host="hermes",
source="hermes-cli",
apply_config=False,
open_url=lambda url: _browser_driver(url),
timeout=10,
)
saved = json.loads(config_path.read_text())
assert saved["hosts"]["hermes"]["oauth"]["clientId"] == "hermes-agent"
def test_config_path_rides_the_authorize_link(fake_as):
endpoints = oauth_flow.resolve_endpoints()
url, _ = oauth_flow.begin_authorization(endpoints, config_path="~/.hermes/honcho.json")
q = parse_qs(urlparse(url).query)
assert q["config_path"][0] == "~/.hermes/honcho.json"
bare, _ = oauth_flow.begin_authorization(endpoints)
assert "config_path=" not in bare
def test_display_config_path_never_leaks_absolute_path():
from pathlib import Path
# Under home → collapsed to ~/…; outside home → bare filename only.
under_home = Path.home() / ".hermes" / "profiles" / "work" / "honcho.json"
assert oauth_flow._display_config_path(under_home) == "~/.hermes/profiles/work/honcho.json"
assert oauth_flow._display_config_path("/var/folders/tmp/honcho.json") == "honcho.json"
def test_cli_flow_stores_tokens_without_applying_config(tmp_path, fake_as):
# apply_config=False (the CLI path): grant config must NOT touch settings.
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"hermes": {"saveMessages": False}}}))
cred = oauth_flow.authorize_via_loopback(
config_path=config_path,
host="hermes",
source="hermes-cli",
apply_config=False,
open_url=lambda url: _browser_driver(url),
timeout=10,
)
saved = json.loads(config_path.read_text())
host = saved["hosts"]["hermes"]
assert host["apiKey"] == cred.access_token
assert host["oauth"]["refreshToken"] == cred.refresh_token
# Wizard-owned setting untouched; grant config keys absent.
assert host["saveMessages"] is False
assert "recallMode" not in host
assert "environment" not in saved
# consent peer name still surfaced (seeds the CLI wizard prompt) despite no merge
assert cred.consent_peer_name == "lyra"
# ── Desktop "Connect" button path: background launcher, status, dispatch ──
@pytest.fixture
def reset_flow():
oauth_flow._status = oauth_flow.FlowStatus()
oauth_flow._flow_thread = None
yield
oauth_flow._status = oauth_flow.FlowStatus()
oauth_flow._flow_thread = None
def _wait_until(predicate, timeout=2.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.02)
return False
def test_launcher_runs_flow_in_background_and_reports_connected(monkeypatch, reset_flow):
seen = {}
gate = threading.Event()
def fake(**kwargs):
seen.update(kwargs) # captures source default + eagerly-resolved path/host
gate.wait(2) # hold the flow open so the launcher returns while pending
monkeypatch.setattr(oauth_flow, "authorize_via_loopback", fake)
monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (True, "oauth"))
st = oauth_flow.start_loopback_flow_background(config_path=Path("/t/honcho.json"), host="hermes")
assert st["state"] == "pending" # returns immediately, before the flow finishes
assert _wait_until(lambda: seen.get("source") == "hermes-desktop") # default source tag
assert seen["host"] == "hermes"
gate.set()
assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "connected")
def test_launcher_reports_error_on_flow_failure(monkeypatch, reset_flow):
def boom(**kwargs):
raise RuntimeError("loopback bind failed")
monkeypatch.setattr(oauth_flow, "authorize_via_loopback", boom)
monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (False, None))
oauth_flow.start_loopback_flow_background(config_path=Path("/t/honcho.json"), host="hermes")
assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "error")
assert "loopback bind failed" in oauth_flow.get_flow_status()["detail"]
def test_launcher_is_idempotent_while_pending(monkeypatch, reset_flow):
block = threading.Event()
calls = []
def fake(**kwargs):
calls.append(1)
block.wait(2)
monkeypatch.setattr(oauth_flow, "authorize_via_loopback", fake)
monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (False, None))
s1 = oauth_flow.start_loopback_flow_background(config_path=Path("/t/h.json"), host="hermes")
assert _wait_until(lambda: len(calls) == 1) # first flow is running
s2 = oauth_flow.start_loopback_flow_background(config_path=Path("/t/h.json"), host="hermes")
block.set()
assert s1["state"] == "pending" and s2["state"] == "pending"
assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "connected")
assert calls == [1] # the second call did not spawn a second flow
def test_get_flow_status_reports_stored_connection(tmp_path, monkeypatch, reset_flow):
from plugins.memory.honcho import client as honcho_client
cfgfile = tmp_path / "honcho.json"
monkeypatch.setattr(honcho_client, "resolve_config_path", lambda: cfgfile)
monkeypatch.setattr(honcho_client, "resolve_active_host", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
cfgfile.write_text(json.dumps({"hosts": {"hermes": {}}}))
assert oauth_flow.get_flow_status()["connected"] is False
cfgfile.write_text(json.dumps({"hosts": {"hermes": {"apiKey": "hch-v3-static"}}}))
s = oauth_flow.get_flow_status()
assert s["connected"] is True and s["auth"] == "apikey"
cfgfile.write_text(json.dumps({"hosts": {"hermes": {
"apiKey": "hch-at-tok",
"oauth": {"refreshToken": "hch-rt-x", "expiresAt": 9_999_999_999,
"clientId": "hermes-desktop", "tokenEndpoint": "http://x/oauth/token"},
}}}))
s = oauth_flow.get_flow_status()
assert s["connected"] is True and s["auth"] == "oauth"
def test_memory_oauth_router_dispatches_by_provider_convention():
# The generic seam behind the two routes: provider → plugins.memory.<p>.oauth_flow.
from fastapi import HTTPException
from hermes_cli.memory_oauth import _resolve_flow
mod = _resolve_flow("honcho")
assert hasattr(mod, "start_loopback_flow_background") and hasattr(mod, "get_flow_status")
for bad in ("builtin", "no-such-provider", "../etc"):
with pytest.raises(HTTPException) as exc:
_resolve_flow(bad)
assert exc.value.status_code == 404
@@ -260,52 +260,6 @@ class TestShrinkImagePartsHelper:
assert seen["max_dimension"] == 2000
assert msgs[0]["content"][0]["image_url"]["url"] == shrunk
def test_anthropic_base64_image_source_rewritten(self, monkeypatch):
"""Anthropic-native image blocks are shrinkable after adapter conversion."""
agent = _make_agent()
_install_fake_pillow(monkeypatch, (2501, 100), shrunk_size=(1500, 60))
original = _big_png_data_url(100)
_, _, original_data = original.partition(",")
shrunk = "data:image/jpeg;base64," + "N" * 1000
seen = {}
def _fake_resize(path, mime_type=None, max_base64_bytes=None, max_dimension=None):
seen["mime_type"] = mime_type
seen["max_dimension"] = max_dimension
return shrunk
monkeypatch.setattr(
"tools.vision_tools._resize_image_for_vision",
_fake_resize,
raising=False,
)
msgs = [{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": original_data,
},
},
],
}]
changed = agent._try_shrink_image_parts_in_messages(
msgs,
max_dimension=2000,
)
source = msgs[0]["content"][0]["source"]
assert changed is True
assert seen["mime_type"] == "image/png"
assert seen["max_dimension"] == 2000
assert source["type"] == "base64"
assert source["media_type"] == "image/jpeg"
assert source["data"] == "N" * 1000
def test_oversized_input_image_string_shape_rewritten(self, monkeypatch):
"""OpenAI Responses shape: {type: input_image, image_url: "data:..."}."""
agent = _make_agent()
-117
View File
@@ -2139,123 +2139,6 @@ class TestStructuredElementsConsumption:
# Markdown surface doesn't carry bounds — lossy by design.
assert cap.elements[0].bounds == (0, 0, 0, 0)
def test_vision_capture_falls_back_to_get_window_state_when_screenshot_dropped(self):
"""cua-driver >=0.5.x dropped the standalone `screenshot` MCP tool and
folded full-window PNG capture into `get_window_state`. When the driver
no longer advertises `screenshot`, vision capture must route through
`get_window_state` (discarding the AX tree) and still return a PNG."""
from tools.computer_use.cua_backend import CuaDriverBackend
backend = CuaDriverBackend()
backend._session = MagicMock()
# Modern driver: capabilities discovered, `screenshot` not advertised.
backend._session._has_tool.return_value = False
backend._session.capabilities_discovered = True
windows_payload = {
"windows": [{
"app_name": "Demo", "pid": 9, "window_id": 1,
"is_on_screen": True, "title": "Demo", "z_index": 0,
}],
}
png_b64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42m"
"NkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def fake_call_tool(name, args):
if name == "list_windows":
return {"data": "", "images": [], "image_mime_types": [],
"structuredContent": windows_payload, "isError": False}
if name == "get_window_state":
return {"data": "", "images": [png_b64],
"image_mime_types": ["image/png"],
"structuredContent": None, "isError": False}
if name == "screenshot":
raise AssertionError("driver dropped screenshot; must not be called")
return {"data": "", "images": [], "image_mime_types": [],
"structuredContent": None, "isError": False}
backend._session.call_tool.side_effect = fake_call_tool
cap = backend.capture(mode="vision")
tool_names = [call.args[0] for call in backend._session.call_tool.call_args_list]
assert tool_names == ["list_windows", "get_window_state"]
assert cap.png_b64 == png_b64
assert cap.image_mime_type == "image/png"
assert cap.width == 1
assert cap.height == 1
# Vision mode stays free of AX element noise.
assert cap.elements == []
def test_capture_app_screen_targets_desktop_window(self):
"""capture(app='screen') resolves to the OS shell/desktop window
(Windows Progman) rather than an application window, so 'show me my
screen' works on cua-driver's window-oriented capture surface."""
from tools.computer_use.cua_backend import CuaDriverBackend
backend = CuaDriverBackend()
backend._session = MagicMock()
windows_payload = {
"windows": [
{"app_name": "Code", "pid": 11, "window_id": 1,
"is_on_screen": True, "title": "editor", "z_index": 0},
{"app_name": "Progman", "pid": 4, "window_id": 99,
"is_on_screen": True, "title": "Program Manager", "z_index": 5},
{"app_name": "Shell_TrayWnd", "pid": 4, "window_id": 50,
"is_on_screen": True, "title": "Taskbar", "z_index": 4},
],
}
def fake_call_tool(name, args):
if name == "list_windows":
return {"data": "", "images": [], "image_mime_types": [],
"structuredContent": windows_payload, "isError": False}
if name == "get_window_state":
# Should be invoked against the desktop backdrop, not Code.
assert args["window_id"] == 99
return {"data": "✅ Desktop — 0 elements", "images": [],
"image_mime_types": [], "structuredContent": None,
"isError": False}
return {"data": "", "images": [], "image_mime_types": [],
"structuredContent": None, "isError": False}
backend._session.call_tool.side_effect = fake_call_tool
cap = backend.capture(mode="ax", app="screen")
assert backend._active_window_id == 99
assert cap.app == "Progman"
def test_capture_app_screen_no_desktop_window_surfaces_limitation(self):
"""When no desktop/shell window is present, capture(app='screen')
returns a clear message about cua-driver's per-window capture limit
instead of silently grabbing the frontmost app."""
from tools.computer_use.cua_backend import CuaDriverBackend
backend = CuaDriverBackend()
backend._session = MagicMock()
windows_payload = {
"windows": [
{"app_name": "Code", "pid": 11, "window_id": 1,
"is_on_screen": True, "title": "editor", "z_index": 0},
],
}
def fake_call_tool(name, args):
if name == "list_windows":
return {"data": "", "images": [], "image_mime_types": [],
"structuredContent": windows_payload, "isError": False}
raise AssertionError(f"unexpected tool {name} — should short-circuit")
backend._session.call_tool.side_effect = fake_call_tool
cap = backend.capture(mode="vision", app="desktop")
assert cap.width == 0 and cap.height == 0
assert cap.png_b64 is None
assert "captures one window at a time" in cap.window_title
class TestCapabilityDiscovery:
"""Surface 4 (NousResearch/hermes-agent#47072): the wrapper learns
+1 -60
View File
@@ -78,29 +78,6 @@ _CUA_DRIVER_ARGS = ["mcp"] # stdio MCP transport (fallback when the
# driver doesn't expose `manifest` — see
# `_resolve_mcp_invocation` below)
# Whole-screen / desktop capture. cua-driver is a window-oriented driver —
# its `get_window_state` / `screenshot` tools capture a single window (by
# pid + window_id), and there is no MCP tool that captures the entire virtual
# desktop or an arbitrary monitor as one image. But the OS shell surfaces
# themselves (the desktop backdrop and the taskbar/menu-bar) are real windows
# that show up in `list_windows`, so "show me my screen" / "click the taskbar"
# is reachable by targeting those windows. When `app` is one of these
# sentinels, capture() resolves to the desktop/shell window instead of an
# application window.
_SCREEN_CAPTURE_SENTINELS = {"screen", "desktop", "fullscreen", "full screen", "all"}
# Known shell/desktop window identifiers across platforms. Matched
# case-insensitively as a substring against both the window's app_name and
# its title (cua-driver surfaces the Win32 class name / app name here).
# Windows: Progman / WorkerW back the desktop; Shell_TrayWnd is the taskbar.
# macOS: Finder owns the desktop; the menu bar / Dock are the shell.
_DESKTOP_WINDOW_NAMES = (
"progman", "workerw", "program manager", # Windows desktop
"shell_traywnd", "taskbar", # Windows taskbar
"finder", "desktop", "dock", # macOS desktop / shell
)
# Env var cua-driver reads to gate its anonymous usage telemetry (PostHog).
# Setting it to "0" disables telemetry; absence => the binary's own default
# (telemetry ON upstream).
@@ -1052,43 +1029,7 @@ class CuaDriverBackend(ComputerUseBackend):
# returned by list_windows is the localized name (e.g. "計算機"), so
# `app="Calculator"` legitimately matches no windows on a non-English
# system and the caller needs to retry with the localized name.
if app and app.strip().lower() in _SCREEN_CAPTURE_SENTINELS:
# Whole-screen / desktop request. cua-driver has no virtual-desktop
# capture tool, so resolve to the OS shell/desktop window (the
# desktop backdrop or the taskbar/menu-bar), which list_windows
# does surface. This makes "show me my screen" and "click the
# taskbar" work; a single image still can't span multiple monitors
# — that's a driver limitation, not a wrapper one.
def _is_desktop_window(w: Dict[str, Any]) -> bool:
haystack = f"{w.get('app_name', '')} {w.get('title', '')}".lower()
return any(name in haystack for name in _DESKTOP_WINDOW_NAMES)
desktop = [w for w in windows if _is_desktop_window(w)]
if not desktop:
return CaptureResult(
mode=mode, width=0, height=0, png_b64=None,
elements=[], app="",
window_title=(
f"<no desktop/shell window found for app={app!r}; "
f"cua-driver captures one window at a time and exposes "
f"no whole-virtual-desktop or per-monitor capture. "
f"Call list_apps / capture(app='<AppName>') to target a "
f"specific window instead. On Windows the taskbar is "
f"'Shell_TrayWnd' and the desktop is 'Progman'.>"
),
png_bytes_len=0,
)
# Prefer the desktop backdrop (Progman/WorkerW/Finder) over the
# taskbar when both are present, so a bare "screen" capture shows
# the full desktop rather than just the task strip.
windows = sorted(
desktop,
key=lambda w: 0 if any(
n in f"{w.get('app_name', '')} {w.get('title', '')}".lower()
for n in ("progman", "workerw", "program manager", "finder", "desktop")
) else 1,
)
elif app:
if app:
app_lower = app.lower()
filtered = [w for w in windows if app_lower in w["app_name"].lower()]
if not filtered:
-189
View File
@@ -1,189 +0,0 @@
"""
Cross-platform Computer Use readiness + macOS permission helpers.
cua-driver runs on macOS, Windows, and Linux, but "ready to drive" means
something different on each:
* macOS — explicit TCC grants (Accessibility + Screen Recording). cua-driver
reports/requests them via ``permissions status`` / ``permissions grant``.
The grants attach to cua-driver's OWN identity (``com.trycua.driver`` /
the installed ``CuaDriver.app``), NOT Hermes — so no Hermes entitlement is
involved, and ``grant`` launches CuaDriver via LaunchServices so the macOS
dialog is attributed correctly.
* Windows — no TCC toggles; the UIAccess worker (``cua-driver-uia.exe``) may
trip a SmartScreen prompt on first run. Readiness == driver health.
* Linux — assistive control via the X11/XWayland stack. Readiness == driver
health.
The universal signal on every platform is ``cua-driver doctor --json`` (binary
integrity + platform support). ``computer_use_status`` folds that together with
the macOS permission detail into one payload for the desktop card, the
``hermes computer-use permissions`` CLI, and ``/api/tools/computer-use/status``.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from typing import Any, Dict, List, Optional
# Platforms with a cua-driver runtime backend (mirrors the toolset platform_gate).
_RUNTIME_PLATFORMS = frozenset({"darwin", "win32", "linux"})
_BOOLS = ("accessibility", "screen_recording", "screen_recording_capturable")
def _driver_cmd(override: Optional[str]) -> str:
if override:
return override
try:
from hermes_cli.tools_config import _cua_driver_cmd
return _cua_driver_cmd()
except Exception:
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
def _child_env() -> Dict[str, str]:
"""cua-driver child env honoring the Hermes telemetry opt-in policy."""
try:
from tools.computer_use.cua_backend import cua_driver_child_env
return cua_driver_child_env()
except Exception:
return dict(os.environ)
def _run(binary: str, *args: str, timeout: float) -> subprocess.CompletedProcess:
return subprocess.run(
[binary, *args],
capture_output=True,
text=True,
timeout=timeout,
env=_child_env(),
stdin=subprocess.DEVNULL,
)
def _json_out(binary: str, *args: str, timeout: float) -> Any:
"""Run ``binary args`` and parse stdout as JSON, or ``None`` on any failure."""
raw = (_run(binary, *args, timeout=timeout).stdout or "").strip()
return json.loads(raw) if raw else None
def _doctor(binary: str) -> Optional[Dict[str, Any]]:
"""``cua-driver doctor --json`` → ``{ok, checks:[{label,status,message}]}``."""
try:
data = _json_out(binary, "doctor", "--json", timeout=12)
except Exception:
return None
if not isinstance(data, dict):
return None
checks: List[Dict[str, str]] = [
{
"label": str(p.get("label", "")),
"status": str(p.get("status", "")),
"message": str(p.get("message", "")),
}
for p in data.get("probes", [])
if isinstance(p, dict)
]
return {"ok": bool(data.get("ok")), "checks": checks}
def _mac_permissions(binary: str, out: Dict[str, Any]) -> None:
"""Fold ``cua-driver permissions status --json`` booleans into ``out``."""
try:
data = _json_out(binary, "permissions", "status", "--json", timeout=10)
except subprocess.TimeoutExpired:
out["error"] = "cua-driver permissions status timed out"
return
except Exception as exc: # spawn failure or malformed JSON
out["error"] = f"cua-driver permissions status failed: {exc}"
return
if isinstance(data, dict):
out.update({k: data[k] for k in _BOOLS if isinstance(data.get(k), bool)})
if isinstance(data.get("source"), dict):
out["source"] = data["source"]
def computer_use_status(driver_cmd: Optional[str] = None) -> Dict[str, Any]:
"""Unified, OS-aware Computer Use readiness for the desktop card.
``ready`` is the single signal the UI keys off: on macOS it's both TCC
grants; elsewhere it's driver health (no TCC model). ``None`` means
unknown (binary missing / probe failed). ``can_grant`` is macOS-only.
"""
plat = sys.platform
binary = shutil.which(_driver_cmd(driver_cmd))
out: Dict[str, Any] = {
"platform": plat,
"platform_supported": plat in _RUNTIME_PLATFORMS,
"installed": bool(binary),
"version": None,
"ready": None,
"can_grant": plat == "darwin",
"checks": [],
"source": None,
"error": None,
**{k: None for k in _BOOLS},
}
if not binary:
return out
try:
out["version"] = (_run(binary, "--version", timeout=5).stdout or "").strip() or None
except Exception:
pass
doctor = _doctor(binary)
if doctor is not None:
out["checks"] = doctor["checks"]
if plat == "darwin":
_mac_permissions(binary, out)
if out["error"] is None:
out["ready"] = out["accessibility"] is True and out["screen_recording"] is True
elif doctor is not None:
# No TCC model off macOS — readiness is driver health.
out["ready"] = doctor["ok"]
return out
def request_permissions_grant(driver_cmd: Optional[str] = None) -> int:
"""Run ``cua-driver permissions grant`` (macOS); stream its output.
Launches CuaDriver via LaunchServices so the TCC dialog is attributed to
``com.trycua.driver``, then waits for the grant. Returns the driver's exit
code (0 ok), 2 if the binary is missing, 64 on a non-macOS platform (which
has no TCC permission model to grant).
"""
if sys.platform != "darwin":
print("Computer Use permissions are a macOS concept; nothing to grant here.")
return 64
binary = shutil.which(_driver_cmd(driver_cmd))
if not binary:
print("cua-driver: not installed. Run: hermes computer-use install")
return 2
print(
"Requesting Accessibility + Screen Recording for CuaDriver.\n"
"macOS will show a dialog attributed to CuaDriver (com.trycua.driver) — "
"approve it, then return here."
)
try:
return int(
subprocess.run(
[binary, "permissions", "grant"],
env=_child_env(),
stdin=subprocess.DEVNULL,
).returncode
)
except KeyboardInterrupt: # pragma: no cover - interactive
return 130
except Exception as exc: # pragma: no cover - defensive
print(f"cua-driver permissions grant failed: {exc}", file=sys.stderr)
return 2
+3 -8
View File
@@ -71,14 +71,9 @@ COMPUTER_USE_SCHEMA: Dict[str, Any] = {
"type": "string",
"description": (
"Optional. Limit capture/action to a specific app "
"(by name, e.g. 'Safari', or bundle ID, "
"'com.apple.Safari'). If omitted, operates on the "
"frontmost app's window. Pass app='screen' (or "
"'desktop') to capture the OS desktop/shell surface — "
"e.g. to see the wallpaper or click the taskbar. Note: "
"capture is per-window; a single image cannot span "
"multiple monitors, so on a multi-screen setup capture "
"one window or display at a time."
"(by name, e.g. 'Safari' or 'Notepad', or bundle ID "
"where the platform supports it). If omitted, operates "
"on the frontmost app's window or the whole screen."
),
},
"max_elements": {
+2
View File
@@ -539,6 +539,7 @@ def cronjob(
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
no_agent: Optional[bool] = None,
profile: Optional[str] = None,
task_id: str = None,
) -> str:
"""Unified cron job management tool."""
@@ -605,6 +606,7 @@ def cronjob(
enabled_toolsets=enabled_toolsets or None,
workdir=_normalize_optional_job_value(workdir),
no_agent=_no_agent,
profile=_normalize_optional_job_value(profile),
)
_notify_provider_jobs_changed_safe()
return json.dumps(
-97
View File
@@ -177,7 +177,6 @@ _LONG_HANDLERS = frozenset(
"billing.step_up",
"browser.manage",
"cli.exec",
"llm.oneshot",
"plugins.manage",
"session.branch",
"session.compress",
@@ -4534,24 +4533,6 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"session_id": None})
@method("project.facts")
def _(rid, params: dict) -> dict:
"""Structured project facts for a cwd — manifests, package manager, the
exact verify commands, and context files.
The same detection the coding-context posture (#43316) bakes into the system
prompt, exposed so UIs (the desktop verify surface) consume it instead of
re-sniffing. ``{"facts": null}`` means the cwd isn't a code workspace.
"""
try:
from agent.coding_context import project_facts_for
return _ok(rid, {"facts": project_facts_for(params.get("cwd"))})
except Exception:
logger.exception("project.facts failed")
return _ok(rid, {"facts": None})
@method("session.resume")
def _(rid, params: dict) -> dict:
target = params.get("session_id", "")
@@ -5201,84 +5182,6 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5007, str(e))
def _main_runtime_from_agent(agent) -> dict | None:
"""Build an aux-client main_runtime override from a live agent.
Lets a one-shot inherit the session's provider/model/credentials so its
output matches the model the user is actually coding with, instead of
falling back to the cheapest auto-detected backend.
"""
if agent is None:
return None
runtime: dict = {}
for field in ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode"):
value = getattr(agent, field, None)
if isinstance(value, str) and value.strip():
runtime[field] = value.strip()
elif field == "api_key" and callable(value):
runtime[field] = value
return runtime or None
@method("llm.oneshot")
def _(rid, params: dict) -> dict:
"""Run a single stateless LLM request outside any conversation.
Generic helper for small generative chores (e.g. a commit message from a
diff). Accepts either a named ``template`` + ``variables`` or an explicit
``instructions`` / ``input`` pair. When ``session_id`` resolves to a live
session the call inherits that agent's model; otherwise it uses the
configured auxiliary ``task`` backend. Never mutates session history, so
prompt caching is untouched.
"""
template = (params.get("template") or "").strip() or None
instructions = params.get("instructions") or ""
user_input = params.get("input") or ""
variables = params.get("variables") if isinstance(params.get("variables"), dict) else {}
task = (params.get("task") or "title_generation").strip() or "title_generation"
try:
max_tokens = int(params.get("max_tokens") or 1024)
except (TypeError, ValueError):
max_tokens = 1024
temperature = params.get("temperature")
if temperature is not None:
try:
temperature = float(temperature)
except (TypeError, ValueError):
temperature = None
if not template and not str(instructions).strip() and not str(user_input).strip():
return _err(rid, 4030, "llm.oneshot requires a template or instructions/input")
# Optional: inherit the live session's model (no error if absent).
session = _sessions.get(params.get("session_id") or "")
main_runtime = _main_runtime_from_agent(session.get("agent")) if session else None
try:
from agent.oneshot import run_oneshot
text = run_oneshot(
instructions=instructions,
user_input=user_input,
template=template,
variables=variables,
task=task,
max_tokens=max_tokens,
temperature=temperature if temperature is not None else 0.3,
main_runtime=main_runtime,
)
except KeyError as e:
return _err(rid, 4031, str(e))
except ValueError as e:
return _err(rid, 4032, str(e))
except Exception as e:
logger.warning("llm.oneshot failed: %s", e)
return _err(rid, 5030, f"one-shot generation failed: {e}")
return _ok(rid, {"text": text})
@method("handoff.request")
def _(rid, params: dict) -> dict:
"""Queue a handoff of this session to a messaging platform.
@@ -61,8 +61,6 @@ AI-native cross-session user modeling with dialectic reasoning, session-scoped c
- `dialecticCadence` — how often the dialectic LLM fires (LLM call frequency)
- `dialecticDepth` — how many `.chat()` passes per dialectic invocation (1–3, depth of reasoning)
The auto-injected dialectic also scales its reasoning level by query length (longer query → deeper reasoning, capped at `reasoningLevelCap`); see [Query-Adaptive Reasoning Level](./honcho.md#query-adaptive-reasoning-level).
**Setup Wizard:**
```bash
hermes memory setup # select "honcho" — runs the Honcho-specific post-setup