Merge remote-tracking branch 'origin/main' into bb/gui

# Conflicts:
#	apps/dashboard/src/i18n/af.ts
#	apps/dashboard/src/i18n/de.ts
#	apps/dashboard/src/i18n/es.ts
#	apps/dashboard/src/i18n/fr.ts
#	apps/dashboard/src/i18n/ga.ts
#	apps/dashboard/src/i18n/hu.ts
#	apps/dashboard/src/i18n/it.ts
#	apps/dashboard/src/i18n/ja.ts
#	apps/dashboard/src/i18n/ko.ts
#	apps/dashboard/src/i18n/pt.ts
#	apps/dashboard/src/i18n/ru.ts
#	apps/dashboard/src/i18n/tr.ts
#	apps/dashboard/src/i18n/uk.ts
#	apps/dashboard/src/i18n/zh-hant.ts
#	gateway/config.py
#	hermes_cli/main.py
#	plugins/strike-freedom-cockpit/README.md
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-11 16:40:09 -04:00
310 changed files with 43818 additions and 4220 deletions
+85 -7
View File
@@ -221,6 +221,40 @@ HARDLINE_PATTERNS_COMPILED = [
]
# =========================================================================
# Sudo stdin guard — block password guessing via "sudo -S"
# =========================================================================
# When SUDO_PASSWORD is not configured, any explicit "sudo -S" in the
# command is the LLM piping a guessed password via stdin. This is a
# brute-force attack vector: the model iterates through candidate
# passwords, inspects sudo's "Sorry, try again" output, and refines.
# Treat this as an unconditional block — there is never a legitimate
# reason for the agent to pipe passwords to sudo -S when no password
# has been configured.
_SUDO_STDIN_RE = re.compile(
r'(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+-S\b',
re.IGNORECASE)
def _check_sudo_stdin_guard(command: str) -> tuple:
"""Detect ``sudo -S`` (stdin password) without configured SUDO_PASSWORD.
When SUDO_PASSWORD is set, ``_transform_sudo_command`` injects ``-S``
internally — that path is legitimate and handled elsewhere. This guard
only fires when SUDO_PASSWORD is *not* set, meaning the LLM explicitly
wrote ``sudo -S`` to pipe a guessed password.
Returns:
(is_blocked: bool, description: str | None)
"""
if "SUDO_PASSWORD" in os.environ:
return (False, None)
normalized = _normalize_command_for_detection(command).lower()
if _SUDO_STDIN_RE.search(normalized):
return (True, "sudo password guessing via stdin (sudo -S)")
return (False, None)
def detect_hardline_command(command: str) -> tuple:
"""Check if a command matches the unconditional hardline blocklist.
@@ -250,6 +284,20 @@ def _hardline_block_result(description: str) -> dict:
}
def _sudo_stdin_block_result(description: str) -> dict:
"""Build the standard block result for sudo stdin guard."""
return {
"approved": False,
"message": (
f"BLOCKED: {description}. "
"Do not pipe passwords to 'sudo -S' — this is a brute-force "
"attack vector. Set SUDO_PASSWORD in your .env file if the "
"agent needs passwordless sudo, or run the sudo command "
"manually in your own terminal."
),
}
# =========================================================================
# Dangerous command patterns
# =========================================================================
@@ -320,6 +368,25 @@ DANGEROUS_PATTERNS = [
# a script is first made executable then immediately run. The script
# content may contain dangerous commands that individual patterns miss.
(r'\bchmod\s+\+x\b.*[;&|]+\s*\./', "chmod +x followed by immediate execution"),
# Sudo with stdin / askpass / shell / list-privs flags. An LLM-driven
# agent has no TTY, so sudo invocations that succeed without human
# interaction are those reading the password from stdin (-S/--stdin)
# or via an askpass helper (-A/--askpass). The shell-launch (-s) and
# list-privileges (-a) flags are also gated since they are
# privilege-relevant invocations the agent can chain after acquiring
# the password (e.g. read SUDO_PASSWORD from .env -> sudo -S -s ->
# root shell). Plain `sudo cmd` (no flag) is TTY-bound and excluded.
# `_normalize_command_for_detection` lowercases input before pattern
# matching, so case variants of S/s and A/a collapse — both forms
# are gated below. Lazy `[^;|&\n]*?` allows flag arguments (e.g.
# `sudo -u root -S whoami`) without spanning command separators. See
# #17873 category 4.
(r'\bsudo\b[^;|&\n]*?\s+(?:-s\b|--stdin\b|-a\b|--askpass\b)',
"sudo with privilege flag (stdin/askpass/shell/list)"),
# Combined short-flag form: -nS, -ns, -sa, -las — sudo flags packed
# into a single -X token. Catches the same threat class.
(r'\bsudo\b[^;|&\n]*?\s+-[a-z]*[sa][a-z]*\b',
"sudo with combined-flag privilege escalation"),
]
@@ -692,13 +759,13 @@ def prompt_dangerous_approval(command: str, description: str,
return "deny"
choice = result["choice"]
if choice in ('o', 'once'):
if choice in {'o', 'once'}:
print(t("approval.allowed_once"))
return "once"
elif choice in ('s', 'session'):
elif choice in {'s', 'session'}:
print(t("approval.allowed_session"))
return "session"
elif choice in ('a', 'always'):
elif choice in {'a', 'always'}:
if not allow_permanent:
print(t("approval.allowed_session"))
return "session"
@@ -764,7 +831,7 @@ def _get_cron_approval_mode() -> str:
from hermes_cli.config import load_config
config = load_config()
mode = str(cfg_get(config, "approvals", "cron_mode", default="deny")).lower().strip()
if mode in ("approve", "off", "allow", "yes"):
if mode in {"approve", "off", "allow", "yes"}:
return "approve"
return "deny"
except Exception:
@@ -833,7 +900,7 @@ def check_dangerous_command(command: str, env_type: str,
Returns:
{"approved": True/False, "message": str or None, ...}
"""
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
return {"approved": True, "message": None}
# Hardline floor: commands with no recovery path (rm -rf /, mkfs, dd
@@ -958,7 +1025,7 @@ def check_all_command_guards(command: str, env_type: str,
other was shown to the user.
"""
# Skip containers for both checks
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
return {"approved": True, "message": None}
# Hardline floor: unconditional block for catastrophic commands
@@ -970,6 +1037,17 @@ def check_all_command_guards(command: str, env_type: str,
logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200])
return _hardline_block_result(hardline_desc)
# == Sudo stdin guard ==
# Like the hardline floor above, this is unconditional: there is never a
# legitimate reason for the agent to pipe passwords to sudo -S when no
# SUDO_PASSWORD has been configured. This must fire BEFORE the yolo
# check so even yolo/smart approval/mode=off cannot bypass it.
is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command)
if is_sudo_guess:
logger.warning("Sudo stdin guard block: %s (command: %s)",
sudo_guess_desc, command[:200])
return _sudo_stdin_block_result(sudo_guess_desc)
# --yolo or approvals.mode=off: bypass all approval prompts.
# Gateway /yolo is session-scoped; CLI --yolo remains process-scoped.
approval_mode = _get_approval_mode()
@@ -1026,7 +1104,7 @@ def check_all_command_guards(command: str, env_type: str,
# Previously, tirith "block" was a hard block with no approval prompt.
# Now both block and warn go through the approval flow so users can
# inspect the explanation and approve if they understand the risk.
if tirith_result["action"] in ("block", "warn"):
if tirith_result["action"] in {"block", "warn"}:
findings = tirith_result.get("findings") or []
rule_id = findings[0].get("rule_id", "unknown") if findings else "unknown"
tirith_key = f"tirith:{rule_id}"
+1 -1
View File
@@ -184,7 +184,7 @@ class BrowserUseProvider(CloudBrowserProvider):
json={"action": "stop"},
timeout=10,
)
if response.status_code in (200, 201, 204):
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Browser Use session %s", session_id)
return True
else:
+1 -1
View File
@@ -180,7 +180,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
},
timeout=10,
)
if response.status_code in (200, 201, 204):
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Browserbase session %s", session_id)
return True
else:
+1 -1
View File
@@ -79,7 +79,7 @@ class FirecrawlProvider(CloudBrowserProvider):
headers=self._headers(),
timeout=10,
)
if response.status_code in (200, 201, 204):
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Firecrawl session %s", session_id)
return True
else:
+86 -3
View File
@@ -412,7 +412,7 @@ class CDPSupervisor:
``{"ok": False, "error": "..."}`` on a recoverable error (no dialog,
ambiguous dialog_id, supervisor inactive).
"""
if action not in ("accept", "dismiss"):
if action not in {"accept", "dismiss"}:
return {"ok": False, "error": f"action must be 'accept' or 'dismiss', got {action!r}"}
with self._state_lock:
@@ -457,6 +457,89 @@ class CDPSupervisor:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return {"ok": True, "dialog": snapshot_copy.to_dict()}
def evaluate_runtime(
self,
expression: str,
*,
return_by_value: bool = True,
await_promise: bool = True,
timeout: float = 10.0,
) -> Dict[str, Any]:
"""Evaluate ``expression`` in the page's Runtime context over the live WS.
Reuses the supervisor's already-connected WebSocket — zero subprocess
startup cost vs the agent-browser CLI ``eval`` command (which does
fork+exec+Node-startup+CDP-setup on every call).
Returns a dict shaped like ``{"ok": True, "result": <value>, "result_type": "..."}``
on success, or ``{"ok": False, "error": "..."}`` on failure.
``return_by_value=True`` asks the browser to JSON-serialize the result
before sending it back, matching DevTools-console semantics for
primitive / plain-object expressions. For DOM nodes or non-serializable
objects, the browser returns a description string in ``result_type``.
"""
loop = self._loop
if loop is None or not loop.is_running():
return {"ok": False, "error": "supervisor loop is not running"}
with self._state_lock:
if not self._active:
return {"ok": False, "error": "supervisor is not active"}
session_id = self._page_session_id
if not session_id:
return {"ok": False, "error": "supervisor has no attached page session"}
async def _do_eval() -> Dict[str, Any]:
return await self._cdp(
"Runtime.evaluate",
{
"expression": expression,
"returnByValue": return_by_value,
"awaitPromise": await_promise,
# userGesture matters for things like clipboard / fullscreen
# APIs that require a user-activation context.
"userGesture": True,
},
session_id=session_id,
timeout=timeout,
)
try:
fut = asyncio.run_coroutine_threadsafe(_do_eval(), loop)
response = fut.result(timeout=timeout + 1)
except Exception as exc:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
# Runtime.evaluate response shape:
# {"id": N, "result": {"result": {"type": "...", "value": ..., ...},
# "exceptionDetails": {...} (only on error)}}
result_payload = response.get("result", {}) if isinstance(response, dict) else {}
exception_details = result_payload.get("exceptionDetails")
if exception_details:
# Surface the JS-side exception with a clean message.
exc_text = exception_details.get("text") or "JavaScript exception"
exc_obj = exception_details.get("exception") or {}
description = exc_obj.get("description")
if description:
exc_text = f"{exc_text}: {description}"
return {"ok": False, "error": exc_text}
result_obj = result_payload.get("result", {})
result_type = result_obj.get("type", "undefined")
if "value" in result_obj:
value = result_obj["value"]
elif result_type == "undefined":
value = None
else:
# Non-serializable (functions, DOM nodes, etc.) — return the
# browser's string description so the model gets *something*.
value = result_obj.get("description") or result_obj.get("unserializableValue")
return {"ok": True, "result": value, "result_type": result_type}
# ── Supervisor loop internals ────────────────────────────────────────────
def _thread_main(self) -> None:
@@ -1123,7 +1206,7 @@ class CDPSupervisor:
info = params.get("targetInfo") or {}
sid = params.get("sessionId")
target_type = info.get("type")
if not sid or target_type not in ("iframe", "worker"):
if not sid or target_type not in {"iframe", "worker"}:
return
self._child_sessions[sid] = {"info": info, "type": target_type}
@@ -1207,7 +1290,7 @@ class CDPSupervisor:
event = ConsoleEvent(ts=time.time(), level="exception", text=text, url=url)
else:
raw_level = str(params.get("type") or "log")
level = "error" if raw_level in ("error", "assert") else (
level = "error" if raw_level in {"error", "assert"} else (
"warning" if raw_level == "warning" else "log"
)
args = params.get("args") or []
+49 -2
View File
@@ -918,7 +918,7 @@ def _url_is_private(url: str) -> bool:
# Hostname — must resolve to confirm it's private (bare "localhost"
# resolves to 127.0.0.1 via /etc/hosts). Short-circuit on obvious
# names to avoid a DNS hop.
if hostname in ("localhost",) or hostname.endswith(".localhost"):
if hostname in {"localhost",} or hostname.endswith(".localhost"):
return True
if hostname.endswith(".local") or hostname.endswith(".lan") or hostname.endswith(".internal"):
return True
@@ -2499,7 +2499,7 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
JSON string with scroll result
"""
# Validate direction
if direction not in ["up", "down"]:
if direction not in {"up", "down"}:
return json.dumps({
"success": False,
"error": f"Invalid direction '{direction}'. Use 'up' or 'down'."
@@ -2671,6 +2671,53 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
return _camofox_eval(expression, task_id)
effective_task_id = _last_session_key(task_id or "default")
# --- Fast path: route through the supervisor's persistent CDP WS ---------
# When a CDPSupervisor is alive for this task_id, ``Runtime.evaluate`` runs
# on the already-connected WebSocket — zero subprocess startup cost vs
# spawning an ``agent-browser eval`` CLI process. Falls through to the
# subprocess path on any error so behaviour is unchanged when no
# supervisor is running (e.g. plain agent-browser without a CDP backend).
try:
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
supervisor = SUPERVISOR_REGISTRY.get(effective_task_id)
if supervisor is not None:
sup_result = supervisor.evaluate_runtime(expression)
if sup_result.get("ok"):
raw_result = sup_result.get("result")
# Match the agent-browser path: if the value is a JSON string,
# parse it so the model gets structured data.
parsed = raw_result
if isinstance(raw_result, str):
try:
parsed = json.loads(raw_result)
except (json.JSONDecodeError, ValueError):
pass # keep as string
response = {
"success": True,
"result": parsed,
"result_type": type(parsed).__name__,
"method": "cdp_supervisor",
}
return json.dumps(response, ensure_ascii=False, default=str)
# JS exception is a real failure — surface it instead of falling
# through to the subprocess path (which would just re-run and
# produce the same exception, but slower).
err = sup_result.get("error") or "evaluate_runtime failed"
if "supervisor" not in err.lower():
# Real JS-side error — return it.
return json.dumps({"success": False, "error": err}, ensure_ascii=False)
# Supervisor-side failure (loop down, no session) — fall through.
logger.debug(
"browser_eval: supervisor path unavailable (%s), falling back to subprocess",
err,
)
except ImportError:
pass
except Exception as exc: # pragma: no cover — defensive
logger.debug("browser_eval: supervisor path errored (%s), falling back", exc)
# --- Fallback: agent-browser CLI subprocess (original path) -------------
result = _run_browser_command(effective_task_id, "eval", [expression])
if not result.get("success"):
+3 -5
View File
@@ -639,7 +639,7 @@ class CheckpointManager:
abs_dir = str(_normalize_path(working_dir))
# Skip root, home, and other overly broad directories
if abs_dir in ("/", str(Path.home())):
if abs_dir in {"/", str(Path.home())}:
logger.debug("Checkpoint skipped: directory too broad (%s)", abs_dir)
return False
@@ -1312,8 +1312,7 @@ def prune_checkpoints(
for p in child.rglob("*"):
try:
mt = p.stat().st_mtime
if mt > newest:
newest = mt
newest = max(newest, mt)
except OSError:
continue
except OSError:
@@ -1455,8 +1454,7 @@ def prune_checkpoints(
size_after = _dir_size_bytes(base)
delta = size_before - size_after
if delta > result["bytes_freed"]:
result["bytes_freed"] = delta
result["bytes_freed"] = max(result["bytes_freed"], delta)
return result
+1 -1
View File
@@ -612,7 +612,7 @@ def _get_or_create_env(task_id: str):
cwd = overrides.get("cwd") or config["cwd"]
container_config = None
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
+1 -1
View File
@@ -673,5 +673,5 @@ def _parse_element(d: Dict[str, Any]) -> UIElement:
pid=int(d.get("pid", 0) or 0),
window_id=int(d.get("windowId", 0) or 0),
attributes={k: v for k, v in d.items()
if k not in ("index", "role", "label", "bounds", "app", "pid", "windowId")},
if k not in {"index", "role", "label", "bounds", "app", "pid", "windowId"}},
)
+4 -4
View File
@@ -131,7 +131,7 @@ def _get_backend() -> ComputerUseBackend:
with _backend_lock:
if _backend is None:
backend_name = os.environ.get("HERMES_COMPUTER_USE_BACKEND", "cua").lower()
if backend_name in ("cua", "cua-driver", ""):
if backend_name in {"cua", "cua-driver", ""}:
from tools.computer_use.cua_backend import CuaDriverBackend
_backend = CuaDriverBackend()
elif backend_name == "noop": # pragma: no cover
@@ -286,7 +286,7 @@ def _request_approval(action: str, args: Dict[str, Any]) -> Optional[str]:
def _summarize_action(action: str, args: Dict[str, Any]) -> str:
if action in ("click", "double_click", "right_click", "middle_click"):
if action in {"click", "double_click", "right_click", "middle_click"}:
if args.get("element") is not None:
return f"{action} element #{args['element']}"
coord = args.get("coordinate")
@@ -314,7 +314,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) ->
if action == "capture":
mode = str(args.get("mode", "som"))
if mode not in ("som", "vision", "ax"):
if mode not in {"som", "vision", "ax"}:
return json.dumps({"error": f"bad mode {mode!r}; use som|vision|ax"})
cap = backend.capture(mode=mode, app=args.get("app"))
return _capture_response(cap)
@@ -335,7 +335,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) ->
res = backend.focus_app(app, raise_window=bool(args.get("raise_window")))
return _maybe_follow_capture(backend, res, capture_after)
if action in ("click", "double_click", "right_click", "middle_click"):
if action in {"click", "double_click", "right_click", "middle_click"}:
button = args.get("button")
click_count = 1
if action == "double_click":
+2 -3
View File
@@ -327,9 +327,8 @@ def cronjob(
"the script is the job.",
success=False,
)
else:
if not prompt and not canonical_skills:
return tool_error("create requires either prompt or at least one skill", success=False)
elif not prompt and not canonical_skills:
return tool_error("create requires either prompt or at least one skill", success=False)
if prompt:
scan_error = _scan_cron_prompt(prompt)
if scan_error:
+5 -5
View File
@@ -315,7 +315,7 @@ def _normalize_role(r: Optional[str]) -> str:
if r is None or not r:
return "leaf"
r_norm = str(r).strip().lower()
if r_norm in ("leaf", "orchestrator"):
if r_norm in {"leaf", "orchestrator"}:
return r_norm
logger.warning("Unknown delegate_task role=%r, coercing to 'leaf'", r)
return "leaf"
@@ -437,7 +437,7 @@ def _get_orchestrator_enabled() -> bool:
return val
# Accept "true"/"false" strings from YAML that doesn't auto-coerce.
if isinstance(val, str):
return val.strip().lower() in ("true", "1", "yes", "on")
return val.strip().lower() in {"true", "1", "yes", "on"}
return True
@@ -1239,7 +1239,7 @@ def _dump_subagent_timeout_diagnostic(
if tool_names:
_w(f" loaded tool count: {len(tool_names)}")
try:
_w(f" loaded tools: {sorted(list(tool_names))}")
_w(f" loaded tools: {sorted(tool_names)}")
except Exception:
pass
_w("")
@@ -2271,9 +2271,9 @@ def delegate_task(
# total as "none" when the parent itself hadn't billed any calls
# yet (rare but possible when the parent's only action this turn
# was delegate_task).
if getattr(parent_agent, "session_cost_source", "none") in (None, "", "none"):
if getattr(parent_agent, "session_cost_source", "none") in {None, "", "none"}:
parent_agent.session_cost_source = "subagent"
if getattr(parent_agent, "session_cost_status", "unknown") in (None, "", "unknown"):
if getattr(parent_agent, "session_cost_status", "unknown") in {None, "", "unknown"}:
parent_agent.session_cost_status = "estimated"
except Exception:
logger.debug("Subagent cost rollup failed", exc_info=True)
+2 -2
View File
@@ -124,7 +124,7 @@ class DaytonaEnvironment(BaseEnvironment):
home = self._sandbox.process.exec("echo $HOME").result.strip()
if home:
self._remote_home = home
if requested_cwd in ("~", "/home/daytona"):
if requested_cwd in {"~", "/home/daytona"}:
self.cwd = home
except Exception:
pass
@@ -195,7 +195,7 @@ class DaytonaEnvironment(BaseEnvironment):
def _ensure_sandbox_ready(self) -> None:
"""Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
self._sandbox.refresh_data()
if self._sandbox.state in (self._SandboxState.STOPPED, self._SandboxState.ARCHIVED):
if self._sandbox.state in {self._SandboxState.STOPPED, self._SandboxState.ARCHIVED}:
self._sandbox.start()
logger.info("Daytona: restarted sandbox %s", self._sandbox.id)
+11
View File
@@ -300,6 +300,7 @@ class DockerEnvironment(BaseEnvironment):
host_cwd: str = None,
auto_mount_cwd: bool = False,
run_as_host_user: bool = False,
extra_args: list = None,
):
if cwd == "~":
cwd = "/root"
@@ -476,6 +477,15 @@ class DockerEnvironment(BaseEnvironment):
security_args = _build_security_args(run_as_host_user and bool(user_args))
logger.info(f"Docker volume_args: {volume_args}")
# User-supplied extra docker run flags (docker_extra_args in config.yaml).
# Appended last so they can override defaults if needed.
validated_extra = []
for arg in (extra_args or []):
if not isinstance(arg, str):
logger.warning("Ignoring non-string docker_extra_args entry: %r", arg)
continue
validated_extra.append(arg)
all_run_args = (
security_args
+ user_args
@@ -483,6 +493,7 @@ class DockerEnvironment(BaseEnvironment):
+ resource_args
+ volume_args
+ env_args
+ validated_extra
)
logger.info(f"Docker run_args: {all_run_args}")
+11
View File
@@ -274,6 +274,17 @@ def _make_run_env(env: dict) -> dict:
if _profile_home:
run_env["HOME"] = _profile_home
# Inject ContextVar-based session vars into subprocess env.
# ContextVars don't propagate to child processes, so we bridge them here.
try:
from gateway.session_context import get_session_env, _UNSET, _VAR_MAP
for var_name, var in _VAR_MAP.items():
value = var.get()
if value is not _UNSET and value:
run_env[var_name] = value
except Exception:
pass
return run_env
+2 -2
View File
@@ -254,7 +254,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
self.init_session()
def _build_create_params(self, *, cpu: float, memory: int, disk: int) -> _SandboxCreateParams:
if disk not in (0, _DEFAULT_CONTAINER_DISK_MB):
if disk not in {0, _DEFAULT_CONTAINER_DISK_MB}:
raise ValueError(
"Vercel Sandbox does not support configurable container_disk. "
"Use the default shared setting."
@@ -336,7 +336,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
if requested_cwd == "~":
self.cwd = self._remote_home
elif requested_cwd in ("", DEFAULT_VERCEL_CWD):
elif requested_cwd in {"", DEFAULT_VERCEL_CWD}:
self.cwd = self._workspace_root
else:
self.cwd = requested_cwd
+2 -2
View File
@@ -1244,7 +1244,7 @@ class ShellFileOperations(FileOperations):
search_root = Path(path)
has_hidden_path_ancestor = any(
part not in (".", "..") and part.startswith(".")
part not in {".", ".."} and part.startswith(".")
for part in search_root.parts
)
@@ -1305,7 +1305,7 @@ class ShellFileOperations(FileOperations):
rel_parts = Path(file_path).resolve().relative_to(normalized_root).parts
except ValueError:
rel_parts = Path(file_path).parts
if any(part not in (".", "..") and part.startswith(".") for part in rel_parts):
if any(part not in {".", ".."} and part.startswith(".") for part in rel_parts):
continue
filtered_files.append(file_path)
files = filtered_files[offset:offset + limit]
+1 -1
View File
@@ -380,7 +380,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
logger.info("Creating new %s environment for task %s...", env_type, task_id[:8])
container_config = None
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
+1 -2
View File
@@ -505,8 +505,7 @@ def _calculate_line_positions(content_lines: List[str], start_line: int,
"""
start_pos = sum(len(line) + 1 for line in content_lines[:start_line])
end_pos = sum(len(line) + 1 for line in content_lines[:end_line]) - 1
if end_pos >= content_length:
end_pos = content_length
end_pos = min(content_length, end_pos)
return start_pos, end_pos
+1 -1
View File
@@ -575,7 +575,7 @@ def _build_fal_payload(
payload: Dict[str, Any] = dict(meta.get("defaults", {}))
payload["prompt"] = (prompt or "").strip()
if size_style in ("image_size_preset", "gpt_literal"):
if size_style in {"image_size_preset", "gpt_literal"}:
payload["image_size"] = sizes[aspect]
elif size_style == "aspect_ratio":
payload["aspect_ratio"] = sizes[aspect]
+283 -17
View File
@@ -39,22 +39,11 @@ logger = logging.getLogger(__name__)
# Gating
# ---------------------------------------------------------------------------
def _check_kanban_mode() -> bool:
"""Tools are available when:
KANBAN_LIST_DEFAULT_LIMIT = 50
KANBAN_LIST_MAX_LIMIT = 200
1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR
2. The current profile has ``kanban`` in its toolsets config
(orchestrator profiles like techlead that route work via Kanban).
Humans running ``hermes chat`` without the kanban toolset see zero
kanban tools. Workers spawned by the kanban dispatcher (gateway-
embedded by default) and orchestrator profiles with the kanban
toolset enabled see all seven.
"""
if os.environ.get("HERMES_KANBAN_TASK"):
return True
# Check if the current profile has the kanban toolset enabled.
def _profile_has_kanban_toolset() -> bool:
# Uses load_config() which has mtime-based caching, so this adds
# negligible overhead. The check_fn results are further TTL-cached
# (~30s) by the tool registry.
@@ -67,6 +56,37 @@ def _check_kanban_mode() -> bool:
return False
def _check_kanban_mode() -> bool:
"""Task-lifecycle tools are available when:
1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR
2. The current profile has ``kanban`` in its toolsets config
(orchestrator profiles like techlead that route work via Kanban).
Humans running ``hermes chat`` without the kanban toolset see zero
kanban tools. Workers spawned by the kanban dispatcher (gateway-
embedded by default) and orchestrator profiles with the kanban
toolset enabled see the Kanban lifecycle tool surface.
"""
if os.environ.get("HERMES_KANBAN_TASK"):
return True
return _profile_has_kanban_toolset()
def _check_kanban_orchestrator_mode() -> bool:
"""Board-routing tools (kanban_list, kanban_unblock) are intentionally
hidden from task workers.
Dispatcher-spawned workers should close their own task via the
lifecycle tools (complete/block/heartbeat), not enumerate or unblock
board state. Profiles that explicitly opt into the kanban toolset
and are NOT scoped to a single task are the orchestrator surface.
"""
if os.environ.get("HERMES_KANBAN_TASK"):
return False
return _profile_has_kanban_toolset()
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
@@ -135,6 +155,73 @@ def _ok(**fields: Any) -> str:
return json.dumps({"ok": True, **fields})
def _normalize_profile(value: Any) -> Optional[str]:
"""Normalize CLI-compatible assignee sentinels for the tool surface."""
if value is None:
return None
text = str(value).strip()
if not text or text.lower() in {"none", "-", "null"}:
return None
return text
def _parse_bool_arg(args: dict, name: str, *, default: bool = False):
value = args.get(name)
if value is None:
return default, None
if isinstance(value, bool):
return value, None
text = str(value).strip().lower()
if text in {"true", "1", "yes"}:
return True, None
if text in {"false", "0", "no"}:
return False, None
return default, f"{name} must be a boolean or 'true'/'false'"
def _require_orchestrator_tool(tool_name: str) -> Optional[str]:
"""Belt-and-suspenders runtime guard for orchestrator-only handlers.
The check_fn (`_check_kanban_orchestrator_mode`) keeps these tools
out of the worker schema entirely, but in case a stale registration
or test harness routes a worker to one of them anyway, return a
structured tool_error so the model gets a clear refusal instead of
silently mutating board state from a worker context.
"""
if os.environ.get("HERMES_KANBAN_TASK"):
return tool_error(
f"{tool_name} is orchestrator-only; dispatcher-spawned workers "
"must use kanban_complete, kanban_block, kanban_heartbeat, or "
"kanban_comment for their assigned task."
)
return None
def _task_summary_dict(kb, conn, task) -> dict[str, Any]:
"""Compact task shape for board-listing tools."""
parents = kb.parent_ids(conn, task.id)
children = kb.child_ids(conn, task.id)
return {
"id": task.id,
"title": task.title,
"assignee": task.assignee,
"status": task.status,
"priority": task.priority,
"tenant": task.tenant,
"workspace_kind": task.workspace_kind,
"workspace_path": task.workspace_path,
"created_by": task.created_by,
"created_at": task.created_at,
"started_at": task.started_at,
"completed_at": task.completed_at,
"current_run_id": task.current_run_id,
"parents": parents,
"children": children,
"parent_count": len(parents),
"child_count": len(children),
}
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
@@ -210,6 +297,66 @@ def _handle_show(args: dict, **kw) -> str:
return tool_error(f"kanban_show: {e}")
def _handle_list(args: dict, **kw) -> str:
"""List task summaries with the same core filters as the CLI."""
guard = _require_orchestrator_tool("kanban_list")
if guard:
return guard
assignee = args.get("assignee")
status = args.get("status")
tenant = args.get("tenant")
include_archived, bool_error = _parse_bool_arg(args, "include_archived")
if bool_error:
return tool_error(bool_error)
limit = args.get("limit")
if limit is None:
limit = KANBAN_LIST_DEFAULT_LIMIT
try:
limit = int(limit)
except (TypeError, ValueError):
return tool_error("limit must be an integer")
if limit < 1:
return tool_error("limit must be >= 1")
if limit > KANBAN_LIST_MAX_LIMIT:
return tool_error(f"limit must be <= {KANBAN_LIST_MAX_LIMIT}")
try:
kb, conn = _connect()
try:
# Match CLI list: dependencies that cleared since the last
# dispatcher tick should be visible to orchestrators immediately.
promoted = kb.recompute_ready(conn)
# Fetch one extra row so model-facing output can report that
# a bounded listing was truncated without dumping the board.
rows = kb.list_tasks(
conn,
assignee=assignee,
status=status,
tenant=tenant,
include_archived=include_archived,
limit=limit + 1,
)
truncated = len(rows) > limit
tasks = rows[:limit]
return json.dumps({
"tasks": [_task_summary_dict(kb, conn, t) for t in tasks],
"count": len(tasks),
"limit": limit,
"truncated": truncated,
"next_limit": (
min(limit * 2, KANBAN_LIST_MAX_LIMIT)
if truncated and limit < KANBAN_LIST_MAX_LIMIT else None
),
"promoted": promoted,
})
finally:
conn.close()
except ValueError as e:
return tool_error(f"kanban_list: {e}")
except Exception as e:
logger.exception("kanban_list failed")
return tool_error(f"kanban_list: {e}")
def _handle_complete(args: dict, **kw) -> str:
"""Mark the current task done with a structured handoff."""
tid = _default_task_id(args.get("task_id"))
@@ -259,12 +406,21 @@ def _handle_complete(args: dict, **kw) -> str:
# Structured rejection — surface the phantom ids so the
# worker can retry with a corrected list or drop the
# field. Audit event already landed in the DB.
#
# The task itself was NOT mutated (the gate runs before
# the write txn), so the worker can simply call
# kanban_complete again. Spell that out — without it the
# model often interprets a tool_error as a terminal
# failure and either blocks or crashes the run instead
# of retrying. See #22923.
return tool_error(
f"kanban_complete blocked: the following created_cards "
f"do not exist or were not created by this worker: "
f"{', '.join(hall_err.phantom)}. "
f"Either omit them, use only ids returned from successful "
f"kanban_create calls, or remove the created_cards field."
f"Your task is still in-flight (no state change). "
f"Retry kanban_complete with the same summary/metadata "
f"and either drop these ids from created_cards, or pass "
f"created_cards=[] to skip the card-claim check entirely."
)
if not ok:
return tool_error(
@@ -416,7 +572,9 @@ def _handle_create(args: dict, **kw) -> str:
priority = args.get("priority")
workspace_kind = args.get("workspace_kind") or "scratch"
workspace_path = args.get("workspace_path")
triage = bool(args.get("triage"))
triage, bool_error = _parse_bool_arg(args, "triage")
if bool_error:
return tool_error(bool_error)
idempotency_key = args.get("idempotency_key")
max_runtime_seconds = args.get("max_runtime_seconds")
skills = args.get("skills")
@@ -462,11 +620,38 @@ def _handle_create(args: dict, **kw) -> str:
)
finally:
conn.close()
except ValueError as e:
return tool_error(f"kanban_create: {e}")
except Exception as e:
logger.exception("kanban_create failed")
return tool_error(f"kanban_create: {e}")
def _handle_unblock(args: dict, **kw) -> str:
"""Transition a blocked task back to ready."""
guard = _require_orchestrator_tool("kanban_unblock")
if guard:
return guard
tid = args.get("task_id")
if not tid:
return tool_error("task_id is required")
ownership_err = _enforce_worker_task_ownership(str(tid))
if ownership_err:
return ownership_err
try:
kb, conn = _connect()
try:
ok = kb.unblock_task(conn, str(tid))
if not ok:
return tool_error(f"could not unblock {tid} (not blocked or unknown)")
return _ok(task_id=str(tid), status="ready")
finally:
conn.close()
except Exception as e:
logger.exception("kanban_unblock failed")
return tool_error(f"kanban_unblock: {e}")
def _handle_link(args: dict, **kw) -> str:
"""Add a parent→child dependency edge after the fact."""
parent_id = args.get("parent_id")
@@ -519,6 +704,50 @@ KANBAN_SHOW_SCHEMA = {
},
}
KANBAN_LIST_SCHEMA = {
"name": "kanban_list",
"description": (
"List Kanban task summaries so an orchestrator profile can discover "
"work to route. Supports the same core filters as the CLI: assignee, "
"status, tenant, include_archived, and limit. Returns compact rows "
"with ids, title, status, assignee, priority, parent/child ids, and "
"counts. Bounded to 50 rows by default, 200 max, with truncation "
"metadata. Also recomputes ready tasks before listing, matching the "
"CLI. Orchestrator-only — dispatcher-spawned task workers never see "
"this tool."
),
"parameters": {
"type": "object",
"properties": {
"assignee": {
"type": "string",
"description": "Optional assignee/profile filter.",
},
"status": {
"type": "string",
"enum": [
"triage", "todo", "ready", "running",
"blocked", "done", "archived",
],
"description": "Optional task status filter.",
},
"tenant": {
"type": "string",
"description": "Optional tenant/project namespace filter.",
},
"include_archived": {
"type": "boolean",
"description": "Include archived tasks. Defaults to false.",
},
"limit": {
"type": "integer",
"description": "Optional maximum rows to return (default 50, max 200).",
},
},
"required": [],
},
}
KANBAN_COMPLETE_SCHEMA = {
"name": "kanban_complete",
"description": (
@@ -787,6 +1016,25 @@ KANBAN_CREATE_SCHEMA = {
},
}
KANBAN_UNBLOCK_SCHEMA = {
"name": "kanban_unblock",
"description": (
"Move a blocked Kanban task back to ready. Orchestrator-only — only "
"profiles with the kanban toolset can unblock routed work; "
"dispatcher-spawned task workers never see this tool."
),
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "Blocked task id to return to ready.",
},
},
"required": ["task_id"],
},
}
KANBAN_LINK_SCHEMA = {
"name": "kanban_link",
"description": (
@@ -818,6 +1066,15 @@ registry.register(
emoji="📋",
)
registry.register(
name="kanban_list",
toolset="kanban",
schema=KANBAN_LIST_SCHEMA,
handler=_handle_list,
check_fn=_check_kanban_orchestrator_mode,
emoji="📋",
)
registry.register(
name="kanban_complete",
toolset="kanban",
@@ -863,6 +1120,15 @@ registry.register(
emoji="",
)
registry.register(
name="kanban_unblock",
toolset="kanban",
schema=KANBAN_UNBLOCK_SCHEMA,
handler=_handle_unblock,
check_fn=_check_kanban_orchestrator_mode,
emoji="",
)
registry.register(
name="kanban_link",
toolset="kanban",
+3 -3
View File
@@ -291,7 +291,7 @@ class MemoryStore:
if len(matches) > 1:
# If all matches are identical (exact duplicates), operate on the first one
unique_texts = set(e for _, e in matches)
unique_texts = {e for _, e in matches}
if len(unique_texts) > 1:
previews = [e[:80] + ("..." if len(e) > 80 else "") for _, e in matches]
return {
@@ -341,7 +341,7 @@ class MemoryStore:
if len(matches) > 1:
# If all matches are identical (exact duplicates), remove the first one
unique_texts = set(e for _, e in matches)
unique_texts = {e for _, e in matches}
if len(unique_texts) > 1:
previews = [e[:80] + ("..." if len(e) > 80 else "") for _, e in matches]
return {
@@ -477,7 +477,7 @@ def memory_tool(
if store is None:
return tool_error("Memory is not available. It may be disabled in config or this environment.", success=False)
if target not in ("memory", "user"):
if target not in {"memory", "user"}:
return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False)
if action == "add":
+2 -1
View File
@@ -54,6 +54,7 @@ from typing import Dict, Any, List, Optional
from tools.openrouter_client import get_async_client as _get_openrouter_client, check_api_key as check_openrouter_api_key
from agent.auxiliary_client import extract_content_or_reasoning
from tools.debug_helpers import DebugSession
import sys
logger = logging.getLogger(__name__)
@@ -451,7 +452,7 @@ if __name__ == "__main__":
print("❌ OPENROUTER_API_KEY environment variable not set")
print("Please set your API key: export OPENROUTER_API_KEY='your-key-here'")
print("Get API key at: https://openrouter.ai/")
exit(1)
sys.exit(1)
else:
print("✅ OpenRouter API key found")
+2 -2
View File
@@ -65,9 +65,9 @@ def check_package_for_malware(
def _infer_ecosystem(command: str) -> Optional[str]:
"""Infer package ecosystem from the command name."""
base = os.path.basename(command).lower()
if base in ("npx", "npx.cmd"):
if base in {"npx", "npx.cmd"}:
return "npm"
if base in ("uvx", "uvx.cmd", "pipx"):
if base in {"uvx", "uvx.cmd", "pipx"}:
return "PyPI"
return None
+2 -2
View File
@@ -263,7 +263,7 @@ def _validate_operations(
simulated = read_result.content
for hunk in op.hunks:
search_lines = [l.content for l in hunk.lines if l.prefix in (' ', '-')]
search_lines = [l.content for l in hunk.lines if l.prefix in {' ', '-'}]
if not search_lines:
# Addition-only hunk: validate context hint uniqueness
if hunk.context_hint:
@@ -282,7 +282,7 @@ def _validate_operations(
continue
search_pattern = '\n'.join(search_lines)
replace_lines = [l.content for l in hunk.lines if l.prefix in (' ', '+')]
replace_lines = [l.content for l in hunk.lines if l.prefix in {' ', '+'}]
replacement = '\n'.join(replace_lines)
new_simulated, count, _strategy, match_error = fuzzy_find_and_replace(
+2 -2
View File
@@ -1237,7 +1237,7 @@ class ProcessRegistry:
killed = 0
for session in targets:
result = self.kill_process(session.id)
if result.get("status") in ("killed", "already_exited"):
if result.get("status") in {"killed", "already_exited"}:
killed += 1
return killed
@@ -1446,7 +1446,7 @@ def _handle_process(args, **kw):
if action == "list":
return json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False)
elif action in ("poll", "log", "wait", "kill", "write", "submit", "close"):
elif action in {"poll", "log", "wait", "kill", "write", "submit", "close"}:
if not session_id:
return tool_error(f"session_id is required for {action}")
if action == "poll":
+1 -1
View File
@@ -919,7 +919,7 @@ async def rl_stop_training(run_id: str) -> str:
run_state = _active_runs[run_id]
if run_state.status not in ("running", "starting"):
if run_state.status not in {"running", "starting"}:
return json.dumps({
"message": f"Run '{run_id}' is not running (status: {run_state.status})",
}, indent=2)
+24 -11
View File
@@ -1034,7 +1034,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non
filename=os.path.basename(media_path),
)
async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
if resp.status not in (200, 201):
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
data = await resp.json()
@@ -1052,7 +1052,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non
},
**_req_kw,
) as resp:
if resp.status not in (200, 201):
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
data = await resp.json()
@@ -1076,7 +1076,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non
# Send text message (skip if empty and media is present)
if message.strip() or not media_files:
async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
if resp.status not in (200, 201):
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Discord API error ({resp.status}): {body}")
last_data = await resp.json()
@@ -1094,7 +1094,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non
with open(media_path, "rb") as f:
form.add_field("files[0]", f, filename=filename)
async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
if resp.status not in (200, 201):
if resp.status not in {200, 201}:
body = await resp.text()
warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
logger.error(warning)
@@ -1457,7 +1457,7 @@ async def _send_mattermost(token, extra, chat_id, message):
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.post(url, headers=headers, json={"channel_id": chat_id, "message": message}) as resp:
if resp.status not in (200, 201):
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Mattermost API error ({resp.status}): {body}")
data = await resp.json()
@@ -1501,7 +1501,7 @@ async def _send_matrix(token, extra, chat_id, message):
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.put(url, headers=headers, json=payload) as resp:
if resp.status not in (200, 201):
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Matrix API error ({resp.status}): {body}")
data = await resp.json()
@@ -1585,7 +1585,7 @@ async def _send_homeassistant(token, extra, chat_id, message):
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.post(url, headers=headers, json={"message": message, "target": chat_id}) as resp:
if resp.status not in (200, 201):
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Home Assistant API error ({resp.status}): {body}")
return {"success": True, "platform": "homeassistant", "chat_id": chat_id}
@@ -1757,7 +1757,20 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No
def _check_send_message():
"""Gate send_message on gateway running (always available on messaging platforms)."""
"""Gate send_message on gateway running (always available on messaging platforms).
Also passes for kanban workers the dispatcher sets ``HERMES_KANBAN_TASK``
on every spawned worker, but those workers run with the assignee profile's
``HERMES_HOME`` which has no ``gateway.pid``, so the gateway-running check
would fail even though the parent gateway is alive. Honoring the env var
lets workers call ``send_message`` to deliver rich content directly to the
originating chat (paired with ``kanban_complete`` for the short notifier
summary), which is the canonical pattern for any worker that needs to
reply with more than the ~200-char first-line truncation the kanban
notifier applies.
"""
if os.environ.get("HERMES_KANBAN_TASK"):
return True
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
if platform and platform != "local":
@@ -1814,7 +1827,7 @@ async def _send_qqbot(pconfig, chat_id, message):
# Try channel endpoint first (works for guild channels)
url = f"https://api.sgroup.qq.com/channels/{chat_id}/messages"
resp = await client.post(url, json=payload, headers=headers)
if resp.status_code in (200, 201):
if resp.status_code in {200, 201}:
data = resp.json()
return {"success": True, "platform": "qqbot", "chat_id": chat_id,
"message_id": data.get("id")}
@@ -1822,7 +1835,7 @@ async def _send_qqbot(pconfig, chat_id, message):
# If channel endpoint failed (likely "频道不存在"), try C2C endpoint
url_c2c = f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages"
resp_c2c = await client.post(url_c2c, json=payload, headers=headers)
if resp_c2c.status_code in (200, 201):
if resp_c2c.status_code in {200, 201}:
data = resp_c2c.json()
return {"success": True, "platform": "qqbot", "chat_id": chat_id,
"message_id": data.get("id")}
@@ -1830,7 +1843,7 @@ async def _send_qqbot(pconfig, chat_id, message):
# If C2C also failed, try group endpoint
url_group = f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages"
resp_group = await client.post(url_group, json=payload, headers=headers)
if resp_group.status_code in (200, 201):
if resp_group.status_code in {200, 201}:
data = resp_group.json()
return {"success": True, "platform": "qqbot", "chat_id": chat_id,
"message_id": data.get("id")}
+1 -1
View File
@@ -780,7 +780,7 @@ def skill_manage(
if action == "create":
if is_background_review():
mark_agent_created(name)
elif action in ("patch", "edit", "write_file", "remove_file"):
elif action in {"patch", "edit", "write_file", "remove_file"}:
bump_patch(name)
elif action == "delete":
forget(name)
+2 -2
View File
@@ -814,7 +814,7 @@ def _check_structure(skill_dir: Path) -> List[Finding]:
))
# Executable permission on non-script files
if ext not in ('.sh', '.bash', '.py', '.rb', '.pl') and f.stat().st_mode & 0o111:
if ext not in {'.sh', '.bash', '.py', '.rb', '.pl'} and f.stat().st_mode & 0o111:
findings.append(Finding(
pattern_id="unexpected_executable",
severity="medium",
@@ -928,5 +928,5 @@ def _build_summary(name: str, source: str, trust: str, verdict: str, findings: L
if not findings:
return f"{name}: clean scan, no threats detected"
categories = set(f.category for f in findings)
categories = {f.category for f in findings}
return f"{name}: {verdict}{len(findings)} finding(s) in {', '.join(sorted(categories))}"
+2 -2
View File
@@ -101,7 +101,7 @@ def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bo
normalized = raw.replace("\\", "/")
path = PurePosixPath(normalized)
parts = [part for part in path.parts if part not in ("", ".")]
parts = [part for part in path.parts if part not in {"", "."}]
if normalized.startswith("/") or path.is_absolute():
raise ValueError(f"Unsafe {field_name}: {path_value}")
@@ -1415,7 +1415,7 @@ class SkillsShSource(SkillSource):
dir_name = entry["name"]
if dir_name.startswith((".", "_")):
continue
if dir_name in ("skills", ".agents", ".claude"):
if dir_name in {"skills", ".agents", ".claude"}:
continue # already tried
# Try direct: repo/dir/skill_token
direct_id = f"{repo}/{dir_name}/{skill_token}"
+1 -1
View File
@@ -345,7 +345,7 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
manifest = _read_manifest()
bundled_dir = _get_bundled_dir()
bundled_skills = _discover_bundled_skills(bundled_dir)
bundled_by_name = {skill_name: skill_dir for skill_name, skill_dir in bundled_skills}
bundled_by_name = dict(bundled_skills)
in_manifest = name in manifest
is_bundled = name in bundled_by_name
+3 -3
View File
@@ -721,7 +721,7 @@ def skills_list(category: str = None, task_id: str = None) -> str:
# Extract unique categories
categories = sorted(
set(s.get("category") for s in all_skills if s.get("category"))
{s.get("category") for s in all_skills if s.get("category")}
)
return json.dumps(
@@ -1133,7 +1133,7 @@ def skill_view(
available_files["assets"].append(rel)
elif rel.startswith("scripts/"):
available_files["scripts"].append(rel)
elif f.suffix in [
elif f.suffix in {
".md",
".py",
".yaml",
@@ -1141,7 +1141,7 @@ def skill_view(
".json",
".tex",
".sh",
]:
}:
available_files["other"].append(rel)
# Remove empty categories
+17 -12
View File
@@ -139,7 +139,7 @@ def _check_vercel_sandbox_requirements(config: dict[str, Any]) -> bool:
return False
disk = config.get("container_disk", 51200)
if disk not in (0, 51200):
if disk not in {0, 51200}:
logger.error(
"Vercel Sandbox does not support custom TERMINAL_CONTAINER_DISK=%s. "
"Use the default shared setting (51200 MB).",
@@ -416,7 +416,7 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str:
chars = []
while True:
c = msvcrt.getwch()
if c in ("\r", "\n"):
if c in {"\r", "\n"}:
break
if c == "\x03":
raise KeyboardInterrupt
@@ -432,7 +432,7 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str:
chars = []
while True:
b = os.read(tty_fd, 1)
if not b or b in (b"\n", b"\r"):
if not b or b in {b"\n", b"\r"}:
break
chars.append(b)
result["password"] = b"".join(chars).decode("utf-8", errors="replace")
@@ -707,7 +707,7 @@ def _rewrite_compound_background(command: str) -> str:
continue
# Quoted tokens — consume whole string via the shared tokenizer.
if ch in ("'", '"'):
if ch in {"'", '"'}:
_, next_i = _read_shell_token(command, i)
i = max(next_i, i + 1)
continue
@@ -888,6 +888,7 @@ from tools.environments.docker import DockerEnvironment as _DockerEnvironment
from tools.environments.modal import ModalEnvironment as _ModalEnvironment
from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
import sys
# Tool description for LLM
@@ -1009,7 +1010,7 @@ def _get_env_config() -> Dict[str, Any]:
default_image = "nikolaik/python-nodejs:python3.11-nodejs20"
env_type = os.getenv("TERMINAL_ENV", "local")
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in ("true", "1", "yes")
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"}
# Default cwd: local uses the host's current directory, ssh uses the
# remote home, Vercel uses its documented workspace root, and everything
@@ -1041,7 +1042,7 @@ def _get_env_config() -> Dict[str, Any]:
):
host_cwd = candidate
cwd = "/workspace"
elif env_type in ("modal", "docker", "singularity", "daytona", "vercel_sandbox") and cwd:
elif env_type in {"modal", "docker", "singularity", "daytona", "vercel_sandbox"} and cwd:
# Host paths and relative paths that won't work inside containers
is_host_path = any(cwd.startswith(p) for p in host_prefixes)
is_relative = not os.path.isabs(cwd) # e.g. "." or "src/"
@@ -1076,17 +1077,18 @@ def _get_env_config() -> Dict[str, Any]:
"ssh_persistent": os.getenv(
"TERMINAL_SSH_PERSISTENT",
os.getenv("TERMINAL_PERSISTENT_SHELL", "true"),
).lower() in ("true", "1", "yes"),
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"),
).lower() in {"true", "1", "yes"},
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"},
# Container resource config (applies to docker, singularity, modal,
# daytona, and vercel_sandbox -- ignored for local/ssh)
"container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),
"container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB)
"container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB)
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("true", "1", "yes"),
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"},
"docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"),
"docker_env": _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON"),
"docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in ("true", "1", "yes"),
"docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"},
"docker_extra_args": _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON"),
}
@@ -1129,6 +1131,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
volumes = cc.get("docker_volumes", [])
docker_forward_env = cc.get("docker_forward_env", [])
docker_env = cc.get("docker_env", {})
docker_extra_args = cc.get("docker_extra_args", [])
if env_type == "local":
return _LocalEnvironment(cwd=cwd, timeout=timeout)
@@ -1144,6 +1147,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
forward_env=docker_forward_env,
env=docker_env,
run_as_host_user=cc.get("docker_run_as_host_user", False),
extra_args=docker_extra_args,
)
elif env_type == "singularity":
@@ -1779,7 +1783,7 @@ def terminal_tool(
}
container_config = None
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
@@ -1792,6 +1796,7 @@ def terminal_tool(
"docker_forward_env": config.get("docker_forward_env", []),
"docker_env": config.get("docker_env", {}),
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
"docker_extra_args": config.get("docker_extra_args", []),
}
local_config = None
@@ -2239,7 +2244,7 @@ if __name__ == "__main__":
if not check_terminal_requirements():
print("\n❌ Requirements not met. Please check the messages above.")
exit(1)
sys.exit(1)
print("\n✅ All requirements met!")
print("\nAvailable Tool:")
+4 -4
View File
@@ -52,7 +52,7 @@ def _env_bool(key: str, default: bool) -> bool:
val = os.getenv(key)
if val is None:
return default
return val.lower() in ("1", "true", "yes")
return val.lower() in {"1", "true", "yes"}
def _env_int(key: str, default: int) -> int:
@@ -189,14 +189,14 @@ def _detect_target() -> str | None:
# Android (Termux) is ABI-compatible with Linux — reuse Linux binaries.
if system == "Darwin":
plat = "apple-darwin"
elif system in ("Linux", "Android"):
elif system in {"Linux", "Android"}:
plat = "unknown-linux-gnu"
else:
return None
if machine in ("x86_64", "amd64"):
if machine in {"x86_64", "amd64"}:
arch = "x86_64"
elif machine in ("aarch64", "arm64"):
elif machine in {"aarch64", "arm64"}:
arch = "aarch64"
else:
return None
+1 -1
View File
@@ -109,7 +109,7 @@ class TodoStore:
# cause the model to re-do finished work after compression.
active_items = [
item for item in self._items
if item["status"] in ("pending", "in_progress")
if item["status"] in {"pending", "in_progress"}
]
if not active_items:
return None
+16 -17
View File
@@ -466,13 +466,12 @@ def _shell_quote_context(command_template: str, position: int) -> Optional[str]:
escaped = True
elif char == '"':
quote = None
else:
if char == "'":
quote = "'"
elif char == '"':
quote = '"'
elif char == "\\":
i += 1
elif char == "'":
quote = "'"
elif char == '"':
quote = '"'
elif char == "\\":
i += 1
i += 1
return quote
@@ -849,13 +848,13 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
OpenAIClient = _import_openai_client()
client = OpenAIClient(api_key=api_key, base_url=base_url)
try:
create_kwargs = dict(
model=model,
voice=voice,
input=text,
response_format=response_format,
extra_headers={"x-idempotency-key": str(uuid.uuid4())},
)
create_kwargs = {
"model": model,
"voice": voice,
"input": text,
"response_format": response_format,
"extra_headers": {"x-idempotency-key": str(uuid.uuid4())},
}
if speed != 1.0:
create_kwargs["speed"] = max(0.25, min(4.0, speed))
response = client.audio.speech.create(**create_kwargs)
@@ -1613,7 +1612,7 @@ def text_to_speech_tool(
file_path = out_dir / f"tts_{timestamp}.{fmt}"
# Use .ogg for Telegram with providers that support native Opus output,
# otherwise fall back to .mp3 (Edge TTS will attempt ffmpeg conversion later).
elif want_opus and provider in ("openai", "elevenlabs", "mistral", "gemini"):
elif want_opus and provider in {"openai", "elevenlabs", "mistral", "gemini"}:
file_path = out_dir / f"tts_{timestamp}.ogg"
else:
file_path = out_dir / f"tts_{timestamp}.mp3"
@@ -1763,12 +1762,12 @@ def text_to_speech_tool(
if opus_path:
file_str = opus_path
voice_compatible = file_str.endswith(".ogg")
elif provider in ("edge", "neutts", "minimax", "xai", "kittentts", "piper") and not file_str.endswith(".ogg"):
elif provider in {"edge", "neutts", "minimax", "xai", "kittentts", "piper"} and not file_str.endswith(".ogg"):
opus_path = _convert_to_opus(file_str)
if opus_path:
file_str = opus_path
voice_compatible = True
elif provider in ("elevenlabs", "openai", "mistral", "gemini"):
elif provider in {"elevenlabs", "openai", "mistral", "gemini"}:
voice_compatible = file_str.endswith(".ogg")
file_size = os.path.getsize(file_str)
+2 -2
View File
@@ -96,10 +96,10 @@ def _global_allow_private_urls() -> bool:
# 1. Env var override (highest priority)
env_val = os.getenv("HERMES_ALLOW_PRIVATE_URLS", "").strip().lower()
if env_val in ("true", "1", "yes"):
if env_val in {"true", "1", "yes"}:
_cached_allow_private = True
return _cached_allow_private
if env_val in ("false", "0", "no"):
if env_val in {"false", "0", "no"}:
# Explicit false — don't fall through to config
return _cached_allow_private
+3 -2
View File
@@ -41,6 +41,7 @@ from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning
from hermes_constants import get_hermes_dir
from tools.debug_helpers import DebugSession
from tools.website_policy import check_website_access
import sys
logger = logging.getLogger(__name__)
@@ -346,7 +347,7 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None,
data_url = _image_to_base64_data_url(image_path, mime_type=mime_type)
return data_url # fall through to size-check in caller
# Convert RGBA to RGB for JPEG output
if pil_format == "JPEG" and img.mode in ("RGBA", "P"):
if pil_format == "JPEG" and img.mode in {"RGBA", "P"}:
img = img.convert("RGB")
# Strategy: halve dimensions until base64 fits, up to 4 rounds.
@@ -937,7 +938,7 @@ if __name__ == "__main__":
if not api_available:
print("❌ No auxiliary vision model available")
print("Configure a supported multimodal backend (OpenRouter, Nous, Codex, Anthropic, or a custom OpenAI-compatible endpoint).")
exit(1)
sys.exit(1)
else:
print("✅ Vision model available")
+1 -2
View File
@@ -456,8 +456,7 @@ class AudioRecorder:
# Compute RMS for level display and silence detection
rms = int(np.sqrt(np.mean(indata.astype(np.float64) ** 2)))
self._current_rms = rms
if rms > self._peak_rms:
self._peak_rms = rms
self._peak_rms = max(self._peak_rms, rms)
# Silence detection
if self._on_silence_stop is not None:
+14 -14
View File
@@ -100,6 +100,7 @@ from tools.managed_tool_gateway import (
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
from tools.url_safety import is_safe_url
from tools.website_policy import check_website_access
import sys
logger = logging.getLogger(__name__)
@@ -126,7 +127,7 @@ def _get_backend() -> str:
keys manually without running setup.
"""
configured = (_load_web_config().get("backend") or "").lower().strip()
if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"):
if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"}:
return configured
# Fallback for manual / legacy config — pick the highest-priority
@@ -1074,7 +1075,7 @@ def _parallel_search(query: str, limit: int = 5) -> dict:
return {"error": "Interrupted", "success": False}
mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip()
if mode not in ("fast", "one-shot", "agentic"):
if mode not in {"fast", "one-shot", "agentic"}:
mode = "agentic"
logger.info("Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit)
@@ -1397,7 +1398,7 @@ async def web_extract_tool(
"include_images": False,
})
results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "")
elif backend in ("searxng", "brave-free", "ddgs"):
elif backend in {"searxng", "brave-free", "ddgs"}:
# These backends are search-only — they cannot extract URL content
_label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend]
return json.dumps({
@@ -1781,7 +1782,7 @@ async def web_crawl_tool(
return cleaned_result
# SearXNG / Brave Search (free tier) / DuckDuckGo (ddgs) are search-only — they cannot crawl
if backend in ("searxng", "brave-free", "ddgs"):
if backend in {"searxng", "brave-free", "ddgs"}:
_label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend]
return json.dumps({
"error": f"{_label} is a search-only backend and cannot crawl URLs. "
@@ -2084,7 +2085,7 @@ def check_firecrawl_api_key() -> bool:
def check_web_api_key() -> bool:
"""Check whether the configured web backend is available."""
configured = _load_web_config().get("backend", "").lower().strip()
if configured in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"):
if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"}:
return _is_backend_available(configured)
return any(
_is_backend_available(backend)
@@ -2130,15 +2131,14 @@ if __name__ == "__main__":
print(" Using Brave Search free tier (search only)")
elif backend == "ddgs":
print(" Using DuckDuckGo via ddgs package (search only)")
elif firecrawl_url_available:
print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}")
elif firecrawl_key_available:
print(" Using direct Firecrawl cloud API")
elif tool_gateway_available:
print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}")
else:
if firecrawl_url_available:
print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}")
elif firecrawl_key_available:
print(" Using direct Firecrawl cloud API")
elif tool_gateway_available:
print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}")
else:
print(" Firecrawl backend selected but not configured")
print(" Firecrawl backend selected but not configured")
else:
print("❌ No web search backend configured")
print(
@@ -2154,7 +2154,7 @@ if __name__ == "__main__":
print(f"✅ Auxiliary model available: {default_summarizer_model}")
if not web_available:
exit(1)
sys.exit(1)
print("🛠️ Web tools ready for use!")
+1 -1
View File
@@ -122,7 +122,7 @@ async def query_group_members(
hint = {"mention_hint": MENTION_HINT} if mention else {}
if action == "list_bots":
bots = [m for m in all_members if m["role"] in ("yuanbao_ai", "bot")]
bots = [m for m in all_members if m["role"] in {"yuanbao_ai", "bot"}]
if not bots:
return {"success": False, "error": "No bots found in this group."}
return {