opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
+128
-72
@@ -19,6 +19,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
||||
# fcntl is Unix-only; on Windows use msvcrt for file locking
|
||||
try:
|
||||
@@ -165,7 +166,7 @@ _parallel_pool_max_workers: Optional[int] = None
|
||||
_running_job_ids: set = set()
|
||||
_running_lock = threading.Lock()
|
||||
|
||||
# Sequential (env-mutating) cron jobs — workdir jobs that touch
|
||||
# Sequential (env/context-mutating) cron jobs — workdir/profile jobs that touch
|
||||
# process-global runtime state — must run one at a time, but must NOT block the
|
||||
# ticker thread. A persistent single-thread executor preserves ordering across
|
||||
# ticks while keeping dispatch fire-and-forget, the same as the parallel pool.
|
||||
@@ -189,10 +190,10 @@ def _get_parallel_pool(max_workers: Optional[int]) -> concurrent.futures.ThreadP
|
||||
def _get_sequential_pool() -> concurrent.futures.ThreadPoolExecutor:
|
||||
"""Return (or create) the persistent single-thread sequential pool.
|
||||
|
||||
A single worker guarantees env-mutating jobs never overlap, even
|
||||
A single worker guarantees env/context-mutating jobs never overlap, even
|
||||
across ticks: a job queued by a newer tick waits for the previous tick's
|
||||
sequential jobs to finish rather than corrupting their os.environ
|
||||
state.
|
||||
sequential jobs to finish rather than corrupting their os.environ /
|
||||
profile state.
|
||||
"""
|
||||
global _sequential_pool
|
||||
if _sequential_pool is None:
|
||||
@@ -234,6 +235,71 @@ def _get_lock_paths() -> tuple[Path, Path]:
|
||||
return lock_dir, lock_dir / ".tick.lock"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _job_profile_context(job_id: str, profile: Optional[str]):
|
||||
"""Temporarily run a job under a specific Hermes profile.
|
||||
|
||||
Cron jobs are stored and scheduled by the profile running the scheduler, but
|
||||
an individual job can opt into a different runtime profile. While active,
|
||||
the scheduler's test/override hook and a context-local Hermes home override
|
||||
both point at the resolved profile directory so _get_hermes_home(),
|
||||
.env/config loading, script resolution, AIAgent construction, and downstream
|
||||
get_hermes_home() callers agree on the same home.
|
||||
|
||||
Some existing provider/config paths still load profile .env values through
|
||||
os.environ, so profile jobs also snapshot and restore the process
|
||||
environment on exit. tick() runs profile jobs sequentially to keep that
|
||||
temporary mutation isolated from other scheduled jobs.
|
||||
"""
|
||||
raw_profile = str(profile or "").strip()
|
||||
if not raw_profile:
|
||||
yield None
|
||||
return
|
||||
|
||||
global _hermes_home
|
||||
prior_override = _hermes_home
|
||||
env_snapshot = os.environ.copy()
|
||||
|
||||
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
normalized_profile = normalize_profile_name(raw_profile)
|
||||
try:
|
||||
profile_home = Path(resolve_profile_env(normalized_profile)).resolve()
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"Job '%s': configured profile %r no longer valid (%s) — "
|
||||
"falling back to scheduler default",
|
||||
job_id, raw_profile, exc,
|
||||
)
|
||||
yield None
|
||||
return
|
||||
|
||||
override_token = None
|
||||
try:
|
||||
override_token = set_hermes_home_override(profile_home)
|
||||
_hermes_home = profile_home
|
||||
logger.info(
|
||||
"Job '%s': using Hermes profile '%s' (%s)",
|
||||
job_id,
|
||||
normalized_profile,
|
||||
profile_home,
|
||||
)
|
||||
yield normalized_profile
|
||||
finally:
|
||||
_hermes_home = prior_override
|
||||
if override_token is not None:
|
||||
reset_hermes_home_override(override_token)
|
||||
# Delta-based restore: remove added keys, restore changed keys.
|
||||
# Avoids a brief window where other threads see an empty env.
|
||||
added = set(os.environ.keys()) - set(env_snapshot.keys())
|
||||
for k in added:
|
||||
os.environ.pop(k, None)
|
||||
for k, v in env_snapshot.items():
|
||||
if os.environ.get(k) != v:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _resolve_origin(job: dict) -> Optional[dict]:
|
||||
"""Extract origin info from a job, preserving any extra routing metadata.
|
||||
|
||||
@@ -966,6 +1032,17 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
else:
|
||||
argv = [sys.executable, str(path)]
|
||||
|
||||
run_env = os.environ.copy()
|
||||
run_env["HERMES_HOME"] = str(_get_hermes_home())
|
||||
try:
|
||||
from hermes_constants import get_subprocess_home
|
||||
|
||||
profile_home = get_subprocess_home()
|
||||
if profile_home:
|
||||
run_env["HOME"] = profile_home
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
|
||||
result = subprocess.run(
|
||||
@@ -974,6 +1051,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
text=True,
|
||||
timeout=script_timeout,
|
||||
cwd=str(path.parent),
|
||||
env=run_env,
|
||||
**popen_kwargs,
|
||||
)
|
||||
stdout = (result.stdout or "").strip()
|
||||
@@ -1040,15 +1118,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
result is used for prompt injection. When omitted, the script
|
||||
(if any) runs inline as before.
|
||||
"""
|
||||
user_prompt = str(job.get("prompt") or "")
|
||||
prompt = user_prompt
|
||||
prompt = str(job.get("prompt") or "")
|
||||
skills = job.get("skills")
|
||||
# True when runtime-collected DATA (script stdout, upstream-job output)
|
||||
# has been injected into the prompt. Data content legitimately quotes
|
||||
# command-shape strings (a triage feed ingesting a bug report that
|
||||
# pastes `rm -rf /`), so it must not be scanned with the strict
|
||||
# user-prompt pattern set — see _scan_assembled_cron_prompt.
|
||||
has_injected_data = False
|
||||
|
||||
# Run data-collection script if configured, inject output as context.
|
||||
script_path = job.get("script")
|
||||
@@ -1066,7 +1137,6 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
f"```\n{script_output}\n```\n\n"
|
||||
f"{prompt}"
|
||||
)
|
||||
has_injected_data = True
|
||||
else:
|
||||
# Script produced no output — nothing to report, skip AI call.
|
||||
return None
|
||||
@@ -1077,7 +1147,6 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
f"```\n{script_output}\n```\n\n"
|
||||
f"{prompt}"
|
||||
)
|
||||
has_injected_data = True
|
||||
|
||||
# Inject output from referenced cron jobs as context.
|
||||
context_from = job.get("context_from")
|
||||
@@ -1120,7 +1189,6 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
f"```\n{latest_output}\n```\n\n"
|
||||
f"{prompt}"
|
||||
)
|
||||
has_injected_data = True
|
||||
else:
|
||||
continue # silent skip — empty output
|
||||
except (OSError, PermissionError) as e:
|
||||
@@ -1149,13 +1217,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
|
||||
skill_names = [str(name).strip() for name in skills if str(name).strip()]
|
||||
if not skill_names:
|
||||
return _scan_assembled_cron_prompt(
|
||||
prompt,
|
||||
job,
|
||||
has_skills=False,
|
||||
has_injected_data=has_injected_data,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
return _scan_assembled_cron_prompt(prompt, job, has_skills=False)
|
||||
|
||||
from tools.skills_tool import skill_view
|
||||
from tools.skill_usage import bump_use
|
||||
@@ -1232,14 +1294,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
return _scan_assembled_cron_prompt("\n".join(parts), job, has_skills=True)
|
||||
|
||||
|
||||
def _scan_assembled_cron_prompt(
|
||||
assembled: str,
|
||||
job: dict,
|
||||
*,
|
||||
has_skills: bool = False,
|
||||
has_injected_data: bool = False,
|
||||
user_prompt: Optional[str] = None,
|
||||
) -> str:
|
||||
def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool = False) -> str:
|
||||
"""Scan the fully-assembled cron prompt for injection patterns. Raises
|
||||
``CronPromptInjectionBlocked`` when a match fires so ``run_job`` can
|
||||
surface a clear refusal to the operator.
|
||||
@@ -1250,45 +1305,29 @@ def _scan_assembled_cron_prompt(
|
||||
(auto-approves tool calls), a malicious skill carrying an injection
|
||||
payload bypassed every gate.
|
||||
|
||||
Two pattern tiers, selected by what the assembled prompt CONTAINS,
|
||||
not just whether skills are attached:
|
||||
Two pattern tiers:
|
||||
|
||||
- When the assembled prompt is essentially the user prompt + the cron
|
||||
hint (no skills, no injected data), the STRICT ``_scan_cron_prompt``
|
||||
patterns apply: a bare ``rm -rf /`` in a small directive prompt is a
|
||||
smoking gun, not prose.
|
||||
- When the assembled prompt includes runtime-loaded content — skill
|
||||
markdown (``has_skills=True``) or DATA injected from a job script's
|
||||
stdout / an upstream job's output (``has_injected_data=True``) — the
|
||||
LOOSER ``_scan_cron_skill_assembled`` pattern set is used: only
|
||||
unambiguous prompt-injection directives block; command-shape
|
||||
patterns are dropped and invisible unicode is sanitized (stripped +
|
||||
logged) rather than blocked, to avoid false-positives that
|
||||
permanently kill a job. Skill bodies are vetted at install time by
|
||||
``skills_guard.py``; script output is produced by operator-authored
|
||||
code, the same trust class — and data feeds (e.g. a triage bot
|
||||
ingesting bug reports) legitimately quote dangerous commands.
|
||||
|
||||
When the looser tier is selected because of injected data only,
|
||||
``user_prompt`` (the raw, pre-assembly prompt) is additionally scanned
|
||||
with the STRICT set so the user-authored surface keeps the full
|
||||
create/update-time guarantee at runtime (defense-in-depth for legacy
|
||||
jobs that predate the create-time scanner).
|
||||
- When ``has_skills=False`` (no skills attached) the assembled prompt
|
||||
is essentially the user prompt + the cron hint, so the STRICT
|
||||
``_scan_cron_prompt`` patterns apply.
|
||||
- When ``has_skills=True`` the assembled prompt includes loaded skill
|
||||
markdown — often security docs / runbooks that *describe* attack
|
||||
commands in prose. The LOOSER ``_scan_cron_skill_assembled``
|
||||
pattern set is used: only unambiguous prompt-injection directives
|
||||
block; command-shape patterns are dropped and invisible unicode is
|
||||
sanitized (stripped + logged) rather than blocked, to avoid
|
||||
false-positives that permanently kill a job. Skill bodies are
|
||||
vetted at install time by ``skills_guard.py``.
|
||||
"""
|
||||
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled
|
||||
|
||||
if has_skills or has_injected_data:
|
||||
# Runtime-loaded content (vetted skill markdown and/or data from
|
||||
# operator-authored scripts) legitimately contains command-shape
|
||||
# strings. Invisible unicode is sanitized (not blocked) so a stray
|
||||
# zero-width space can't permanently kill the job; the cleaned
|
||||
if has_skills:
|
||||
# Skill content is install-time vetted by skills_guard.py. Invisible
|
||||
# unicode is sanitized (not blocked) so a stray zero-width space in a
|
||||
# skill code example can't permanently kill the job; the cleaned
|
||||
# prompt is what actually runs.
|
||||
cleaned, scan_error = _scan_cron_skill_assembled(assembled)
|
||||
assembled = cleaned
|
||||
if not scan_error and not has_skills and user_prompt:
|
||||
# Data-injection path: keep the strict guarantee on the
|
||||
# user-authored prompt itself.
|
||||
scan_error = _scan_cron_prompt(user_prompt)
|
||||
else:
|
||||
scan_error = _scan_cron_prompt(assembled)
|
||||
if scan_error:
|
||||
@@ -1303,6 +1342,13 @@ def _scan_assembled_cron_prompt(
|
||||
|
||||
|
||||
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"""Execute a single cron job, applying any per-job profile override."""
|
||||
job_id = job["id"]
|
||||
with _job_profile_context(job_id, job.get("profile")):
|
||||
return _run_job_impl(job)
|
||||
|
||||
|
||||
def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"""
|
||||
Execute a single cron job.
|
||||
|
||||
@@ -1539,8 +1585,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
# .cursorrules from the job's project dir, AND
|
||||
# - the terminal, file, and code-exec tools run commands from there.
|
||||
#
|
||||
# tick() serializes workdir-jobs outside the parallel pool, so mutating
|
||||
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
|
||||
# tick() serializes jobs that mutate process-global runtime state (workdir
|
||||
# and/or profile jobs) outside the parallel pool, so mutating
|
||||
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
|
||||
# jobs we leave TERMINAL_CWD untouched — preserves the original behaviour
|
||||
# (skip_context_files=True, tools use whatever cwd the scheduler has).
|
||||
_job_workdir = (job.get("workdir") or "").strip() or None
|
||||
@@ -2087,12 +2134,21 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
|
||||
mark_job_run(job["id"], False, str(e))
|
||||
return False
|
||||
|
||||
# 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: jobs with a per-job workdir and/or profile touch
|
||||
# process-global runtime state inside run_job. Workdir jobs temporarily
|
||||
# set os.environ["TERMINAL_CWD"]; profile jobs use a context-local
|
||||
# Hermes home override, scheduler _hermes_home hook, and temporary
|
||||
# profile .env load into os.environ with snapshot/restore. They MUST run
|
||||
# sequentially to avoid corrupting each other. Jobs without either field
|
||||
# stay parallel-safe.
|
||||
sequential_jobs = [
|
||||
j for j in due_jobs
|
||||
if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip()
|
||||
]
|
||||
parallel_jobs = [
|
||||
j for j in due_jobs
|
||||
if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip())
|
||||
]
|
||||
|
||||
_results: list = []
|
||||
_all_futures: list = []
|
||||
@@ -2121,9 +2177,9 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
|
||||
|
||||
return pool.submit(_run_and_release)
|
||||
|
||||
# Sequential pass for env-mutating (workdir) jobs.
|
||||
# Sequential pass for env/context-mutating (workdir/profile) jobs.
|
||||
# Queued to a persistent single-thread pool so they run one at a time
|
||||
# WITHOUT blocking the ticker thread — a long workdir job no
|
||||
# WITHOUT blocking the ticker thread — a long workdir/profile job no
|
||||
# longer starves the rest of the schedule (same fix as the parallel
|
||||
# pass, just serialized). The in-flight guard prevents a still-running
|
||||
# job from being re-queued on the next tick.
|
||||
|
||||
Reference in New Issue
Block a user