mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
fix(gateway): accept -s/--checkpoints/--pass-session-id/--accept-hooks as session creation policy
These four launch options had no working path on the canonical gateway. Classic `hermes chat` refused them and pointed users at `hermes --tui ...`. The Ink TUI sent them in session.create, but CREATE_FIELDS rejected them (4001 invalid_params at the canonical contract), and canonicalRequest threw before the session existed. localCreationOptions read the documented, exported HERMES_ACCEPT_HOOKS=1, so with that variable set no TUI session could be created at all. HERMES_TUI_PASS_SESSION_ID was read by nothing and was silently dropped. On main the in-process TUI gateway and cli.py applied all four, and tests/hermes_cli/test_cli_preloaded_skills.py covered the -s preload. That test was deleted on the branch with no replacement. All four options are now creation fields of the session that names them, applied by the owner: - skills: rendered once with build_preloaded_skills_prompt (off the owner loop, only for a new session; a retried request_id resumes the frozen route) and stored in the frozen LocalSessionPolicy.skills_prompt. The in-process turn appends it to the ephemeral prompt and the managed worker passes it as ephemeral_system_prompt, matching the CLI/TUI startup preload. A typo among good names is logged and skipped, as on main. When no name resolves, creation is refused with unknown_skill. A bypass (--safe-mode / --ignore-user-config) launch refuses -s because it must not read the profile. - checkpoints: folded into the frozen config's checkpoints.enabled, which the in-process agent already reads through _checkpoint_agent_kwargs(policy config). The managed worker now passes it too. - pass_session_id: frozen in the request and passed to AIAgent (in-process and managed), so "Session ID: <id>" rides the system prompt. - accept_hooks: consents to the profile's configured shell hooks (per-user allowlist, off-loop) and registers them on the owner, like the in-process --accept-hooks. It is a no-op on a bypass launch, so an exported HERMES_ACCEPT_HOOKS=1 cannot brick --safe-mode. Classic `hermes chat` is a gateway client too: it forwards the same fields, plus HERMES_ACCEPT_HOOKS=1 when that variable is exported, instead of refusing them. The Ink TUI maps HERMES_TUI_SKILLS to a list and the flags to booleans, and the pipe bootstrap stops leaking HERMES_TUI_CHECKPOINTS/HERMES_TUI_PASS_SESSION_ID into a spawned daemon. The canonical contract (CanonicalCreateParams plus the generated TS/OpenRPC) advertises the new fields. Live proof: a real `python -m gateway.run` daemon on a fake home, a real WS session.create per field, the real Ink mapping via tsx with HERMES_ACCEPT_HOOKS=1 exported, and a real turn against a loopback model. The skill body and "Session ID: <id>" reach the model's system prompt, a write snapshots into the checkpoint store (no snapshot without the flag), and the hook command lands in the allowlist. A real `hermes chat -Q -q ... -s X --checkpoints --pass-session-id --accept-hooks` exits 0 with the skill in its prompt. On the base, all of these were refused with invalid_params, Ink threw, and classic chat exited 2. Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5456384314
This commit is contained in:
1 parent
562aeb62f3
commit
2ee1cd1ebb
19 files changed
+266
-31
No files matched your search
@@ -310,6 +310,8 @@ def execute(frame, channel):
|
||||
gateway_session_key=frame['route'], user_id=frame['user_id'], chat_id=frame['chat_id'],
|
||||
skip_context_files=policy.ignore_rules, load_soul_identity=not policy.ignore_rules,
|
||||
skip_memory=policy.ignore_rules, skip_background_review=True, quiet_mode=True,
|
||||
ephemeral_system_prompt=policy.skills_prompt, pass_session_id=policy.pass_session_id,
|
||||
checkpoints_enabled=policy.checkpoints_enabled,
|
||||
stream_delta_callback=lambda text: channel.send('delta', text=text) if text else None,
|
||||
clarify_callback=controls.clarify,
|
||||
tool_start_callback=lambda call_id, name, args: channel.send('tool.start', **tool_frame(call_id, name, args)),
|
||||
|
||||
@@ -4697,6 +4697,10 @@ export interface CanonicalCreateParams {
|
||||
yolo?: boolean
|
||||
safe_mode?: boolean
|
||||
ignore_user_config?: boolean
|
||||
skills?: string[] | null
|
||||
checkpoints?: boolean
|
||||
accept_hooks?: boolean
|
||||
pass_session_id?: boolean
|
||||
editor?: Record<string, unknown> | null
|
||||
title?: string | null
|
||||
hidden?: boolean
|
||||
|
||||
@@ -8646,6 +8646,36 @@
|
||||
"title": "Ignore User Config",
|
||||
"type": "boolean"
|
||||
},
|
||||
"skills": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Skills"
|
||||
},
|
||||
"checkpoints": {
|
||||
"default": false,
|
||||
"title": "Checkpoints",
|
||||
"type": "boolean"
|
||||
},
|
||||
"accept_hooks": {
|
||||
"default": false,
|
||||
"title": "Accept Hooks",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pass_session_id": {
|
||||
"default": false,
|
||||
"title": "Pass Session Id",
|
||||
"type": "boolean"
|
||||
},
|
||||
"editor": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -1089,6 +1089,8 @@ class TurnRunner(GatewayTurnProgressMixin, GatewaySessionAgentMixin):
|
||||
apply_launch_yolo(ctx.session_key)
|
||||
platform_key = policy.platform if policy else ("cli" if ctx.source.platform == Platform.LOCAL else ctx.source.platform.value)
|
||||
combined_ephemeral = self._combined_ephemeral_prompt()
|
||||
if policy and policy.skills_prompt:
|
||||
combined_ephemeral = "\n\n".join(p for p in (combined_ephemeral, policy.skills_prompt) if p)
|
||||
max_iterations = policy.max_turns if policy else _current_max_iterations()
|
||||
from gateway.hosted_room_execution_policy import current_room_execution_policy
|
||||
room = current_room_execution_policy()
|
||||
|
||||
@@ -248,7 +248,24 @@ class AuthorityConnection:
|
||||
raise RuntimeStoreError('invalid_params')
|
||||
ref = title and resolve_titled_session(self.authority, self.actor, title, missing_ok=True)
|
||||
if not ref:
|
||||
ref = create_local_session(self.authority, self.actor, params)
|
||||
skills_prompt = None
|
||||
if 'skills' in params:
|
||||
# Skill files are read off the owner loop, once per NEW session (a retried
|
||||
# request_id resumes the frozen policy and never re-renders).
|
||||
import asyncio
|
||||
from gateway.session_local_recovery import local_identity
|
||||
from gateway.session_policy import render_launch_skills
|
||||
params.setdefault('request_id', uuid.uuid4().hex)
|
||||
sid = local_identity(self.authority.profile_id, self.actor.subject, params['request_id'])
|
||||
if self.authority.db.get_session(sid) is None:
|
||||
skills_prompt = await asyncio.to_thread(render_launch_skills, params, sid)
|
||||
ref = create_local_session(self.authority, self.actor, params, skills_prompt=skills_prompt)
|
||||
if params.get('accept_hooks') is True:
|
||||
import asyncio
|
||||
from gateway.session_policy import accept_launch_hooks, restore_policy
|
||||
from hermes_state_local import local_receipt
|
||||
policy = restore_policy(local_receipt(self.authority.db, ref.session_id)['policy'])
|
||||
await asyncio.to_thread(accept_launch_hooks, policy)
|
||||
if title:
|
||||
title_new_session(self.authority, ref, title)
|
||||
if hidden:
|
||||
|
||||
@@ -178,6 +178,7 @@ class GatewaySessionAgentMixin:
|
||||
# Keep the persona even with minimal context: soul identity is one small file.
|
||||
load_soul_identity=not bool(policy and policy.ignore_rules),
|
||||
skip_memory=bool(policy and policy.ignore_rules),
|
||||
pass_session_id=bool(policy and policy.pass_session_id),
|
||||
)
|
||||
|
||||
def _resolve_turn_agent(self, turn_route, platform_key, combined_ephemeral, max_iterations, reasoning_config, pr):
|
||||
|
||||
@@ -111,7 +111,7 @@ def _bypass_policy(params, *, private_secrets):
|
||||
return build_policy(params, defaults, private_secrets=private_secrets, profile_terminal=False)
|
||||
|
||||
|
||||
def create_local_session(authority, actor, params, *, trusted_policy=None, trusted_secrets=None):
|
||||
def create_local_session(authority, actor, params, *, trusted_policy=None, trusted_secrets=None, skills_prompt=None):
|
||||
if actor.profile_id != authority.profile_id:
|
||||
raise RuntimeStoreError('profile_mismatch')
|
||||
if 'session:create' not in actor.capabilities:
|
||||
@@ -154,6 +154,12 @@ def create_local_session(authority, actor, params, *, trusted_policy=None, trust
|
||||
if key is None or not hmac.compare_digest(key, params.get('api_key') or ''):
|
||||
raise RuntimeStoreError('admission_conflict')
|
||||
return restore_local_session(authority, sid)
|
||||
from gateway.session_policy import launch_skills
|
||||
if launch_skills(params) and skills_prompt is None:
|
||||
# `-s` is rendered off-loop by the RPC handler (render_launch_skills); a caller that
|
||||
# skipped it must not mint a session silently missing its preload.
|
||||
raise RuntimeStoreError('invalid_params')
|
||||
policy = replace(policy, skills_prompt=skills_prompt)
|
||||
policy = bind_launch_key(authority, sid, policy, params.get("api_key"), config_secrets=private_secrets)
|
||||
from gateway.session_local_recovery import local_source
|
||||
source = local_source(authority, sid, actor.subject)
|
||||
|
||||
@@ -9,7 +9,8 @@ from hermes_state_runtime import RuntimeStoreError
|
||||
|
||||
CREATE_FIELDS = frozenset({'request_id', 'source', 'cwd', 'model', 'toolsets',
|
||||
'provider', 'base_url', 'reasoning', 'max_turns', 'ignore_rules', 'api_key', 'editor',
|
||||
'yolo', 'safe_mode', 'ignore_user_config'})
|
||||
'yolo', 'safe_mode', 'ignore_user_config',
|
||||
'skills', 'checkpoints', 'accept_hooks', 'pass_session_id'})
|
||||
BYPASS_FIELDS = ('safe_mode', 'ignore_user_config')
|
||||
_ACTIVE_POLICY: ContextVar = ContextVar('local_session_policy', default=None)
|
||||
SURFACES = {'cli': 'cli', 'tui': 'tui', 'gui': 'desktop', 'acp': 'acp'}
|
||||
@@ -33,6 +34,9 @@ class LocalSessionPolicy:
|
||||
# route; safe_mode always implies ignore_user_config (normalized once in build_policy).
|
||||
safe_mode: bool = False
|
||||
ignore_user_config: bool = False
|
||||
# `-s/--skills` blocks rendered once at creation (render_launch_skills): the session's
|
||||
# ephemeral prompt never re-reads the skill files, like the CLI's startup preload.
|
||||
skills_prompt: str | None = None
|
||||
|
||||
def config(self, authority=None):
|
||||
config = present_sections(json.loads(self.config_json))
|
||||
@@ -66,6 +70,17 @@ class LocalSessionPolicy:
|
||||
scope `/yolo` gives a messaging chat; never the process-wide HERMES_YOLO_MODE."""
|
||||
return json.loads(self.request_json).get('yolo', False)
|
||||
|
||||
@property
|
||||
def pass_session_id(self):
|
||||
"""`--pass-session-id`: the session id rides the agent's system prompt."""
|
||||
return json.loads(self.request_json).get('pass_session_id', False)
|
||||
|
||||
@property
|
||||
def checkpoints_enabled(self):
|
||||
"""Frozen `checkpoints.enabled` (`--checkpoints` folds into it); legacy bool form accepted."""
|
||||
section = self.config().get('checkpoints', {})
|
||||
return bool(section.get('enabled', False) if isinstance(section, dict) else section)
|
||||
|
||||
@property
|
||||
def max_turns(self):
|
||||
from hermes_cli.config import resolve_turn_limit
|
||||
@@ -109,6 +124,7 @@ def build_policy(params, config, *, private_secrets=None, profile_terminal=True)
|
||||
raise RuntimeStoreError('invalid_params')
|
||||
from gateway.session_local_editor import validate_editor
|
||||
validate_editor(source, params.get('editor'))
|
||||
launch_skills(params)
|
||||
config = present_sections(json.loads(json.dumps(config)))
|
||||
_apply_launch_overrides(params, config)
|
||||
enabled = _resolve_toolsets(params, config, source, safe_mode)
|
||||
@@ -141,9 +157,13 @@ def _apply_launch_overrides(params, config):
|
||||
or url.username or url.password or url.query or url.fragment):
|
||||
raise RuntimeStoreError('invalid_params')
|
||||
config.setdefault('model', {})[key] = value
|
||||
for flag in ('ignore_rules', 'yolo'):
|
||||
for flag in ('ignore_rules', 'yolo', 'checkpoints', 'accept_hooks', 'pass_session_id'):
|
||||
if flag in params and type(params[flag]) is not bool:
|
||||
raise RuntimeStoreError('invalid_params')
|
||||
if params.get('checkpoints'):
|
||||
section = config.get('checkpoints')
|
||||
section = dict(section) if isinstance(section, dict) else {'enabled': bool(section)} if section else {}
|
||||
config['checkpoints'] = {**section, 'enabled': True}
|
||||
if 'max_turns' in params:
|
||||
# The spellings `--max-turns` always took: a positive cap, or 0 / -1 / "none" / "unlimited"
|
||||
# for no cap (resolve_turn_limit). Frozen normalized; anything unreadable is refused.
|
||||
@@ -215,6 +235,46 @@ def _extract_config_secrets(value, private, path=()):
|
||||
_extract_config_secrets(child, private, child_path)
|
||||
|
||||
|
||||
def launch_skills(params):
|
||||
"""The validated `-s/--skills` identifiers of a create request (empty when absent). Skills
|
||||
live in the profile, which a bypass launch must never read: refused there, not dropped."""
|
||||
skills = params.get('skills', [])
|
||||
if (('skills' in params and (not isinstance(skills, list) or not skills
|
||||
or any(not isinstance(s, str) or not s.strip() for s in skills)))
|
||||
or (skills and any(params.get(name) is True for name in BYPASS_FIELDS))):
|
||||
raise RuntimeStoreError('invalid_params')
|
||||
return [s.strip() for s in skills]
|
||||
|
||||
|
||||
def render_launch_skills(params, session_id):
|
||||
"""`-s/--skills` preload blocks for a new session, rendered ONCE at creation and frozen in its
|
||||
policy (the CLI's startup preload). Blocking skill-tree reads: callers run it off the owner
|
||||
loop. A typo among good names is logged and skipped, like the CLI; none resolving refuses."""
|
||||
skills = launch_skills(params)
|
||||
if not skills:
|
||||
return None
|
||||
from agent.skill_commands import build_preloaded_skills_prompt, format_missing_skills
|
||||
prompt, loaded, missing = build_preloaded_skills_prompt(skills, task_id=session_id)
|
||||
if not loaded:
|
||||
raise RuntimeStoreError('unknown_skill')
|
||||
if missing:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning('Skipping %s for session %s; continuing with: %s',
|
||||
format_missing_skills(missing), session_id, ', '.join(loaded))
|
||||
return prompt or None
|
||||
|
||||
|
||||
def accept_launch_hooks(policy):
|
||||
"""`--accept-hooks` / `HERMES_ACCEPT_HOOKS=1` on a launch: consent to the profile's configured
|
||||
shell hooks (recorded in the per-user allowlist, as the in-process CLI/TUI did) and register
|
||||
them on this owner. A bypass launch has no profile hooks to consent to (main's safe mode
|
||||
registered none either), so the flag is a no-op there rather than a refusal. Allowlist file
|
||||
lock + write: callers run it off the owner loop."""
|
||||
if json.loads(policy.request_json).get('accept_hooks') and not policy.ignore_user_config:
|
||||
from agent.shell_hooks import register_from_config
|
||||
register_from_config(policy.config(), accept_hooks=True)
|
||||
|
||||
|
||||
def bind_launch_key(authority, session_id, policy, api_key, *, config_secrets=None):
|
||||
"""CLI keys live only in this authority lifetime, never its durable receipt.
|
||||
|
||||
@@ -277,6 +337,7 @@ def restore_policy(data):
|
||||
or not isinstance(policy.toolsets, (list, tuple))
|
||||
or any(not isinstance(name, str) for name in policy.toolsets)
|
||||
or type(policy.safe_mode) is not bool or type(policy.ignore_user_config) is not bool
|
||||
or not isinstance(policy.skills_prompt, (str, type(None)))
|
||||
or (policy.safe_mode and not policy.ignore_user_config)):
|
||||
raise ValueError('invalid policy')
|
||||
from dataclasses import replace
|
||||
|
||||
+15
-10
@@ -14,23 +14,18 @@ from hermes_cli.gateway_client import GatewayClientError, connect_gateway
|
||||
# These options change execution or require frontend facilities not yet exposed by
|
||||
# the authority. Reject them, rather than mutate process-wide gateway settings.
|
||||
_UNSUPPORTED = (
|
||||
"image", "skills", "worktree", "w", "checkpoints", "pass_session_id",
|
||||
"accept_hooks",
|
||||
"image", "worktree", "w",
|
||||
"no_restore_cwd",
|
||||
"run_budget", "verbose", "compact",
|
||||
"list_tools", "list_toolsets",
|
||||
)
|
||||
_POLICY = ("model", "provider", "reasoning", "toolsets", "max_turns", "base_url", "ignore_rules", "api_key",
|
||||
"yolo", "safe_mode", "ignore_user_config")
|
||||
"yolo", "safe_mode", "ignore_user_config", "skills", "checkpoints", "accept_hooks", "pass_session_id")
|
||||
# Where each refused option lives now; the refusal names it so the user is not left guessing.
|
||||
_RELOCATED = {
|
||||
"image": "attach the image in `hermes --tui` or the Desktop app",
|
||||
"skills": "`hermes --tui -s <skill>`",
|
||||
"worktree": "`hermes --tui -w`",
|
||||
"w": "`hermes --tui -w`",
|
||||
"checkpoints": "`checkpoints.enabled: true` in config.yaml, or `hermes --tui --checkpoints`",
|
||||
"pass_session_id": "`hermes --tui --pass-session-id`",
|
||||
"accept_hooks": "`hooks_auto_accept: true` in config.yaml, or `hermes --tui --accept-hooks`",
|
||||
"no_restore_cwd": "`--in <dir>` (the gateway keeps the session's frozen cwd)",
|
||||
"run_budget": "`agent.run_budget_seconds` in config.yaml",
|
||||
"verbose": "`hermes logs --follow`, or `hermes --tui -v`",
|
||||
@@ -110,11 +105,14 @@ async def run_gateway_chat(args, emitter=None):
|
||||
if source not in contract.get("sources", []):
|
||||
raise GatewayClientError(f"Gateway does not support source {source!r}")
|
||||
parameters = contract.get("parameters", [])
|
||||
policy = _launch_flags(args)
|
||||
policy = _requested_policy(args)
|
||||
policy.pop("source", None)
|
||||
if create_if_missing:
|
||||
policy["title"] = title
|
||||
if isinstance(policy.get("toolsets"), str):
|
||||
policy["toolsets"] = [name.strip() for name in policy["toolsets"].split(",") if name.strip()]
|
||||
# The documented `HERMES_ACCEPT_HOOKS=1` opt-in, as the in-process CLI read it; a
|
||||
# creation flag of THIS session only (a resume keeps the frozen route's consent).
|
||||
if os.environ.get("HERMES_ACCEPT_HOOKS", "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
policy["accept_hooks"] = True
|
||||
cwd = await asyncio.to_thread(_caller_cwd, args)
|
||||
if "cwd" in parameters:
|
||||
policy["cwd"] = cwd
|
||||
@@ -159,6 +157,13 @@ def _requested_policy(args):
|
||||
policy = _launch_flags(args)
|
||||
if isinstance(policy.get("toolsets"), str):
|
||||
policy["toolsets"] = [name.strip() for name in policy["toolsets"].split(",") if name.strip()]
|
||||
if "skills" in policy:
|
||||
# `-s a,b -s c` (argparse append) or cli.main's comma string: one deduplicated list.
|
||||
raw = policy["skills"] if isinstance(policy["skills"], (list, tuple)) else [policy["skills"]]
|
||||
names = [name.strip() for item in raw for name in str(item).split(",") if name.strip()]
|
||||
policy["skills"] = list(dict.fromkeys(names))
|
||||
if not policy["skills"]:
|
||||
policy.pop("skills")
|
||||
if getattr(args, "source", None):
|
||||
policy["source"] = args.source
|
||||
return policy
|
||||
|
||||
@@ -48,7 +48,7 @@ async def probe(peer, target, kind):
|
||||
try:
|
||||
a = await connect()
|
||||
for payload in ({'source': 'telegram'}, {'source': 'cron'}, {'internal': True},
|
||||
{'profile': 'foreign'}, {'cwd': 'relative'}, {'checkpoints': True}):
|
||||
{'profile': 'foreign'}, {'cwd': 'relative'}, {'checkpoints': 'yes'}):
|
||||
denied = await rpc(a, 'session.create', **payload)
|
||||
assert 'error' in denied, denied
|
||||
before_sessions = set(authority.sessions)
|
||||
|
||||
@@ -79,7 +79,7 @@ async def probe(peer):
|
||||
result = json.loads(await ws.recv())
|
||||
if result.get('id') == method:
|
||||
return result
|
||||
sockets, sessions = [], []
|
||||
sockets, sessions, created_ids = [], [], {}
|
||||
before = dict(os.environ)
|
||||
config_before = Path(os.environ['HERMES_HOME'], 'config.yaml').read_bytes()
|
||||
try:
|
||||
@@ -91,10 +91,13 @@ async def probe(peer):
|
||||
params = dict(request_id=source, source=source, cwd=str(cwd), model='policy-' + source)
|
||||
if source == 'cli':
|
||||
params['toolsets'] = ['terminal']
|
||||
if source == 'tui': # `hermes --tui -s policy-skill --pass-session-id`
|
||||
params.update(skills=['policy-skill'], pass_session_id=True)
|
||||
created = await rpc(ws, 'session.create', **params)
|
||||
assert 'result' in created, created
|
||||
sid = created['result']['session_id']
|
||||
sessions.append(sid)
|
||||
created_ids[sid] = created['result']['stored_session_id']
|
||||
from gateway.session_policy import policy_for_source
|
||||
from hermes_state_runtime import RuntimeStoreError
|
||||
live_source = authority.sessions[sid].source
|
||||
@@ -131,6 +134,12 @@ async def probe(peer):
|
||||
assert len(requests) >= 2, peer.requests
|
||||
# Git Bash prints the MSYS form (/c/Users/...) on Windows; json.dumps doubles backslashes.
|
||||
assert any(_names_cwd(m.get('content'), cwd) for m in tool_results), (source, str(cwd), tool_results)
|
||||
# The preloaded skill and the session id ride every request's system prompt (frozen at
|
||||
# creation); the sessions launched without them carry neither.
|
||||
systems = [json.dumps([m for m in r['messages'] if m['role'] in {'system', 'developer'}]) for r in requests]
|
||||
stored = created_ids[sid]
|
||||
assert all(('POLICY_SKILL_BODY' in text) == (source == 'tui') for text in systems), (source, systems)
|
||||
assert all((('Session ID: ' + stored) in text) == (source == 'tui') for text in systems), (source, systems)
|
||||
names = {t['function']['name'] for t in requests[0]['tools']}
|
||||
assert 'terminal' in names
|
||||
assert ('desktop_ui' in agent.enabled_toolsets) == (source == 'gui')
|
||||
@@ -160,6 +169,9 @@ def main():
|
||||
threading.Thread(target=peer.serve_forever, daemon=True).start()
|
||||
url = f'http://127.0.0.1:{peer.server_port}/v1'
|
||||
os.environ.update(OPENAI_API_KEY='loopback-only', OPENAI_BASE_URL=url)
|
||||
skill = Path(os.environ['HERMES_HOME'], 'skills', 'policy-skill')
|
||||
skill.mkdir(parents=True)
|
||||
(skill / 'SKILL.md').write_text('---\nname: policy-skill\ndescription: fixture\n---\n\nPOLICY_SKILL_BODY\n')
|
||||
Path(os.environ['HERMES_HOME'], 'config.yaml').write_text(
|
||||
f'model:\n default: policy-default\n provider: custom\n base_url: {url}\n'
|
||||
'terminal:\n env_type: local\nstreaming:\n enabled: false\n'
|
||||
|
||||
@@ -34,11 +34,75 @@ def test_policy_selects_surface_and_isolates_cwd(tmp_path):
|
||||
list(pool.map(run, policies))
|
||||
assert dict(os.environ) == before
|
||||
for params in ({'source': 'cron'}, {'toolsets': ['not-a-toolset']}, {'cwd': '.'},
|
||||
{'provider': 12}, {'skills': ['x']}, {'toolsets': ['desktop_ui'], 'source': 'cli'}):
|
||||
{'provider': 12}, {'skills': 'x'}, {'skills': []}, {'pass_session_id': 1},
|
||||
{'toolsets': ['desktop_ui'], 'source': 'cli'}):
|
||||
with pytest.raises(RuntimeStoreError, match='invalid_params'):
|
||||
build_policy(params, {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_launch_options_reach_the_owner_frozen_and_hooks_consented(tmp_path, monkeypatch):
|
||||
"""`-s/--skills`, `--checkpoints`, `--pass-session-id`, `--accept-hooks` (and an exported
|
||||
HERMES_ACCEPT_HOOKS=1, which the TUI forwards) were refused by session.create, so no session
|
||||
could be created with them. They are this session's creation policy, applied by the owner."""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from gateway import run
|
||||
from gateway.config import GatewayConfig
|
||||
from gateway.session import SessionStore
|
||||
from gateway.session_authority import initialize_session_authority
|
||||
from gateway.session_controls import AuthorityConnection
|
||||
from gateway.session_policy import restore_policy
|
||||
from hermes_state_local import local_receipt
|
||||
from agent import shell_hooks
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
|
||||
home = tmp_path / 'home'
|
||||
(home / 'skills' / 'launch-fixture').mkdir(parents=True)
|
||||
(home / 'skills' / 'launch-fixture' / 'SKILL.md').write_text(
|
||||
'---\nname: launch-fixture\ndescription: fixture\n---\n\nLAUNCH_SKILL_BODY\n')
|
||||
monkeypatch.setenv('HERMES_HOME', str(home))
|
||||
monkeypatch.delenv('HERMES_ACCEPT_HOOKS', raising=False)
|
||||
hook = tmp_path / 'hook.sh'
|
||||
hook.write_text('#!/bin/sh\nexit 0\n')
|
||||
cfg = {'platform_toolsets': {'cli': []}, 'hooks': {'pre_tool_call': [{'command': str(hook)}]}}
|
||||
monkeypatch.setattr(run, '_load_gateway_config', lambda: cfg)
|
||||
monkeypatch.setattr(run, '_resolve_gateway_model', lambda config: 'fixture')
|
||||
monkeypatch.setattr(get_plugin_manager(), '_hooks', {})
|
||||
shell_hooks.reset_for_tests()
|
||||
store = SessionStore(tmp_path / 'sessions', GatewayConfig())
|
||||
runner = SimpleNamespace(session_store=store, _session_db=store._db, adapters={}, _draining=False,
|
||||
_cached_agent_for=lambda route: None)
|
||||
runner._adapter_for_source = lambda source: runner.adapters.get(source.platform)
|
||||
authority = await initialize_session_authority(runner, profile_id=str(home), instance_id='first')
|
||||
connection = AuthorityConnection(authority, object(), {'user_id': 'human'})
|
||||
|
||||
async def create(request_id, **params):
|
||||
reply = await connection.dispatch({'id': 1, 'method': 'session.create', 'params': {
|
||||
'request_id': request_id, 'source': 'tui', 'cwd': str(tmp_path), **params}})
|
||||
return reply.get('result', {}).get('session_id') or reply['error']['message']
|
||||
|
||||
launch = dict(skills=['launch-fixture', 'missing-typo'], checkpoints=True, pass_session_id=True, accept_hooks=True)
|
||||
sid = await create('launch', **launch)
|
||||
def frozen(session_id):
|
||||
return restore_policy(local_receipt(authority.db, session_id)['policy'])
|
||||
policy = frozen(sid)
|
||||
assert 'LAUNCH_SKILL_BODY' in policy.skills_prompt and policy.pass_session_id and policy.checkpoints_enabled
|
||||
assert str(hook) in (home / shell_hooks.ALLOWLIST_FILENAME).read_text()
|
||||
assert len(get_plugin_manager()._hooks['pre_tool_call']) == 1
|
||||
# Retrying the request id resumes the frozen route; nothing re-renders or re-registers.
|
||||
assert await create('launch', **launch) == sid
|
||||
assert len(get_plugin_manager()._hooks['pre_tool_call']) == 1
|
||||
plain = frozen(await create('plain'))
|
||||
assert plain.skills_prompt is None and not plain.pass_session_id and not plain.checkpoints_enabled
|
||||
# Never silently dropped: nothing resolving, bad shapes, or profile reads on a bypass launch refuse.
|
||||
for params in ({'skills': ['missing-typo']}, {'skills': 'launch-fixture'}, {'checkpoints': 'yes'},
|
||||
{'model': 'm', 'safe_mode': True, 'skills': ['launch-fixture']}):
|
||||
assert await create('refused-' + json.dumps(params), **params) in {'unknown_skill', 'invalid_params'}, params
|
||||
# An exported HERMES_ACCEPT_HOOKS=1 must not brick a --safe-mode TUI: no profile hooks, no refusal.
|
||||
assert (await create('safe', model='m', safe_mode=True, accept_hooks=True)).startswith('local-')
|
||||
|
||||
|
||||
def test_launch_policy_reaches_real_turn_runner(tmp_path):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -71,7 +71,8 @@ def test_launch_yolo_revocation_survives_the_next_turn(monkeypatch):
|
||||
source = SessionSource(platform=Platform.LOCAL, chat_id="launch-yolo", user_id="u", chat_type="dm")
|
||||
key = "agent:main:local:dm:launch-yolo"
|
||||
monkeypatch.setattr(session_policy, "policy_for_source",
|
||||
lambda _runner, _source: SimpleNamespace(yolo=True, platform="cli", max_turns=5))
|
||||
lambda _runner, _source: SimpleNamespace(yolo=True, platform="cli", max_turns=5,
|
||||
skills_prompt=None))
|
||||
|
||||
def _stop(**_kw):
|
||||
raise RuntimeError("stop after the yolo seam")
|
||||
|
||||
@@ -8,7 +8,7 @@ def test_unsupported_launch_options_fail_before_connection(monkeypatch, capsys):
|
||||
from hermes_cli import gateway_chat
|
||||
calls = []
|
||||
monkeypatch.setattr(gateway_chat, "connect_gateway", lambda: calls.append(True))
|
||||
for option in ("checkpoints", "worktree", "run_budget"):
|
||||
for option in ("image", "worktree", "run_budget"):
|
||||
args = argparse.Namespace(**{option: True})
|
||||
assert gateway_chat.launch_from_args(args) == 2
|
||||
assert option.replace("_", "-") in capsys.readouterr().err
|
||||
@@ -38,9 +38,9 @@ def test_refusals_name_the_replacement_and_a_runnable_safe_mode_example(monkeypa
|
||||
refusal prints a command they can run as-is."""
|
||||
from hermes_cli import gateway_chat
|
||||
monkeypatch.setattr(gateway_chat, "connect_gateway", lambda: pytest.fail("connected"))
|
||||
assert gateway_chat.launch_from_args(argparse.Namespace(checkpoints=True, run_budget=30.0)) == 2
|
||||
assert gateway_chat.launch_from_args(argparse.Namespace(worktree=True, run_budget=30.0)) == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--checkpoints: use" in err and "checkpoints.enabled" in err
|
||||
assert "--worktree: use" in err and "hermes --tui -w" in err
|
||||
assert "--run-budget: use" in err and "run_budget_seconds" in err
|
||||
for name in gateway_chat._UNSUPPORTED:
|
||||
assert name in gateway_chat._RELOCATED, f"{name} refused without saying where it went"
|
||||
@@ -64,7 +64,9 @@ async def test_creation_preserves_advertised_cwd_model_and_toolsets(monkeypatch,
|
||||
async def rpc(self, method, **params):
|
||||
calls.append((method, params))
|
||||
if method == "runtime.describe":
|
||||
return {"session_create": {"sources": ["cli"], "parameters": ["cwd", "model", "toolsets", "request_id", "source"]}}
|
||||
return {"session_create": {"sources": ["cli"], "parameters": [
|
||||
"cwd", "model", "toolsets", "request_id", "source", "skills", "checkpoints", "accept_hooks",
|
||||
"pass_session_id"]}}
|
||||
return {"stored_session_id": "stored"}
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -78,12 +80,18 @@ async def test_creation_preserves_advertised_cwd_model_and_toolsets(monkeypatch,
|
||||
monkeypatch.setattr(gateway_chat, "connect_gateway", connected)
|
||||
monkeypatch.setattr(GatewayChatView, "run", rendered)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
args = argparse.Namespace(query="literal", model="explicit-model", toolsets="terminal, file", quiet=True)
|
||||
# `-s a,b -s a` + the session-scoped launch flags + an exported HERMES_ACCEPT_HOOKS=1 ride the
|
||||
# create (the classic client is a gateway client; nothing is refused or dropped).
|
||||
monkeypatch.setenv("HERMES_ACCEPT_HOOKS", "1")
|
||||
args = argparse.Namespace(query="literal", model="explicit-model", toolsets="terminal, file", quiet=True,
|
||||
skills=["a,b", "a"], checkpoints=True, pass_session_id=True)
|
||||
assert await gateway_chat.run_gateway_chat(args) == 0
|
||||
create = calls[1][1]
|
||||
assert create["cwd"] == str(tmp_path)
|
||||
assert create["model"] == "explicit-model"
|
||||
assert create["toolsets"] == ["terminal", "file"]
|
||||
assert create["skills"] == ["a", "b"] and create["checkpoints"] is create["pass_session_id"] is True
|
||||
assert create["accept_hooks"] is True
|
||||
assert create["source"] == "cli" and create["request_id"]
|
||||
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ def test_invalid_options_are_reported_before_reading_profile(monkeypatch, capsys
|
||||
raise AssertionError("Unsupported launch read profile configuration")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", unexpected)
|
||||
assert launch_gateway_chat(Namespace(checkpoints=True)) == 2
|
||||
assert "--checkpoints" in capsys.readouterr().err
|
||||
assert launch_gateway_chat(Namespace(worktree=True)) == 2
|
||||
assert "--worktree" in capsys.readouterr().err
|
||||
# A bypass launch skips provider probing and the profile cost guard entirely.
|
||||
monkeypatch.setattr("hermes_cli.main._confirm_startup_expensive_model_override", lambda args: unexpected())
|
||||
from hermes_cli.gateway_client import GatewayClientError
|
||||
|
||||
@@ -29,6 +29,10 @@ class CanonicalCreateParams(ProfileParams):
|
||||
yolo: bool = False
|
||||
safe_mode: bool = False
|
||||
ignore_user_config: bool = False
|
||||
skills: list[str] | None = None
|
||||
checkpoints: bool = False
|
||||
accept_hooks: bool = False
|
||||
pass_session_id: bool = False
|
||||
editor: dict[str, JsonValue] | None = None
|
||||
title: str | None = None
|
||||
hidden: bool = False
|
||||
|
||||
@@ -17,7 +17,8 @@ def bootstrap(start: bool) -> dict:
|
||||
# Launch policy travels in session.create, not into daemon-wide defaults.
|
||||
for key in ("HERMES_MODEL", "HERMES_INFERENCE_MODEL", "HERMES_TUI_PROVIDER",
|
||||
"HERMES_INFERENCE_PROVIDER", "HERMES_TUI_TOOLSETS", "HERMES_TUI_SKILLS",
|
||||
"HERMES_CWD", "TERMINAL_CWD", "HERMES_YOLO", "HERMES_ACCEPT_HOOKS"):
|
||||
"HERMES_CWD", "TERMINAL_CWD", "HERMES_YOLO", "HERMES_ACCEPT_HOOKS",
|
||||
"HERMES_TUI_CHECKPOINTS", "HERMES_TUI_PASS_SESSION_ID"):
|
||||
os.environ.pop(key, None)
|
||||
receipt = (ensure_gateway_runtime(home, timeout=30) if start
|
||||
else discover_gateway_endpoint(home, timeout=5))
|
||||
|
||||
@@ -15,13 +15,23 @@ it('retains prepared identity and rejects unsupported TUI launch policy instead
|
||||
const contract = { sources: ['tui'], parameters: ['request_id', 'source', 'model', 'cwd', 'toolsets'] }
|
||||
expect(canonicalRequest('session.create', { request_id: 'fresh', model: 'local-model', cwd: '/tmp/project' }, contract)).toEqual({ method: 'session.create', params: { request_id: 'fresh', source: 'tui', model: 'local-model', cwd: '/tmp/project' } })
|
||||
expect(() => canonicalRequest('session.create', {}, { sources: ['cli'], parameters: [] })).toThrow('tui')
|
||||
expect(() => canonicalRequest('session.create', { skills: ['test'] }, contract)).toThrow('skills')
|
||||
expect(() => canonicalRequest('session.create', { yolo: true }, contract)).toThrow('yolo')
|
||||
expect(canonicalRequest('prompt.submit', { session_id: 'sid', submission_id: 'prepared-id', text: 'hello', queued: true }, contract).params).toEqual({ session_id: 'sid', input_id: 'prepared-id', text: 'hello', queued: true })
|
||||
expect(sharedControlParams({ sharedControl: { session_id: 'sid', execution_generation: 9, prompt_id: 'approval-9' } })).toEqual({ session_id: 'sid', execution_generation: 9, prompt_id: 'approval-9' })
|
||||
const receipt = canonicalResult('prompt.submit', { admission_id: 'server-admission', ref: { profile_id: '/tmp/profile', session_id: 'sid' }, status: 'queued' }, { input_id: 'prepared-id' })
|
||||
expect(receipt).toMatchObject({ admission_id: 'server-admission', input_id: 'prepared-id', target_profile_home: '/tmp/profile', target_session_id: 'sid' })
|
||||
})
|
||||
|
||||
it('carries -s/--checkpoints/--pass-session-id/--accept-hooks (and exported HERMES_ACCEPT_HOOKS=1) onto session.create', () => {
|
||||
const options = localCreationOptions({ HERMES_TUI_SKILLS: 'a, b,a', HERMES_TUI_CHECKPOINTS: '1',
|
||||
HERMES_TUI_PASS_SESSION_ID: '1', HERMES_ACCEPT_HOOKS: '1' } as NodeJS.ProcessEnv)
|
||||
|
||||
expect(options).toEqual({ skills: ['a', 'b'], checkpoints: true, pass_session_id: true, accept_hooks: true })
|
||||
expect(localCreationOptions({ HERMES_ACCEPT_HOOKS: '0', HERMES_TUI_SKILLS: ' , ' } as NodeJS.ProcessEnv)).toEqual({})
|
||||
const contract = { sources: ['tui'], parameters: ['request_id', 'source', ...Object.keys(options)] }
|
||||
expect(canonicalRequest('session.create', { request_id: 'owned', ...options }, contract).params).toMatchObject(options)
|
||||
})
|
||||
|
||||
it('rebuilds --max-turns from the launcher environment as the integer the session policy requires', () => {
|
||||
const options = localCreationOptions({ HERMES_TUI_MAX_TURNS: '5', HERMES_MODEL: 'local-model' } as NodeJS.ProcessEnv)
|
||||
expect(options.max_turns).toBe(5)
|
||||
|
||||
@@ -77,15 +77,18 @@ export function canonicalEvent<T extends { type: string; payload?: any }>(event:
|
||||
|
||||
export function localCreationOptions(env = process.env): Record<string, unknown> {
|
||||
const fields: Record<string, string | undefined> = {
|
||||
model: env.HERMES_MODEL, provider: env.HERMES_TUI_PROVIDER, cwd: env.HERMES_CWD,
|
||||
skills: env.HERMES_TUI_SKILLS, checkpoints: env.HERMES_TUI_CHECKPOINTS,
|
||||
accept_hooks: env.HERMES_ACCEPT_HOOKS
|
||||
model: env.HERMES_MODEL, provider: env.HERMES_TUI_PROVIDER, cwd: env.HERMES_CWD
|
||||
}
|
||||
|
||||
const options: Record<string, unknown> = Object.fromEntries(Object.entries(fields).filter(([, value]) => value))
|
||||
|
||||
if (env.HERMES_TUI_TOOLSETS) { options.toolsets = env.HERMES_TUI_TOOLSETS.split(',') }
|
||||
|
||||
// `hermes --tui -s a,b`: the owner renders these into the session's frozen prompt.
|
||||
const skills = [...new Set((env.HERMES_TUI_SKILLS ?? '').split(/[,\n]/).map(name => name.trim()).filter(Boolean))]
|
||||
|
||||
if (skills.length) { options.skills = skills }
|
||||
|
||||
// The launcher exports `--max-turns 5` as the string "5": numbers ride as
|
||||
// ints (0 / -1 = unlimited), and a word ("none", "unlimited") stays text
|
||||
// for the policy to read — `Number` would turn it into NaN, sent as null.
|
||||
@@ -99,9 +102,13 @@ export function localCreationOptions(env = process.env): Record<string, unknown>
|
||||
// session prompts anyway / still loads rules, SOUL, memory and skills.auto_load.
|
||||
const truthy = (value?: string) => ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase())
|
||||
|
||||
// `--checkpoints` / `--pass-session-id` / `--accept-hooks` (and an exported HERMES_ACCEPT_HOOKS=1)
|
||||
// are this session's creation policy too: the owner applies them, nothing reads them in-process.
|
||||
for (const [field, value] of [
|
||||
['yolo', env.HERMES_YOLO_MODE], ['ignore_rules', env.HERMES_IGNORE_RULES],
|
||||
['safe_mode', env.HERMES_SAFE_MODE], ['ignore_user_config', env.HERMES_IGNORE_USER_CONFIG]
|
||||
['safe_mode', env.HERMES_SAFE_MODE], ['ignore_user_config', env.HERMES_IGNORE_USER_CONFIG],
|
||||
['checkpoints', env.HERMES_TUI_CHECKPOINTS], ['pass_session_id', env.HERMES_TUI_PASS_SESSION_ID],
|
||||
['accept_hooks', env.HERMES_ACCEPT_HOOKS]
|
||||
] as const) {
|
||||
if (truthy(value)) { options[field] = true }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user