diff --git a/agent/managed_worker.py b/agent/managed_worker.py index 2c4916a1e4..2847acc1f2 100644 --- a/agent/managed_worker.py +++ b/agent/managed_worker.py @@ -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)), diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts index 3672d3b4a9..819df340a7 100644 --- a/apps/shared/src/gateway-contract.generated.ts +++ b/apps/shared/src/gateway-contract.generated.ts @@ -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 | null title?: string | null hidden?: boolean diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index 8778cb36e8..5f1880becd 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -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": [ { diff --git a/gateway/run_turn_runner.py b/gateway/run_turn_runner.py index 7e79b53f53..c8495e8d0d 100644 --- a/gateway/run_turn_runner.py +++ b/gateway/run_turn_runner.py @@ -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() diff --git a/gateway/session_controls.py b/gateway/session_controls.py index eed4ebc995..930088f303 100644 --- a/gateway/session_controls.py +++ b/gateway/session_controls.py @@ -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: diff --git a/gateway/session_execution.py b/gateway/session_execution.py index cf006094c1..e619d3ca6f 100644 --- a/gateway/session_execution.py +++ b/gateway/session_execution.py @@ -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): diff --git a/gateway/session_local.py b/gateway/session_local.py index b55a034401..2745404402 100644 --- a/gateway/session_local.py +++ b/gateway/session_local.py @@ -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) diff --git a/gateway/session_policy.py b/gateway/session_policy.py index ffb4c39f1d..17782154ac 100644 --- a/gateway/session_policy.py +++ b/gateway/session_policy.py @@ -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 diff --git a/hermes_cli/gateway_chat.py b/hermes_cli/gateway_chat.py index e17eae4391..11a97aa1b9 100644 --- a/hermes_cli/gateway_chat.py +++ b/hermes_cli/gateway_chat.py @@ -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 `", "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 ` (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 diff --git a/tests/gateway/fixtures/local_session_peer.py b/tests/gateway/fixtures/local_session_peer.py index 33462c094f..ea366d2790 100644 --- a/tests/gateway/fixtures/local_session_peer.py +++ b/tests/gateway/fixtures/local_session_peer.py @@ -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) diff --git a/tests/gateway/fixtures/session_policy_peer.py b/tests/gateway/fixtures/session_policy_peer.py index 12218c569e..3cd06458b1 100644 --- a/tests/gateway/fixtures/session_policy_peer.py +++ b/tests/gateway/fixtures/session_policy_peer.py @@ -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' diff --git a/tests/gateway/test_session_policy.py b/tests/gateway/test_session_policy.py index ceff0edd0f..2db24d626c 100644 --- a/tests/gateway/test_session_policy.py +++ b/tests/gateway/test_session_policy.py @@ -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 diff --git a/tests/gateway/test_yolo_command.py b/tests/gateway/test_yolo_command.py index 726c509ed6..4481e33ea0 100644 --- a/tests/gateway/test_yolo_command.py +++ b/tests/gateway/test_yolo_command.py @@ -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") diff --git a/tests/hermes_cli/test_gateway_chat.py b/tests/hermes_cli/test_gateway_chat.py index 4cb51094e1..64da98aa85 100644 --- a/tests/hermes_cli/test_gateway_chat.py +++ b/tests/hermes_cli/test_gateway_chat.py @@ -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"] diff --git a/tests/hermes_cli/test_gateway_startup_validation.py b/tests/hermes_cli/test_gateway_startup_validation.py index ae1718c77d..b0ca95a812 100644 --- a/tests/hermes_cli/test_gateway_startup_validation.py +++ b/tests/hermes_cli/test_gateway_startup_validation.py @@ -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 diff --git a/tui_gateway/contracts/canonical_sessions.py b/tui_gateway/contracts/canonical_sessions.py index c4fa6a8815..3f6fcf9131 100644 --- a/tui_gateway/contracts/canonical_sessions.py +++ b/tui_gateway/contracts/canonical_sessions.py @@ -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 diff --git a/ui-tui/scripts/gateway_bootstrap.py b/ui-tui/scripts/gateway_bootstrap.py index fc3b43b572..42d64b90b5 100644 --- a/ui-tui/scripts/gateway_bootstrap.py +++ b/ui-tui/scripts/gateway_bootstrap.py @@ -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)) diff --git a/ui-tui/src/__tests__/canonicalGateway.test.ts b/ui-tui/src/__tests__/canonicalGateway.test.ts index b2fcf7c19d..d68c8dd95d 100644 --- a/ui-tui/src/__tests__/canonicalGateway.test.ts +++ b/ui-tui/src/__tests__/canonicalGateway.test.ts @@ -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) diff --git a/ui-tui/src/canonicalGateway.ts b/ui-tui/src/canonicalGateway.ts index 75e703fdfb..b67a118238 100644 --- a/ui-tui/src/canonicalGateway.ts +++ b/ui-tui/src/canonicalGateway.ts @@ -77,15 +77,18 @@ export function canonicalEvent(event: export function localCreationOptions(env = process.env): Record { const fields: Record = { - 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 = 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 // 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 } }