diff --git a/gateway/run.py b/gateway/run.py index 7c0522dbed..16127aae24 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -27810,9 +27810,10 @@ def main(): # Advertise the agent harness to child processes (AI_AGENT is the # cross-agent standard; HERMES_AGENT the Hermes-specific marker — see # _advertise_agent_env in hermes_cli/main.py, kept inline here to avoid - # importing that module's startup side effects). setdefault so an outer - # harness is never clobbered. - os.environ.setdefault("AI_AGENT", "hermes") + # importing that module's startup side effects). The value must equal our + # public agent-harness registry id (``hermes-agent``) — standard-var + # matching is exact. setdefault so an outer harness is never clobbered. + os.environ.setdefault("AI_AGENT", "hermes-agent") os.environ.setdefault("HERMES_AGENT", "true") # Force UTF-8 stdio on Windows — gateway logs and startup banner would diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7f6d61ec31..5741a85f0a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11331,11 +11331,14 @@ def _advertise_agent_env() -> None: ``AI_AGENT`` is the emerging cross-agent standard (huggingface_hub's agent detection reads it; pi and other agents set it — earendil-works/pi#7493) so generic tooling can attribute subprocesses to the harness that spawned - them. ``HERMES_AGENT`` is the Hermes-specific marker. setdefault: never + them. The value must be our id in the public agent-harness registry + (``hermes-agent`` in huggingface.js ``agent-harnesses.ts``): standard-var + matching is exact, so any other value is counted as "unknown". + ``HERMES_AGENT`` is the Hermes-specific marker. setdefault: never clobber an outer harness (e.g. Hermes running inside another agent's terminal). """ - os.environ.setdefault("AI_AGENT", "hermes") + os.environ.setdefault("AI_AGENT", "hermes-agent") os.environ.setdefault("HERMES_AGENT", "true") diff --git a/tests/hermes_cli/test_agent_env_advertisement.py b/tests/hermes_cli/test_agent_env_advertisement.py index 86fb12f036..58f2fc35d1 100644 --- a/tests/hermes_cli/test_agent_env_advertisement.py +++ b/tests/hermes_cli/test_agent_env_advertisement.py @@ -3,19 +3,35 @@ Port of earendil-works/pi#7493: entry points advertise the agent harness to child processes via the cross-agent ``AI_AGENT`` standard plus a Hermes-specific marker, without clobbering an outer harness. + +The AI_AGENT value must equal Hermes' id in the public agent-harness +registry (``hermes-agent`` in huggingface.js ``agent-harnesses.ts``) — +standard-var matching there is exact, so any other value is attributed to +"unknown". + +The terminal backends additionally export both vars inside every wrapped +shell command (``BaseEnvironment._wrap_command``) so the marker reaches +REMOTE backends (Docker/SSH/Modal/Daytona/Singularity/Vercel) whose exec +environment does not inherit the Hermes process env, and survives the +cross-session leak guard that strips ``HERMES_SESSION_*`` from subprocess +envs in engaged multi-session hosts. """ import os +import subprocess from hermes_cli.main import _advertise_agent_env +# Registry id — must stay in sync with huggingface.js agent-harnesses.ts. +HARNESS_ID = "hermes-agent" + class TestAdvertiseAgentEnv: def test_sets_both_vars_when_unset(self, monkeypatch): monkeypatch.delenv("AI_AGENT", raising=False) monkeypatch.delenv("HERMES_AGENT", raising=False) _advertise_agent_env() - assert os.environ["AI_AGENT"] == "hermes" + assert os.environ["AI_AGENT"] == HARNESS_ID assert os.environ["HERMES_AGENT"] == "true" def test_does_not_clobber_outer_harness(self, monkeypatch): @@ -30,5 +46,48 @@ class TestAdvertiseAgentEnv: monkeypatch.delenv("HERMES_AGENT", raising=False) _advertise_agent_env() _advertise_agent_env() - assert os.environ["AI_AGENT"] == "hermes" + assert os.environ["AI_AGENT"] == HARNESS_ID assert os.environ["HERMES_AGENT"] == "true" + + +class TestWrapCommandAdvertisesHarness: + """The shell-level export in BaseEnvironment._wrap_command.""" + + def _wrap(self, command: str) -> str: + from tools.environments.local import LocalEnvironment + + env = LocalEnvironment.__new__(LocalEnvironment) + env._snapshot_ready = False + env._session_id = "testsession0" + env._cwd_marker = "__HERMES_CWD_testsession0__" + env._snapshot_path = "/tmp/hermes-snap-testsession0.sh" + env._snapshot_passthrough_names = set() + return env._wrap_command(command, "/tmp") + + def test_wrap_command_contains_export(self): + wrapped = self._wrap("true") + assert 'AI_AGENT="${AI_AGENT:-' + HARNESS_ID + '}"' in wrapped + assert 'HERMES_AGENT="${HERMES_AGENT:-true}"' in wrapped + + def test_export_precedes_user_command(self): + wrapped = self._wrap("echo payload-sentinel") + assert wrapped.index("AI_AGENT=") < wrapped.index("payload-sentinel") + + def test_shell_sets_default_and_preserves_outer(self): + """Run the wrapped script through real bash both ways.""" + wrapped = self._wrap('echo "AI=$AI_AGENT HERMES=$HERMES_AGENT"') + + clean_env = {k: v for k, v in os.environ.items() + if k not in ("AI_AGENT", "HERMES_AGENT")} + out = subprocess.run( + ["bash", "-c", wrapped], capture_output=True, text=True, + env=clean_env, timeout=30, + ) + assert f"AI={HARNESS_ID} HERMES=true" in out.stdout + + outer_env = dict(clean_env, AI_AGENT="pi", HERMES_AGENT="false") + out = subprocess.run( + ["bash", "-c", wrapped], capture_output=True, text=True, + env=outer_env, timeout=30, + ) + assert "AI=pi HERMES=false" in out.stdout diff --git a/tools/environments/base.py b/tools/environments/base.py index 5375e19bfe..76fcf81353 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -566,6 +566,13 @@ def _export_dump_excluding_session_vars( return ( "{ ( " "unset ${!HERMES_SESSION_*} ${!HERMES_CRON_AUTO_DELIVER_*} " + # AI_AGENT / HERMES_AGENT are per-command attribution markers + # (re-exported by every _wrap_command with outer-harness-preserving + # ${VAR:-default} semantics). Persisting them into the snapshot + # would make the FIRST command's value override a later outer + # harness value arriving via the process env, exactly like the + # session-var leak this dump already guards against. + "AI_AGENT HERMES_AGENT " f"HERMES_UI_SESSION_ID{extra_unset} 2>/dev/null; " "export -p; " ") || true; } " @@ -886,6 +893,23 @@ class BaseEnvironment(ABC): ) parts.append(f"unset {present} {value}") + # Harness attribution: every tool subprocess advertises that it runs + # under Hermes via the cross-agent ``AI_AGENT`` standard (read by e.g. + # huggingface_hub's agent detection) plus the Hermes-specific + # ``HERMES_AGENT`` marker. The value MUST equal our id in the public + # agent-harness registry (``hermes-agent`` — see huggingface.js + # ``agent-harnesses.ts``); standard-var matching is exact, so any other + # value is reported as "unknown". Setting it here (rather than only in + # the host process env) is what carries the marker into REMOTE backends + # (Docker/SSH/Modal/Daytona/Singularity/Vercel), whose exec env is not + # inherited from the Hermes process. ``${VAR:-default}`` semantics: + # never clobber an outer harness value that arrived via the inherited + # process env (Hermes running inside another agent's terminal). + parts.append( + 'export AI_AGENT="${AI_AGENT:-hermes-agent}" ' + 'HERMES_AGENT="${HERMES_AGENT:-true}"' + ) + # Preserve bare ``~`` expansion, but rewrite ``~/...`` through # ``$HOME`` so suffixes with spaces remain a single shell word. quoted_cwd = self._quote_cwd_for_cd(cwd) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b02b7cdd8c..7aa44c6eaf 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -864,8 +864,8 @@ Unset the variable or remove it from `.env` to restore normal writes (still subj | `SESSION_IDLE_MINUTES` | Reset sessions after N minutes of inactivity (default: 1440) | | `SESSION_RESET_HOUR` | Daily reset hour in 24h format (default: 4 = 4am) | | `HERMES_SESSION_ID` | **Exported automatically into every tool subprocess** Hermes spawns (`terminal`, `execute_code`, persistent shell, Docker/Singularity backends, delegated subagent runs). Set by the agent to the current session ID; user scripts called from tools can read it to correlate their output, telemetry, or side effects with the originating Hermes session. **You should not set this manually** — overriding it from a parent shell only takes effect outside an agent run, and is overwritten the moment the agent starts a session. | -| `AI_AGENT` | **Set to `hermes` by the CLI and gateway entry points** (only when not already set by an outer harness). The emerging cross-agent standard for child-process attribution — generic tooling (e.g. huggingface_hub's agent detection) reads it to know it runs under an AI agent. Don't set manually. | -| `HERMES_AGENT` | **Set to `true` by the CLI and gateway entry points** so child processes can detect they run inside Hermes specifically. Don't set manually. | +| `AI_AGENT` | **Set to `hermes-agent` by the CLI and gateway entry points** (only when not already set by an outer harness), and exported into every terminal-tool shell — including remote backends (Docker, SSH, Modal, Daytona, Singularity, Vercel). The emerging cross-agent standard for child-process attribution — generic tooling (e.g. huggingface_hub's agent detection) reads it to know it runs under an AI agent. The value matches Hermes' id in the public agent-harness registry. Don't set manually. | +| `HERMES_AGENT` | **Set to `true` by the CLI and gateway entry points** and exported into every terminal-tool shell so child processes can detect they run inside Hermes specifically. Don't set manually. | ## Context Compression (config.yaml only)