Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
876964f5a8 |
+21
-72
@@ -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).
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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]')))
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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'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'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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Vendored
+5
-50
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'プレビュー',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '預覽',
|
||||
|
||||
@@ -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: '预览',
|
||||
|
||||
@@ -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:')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user