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:
Teknium committed 2026-10-08 09:10:37 -07:00
1 parent 562aeb62f3
commit 2ee1cd1ebb
19 files changed
+266 -31

No files matched your search

+2
View File
@@ -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": [
{
+2
View File
@@ -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()
+18 -1
View File
@@ -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:
+1
View File
@@ -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):
+7 -1
View File
@@ -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)
+63 -2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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)
+13 -1
View File
@@ -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'
+65 -1
View File
@@ -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
+2 -1
View File
@@ -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")
+13 -5
View File
@@ -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
+2 -1
View File
@@ -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))
+11 -1
View File
@@ -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)
+11 -4
View File
@@ -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 }
}