With the homeassistant plugin missing, send_message to a `homeassistant:` target failed with
"Unknown or unregistered plugin platform: homeassistant" and no way forward.
Both unknown-platform errors (target resolution and platform config) now go through one helper
that appends, for a platform that left core (a LEFT_CORE row's platform), the exact install
command for the active home (`hermes [-p <profile>] plugins install homeassistant`). Other unknown
names keep the bare message.
PluginContext.register_platform is last-writer-wins, so a user plugin could re-register 'telegram'
with trusted_inbound=True and every sender would skip the allowlist and pairing. The flag is now
refused for a platform core ships (a static Platform member or a bundled plugins/platforms/ plugin)
unless the registering plugin is bundled. Home Assistant is no longer either, so its catalog plugin
keeps it. `hermes plugins validate` prints a line for every trusted_inbound platform (and fails one
naming a core platform).
Also: the MCP collision test docstring no longer refers to the toolset it used to collide with.
Moving HA out of core dropped HASS_TOKEN/HASS_URL from the static provider blocklist and HASS_TOKEN
from the Tier-1 always-strip set. Once installed, the plugin's manifest declares them, but with the
plugin absent (migration pending, declined or failed) the token reached every terminal/subprocess
child. Each left-core row now names the env core stripped while it shipped the feature
(secret_env -> Tier 1, private_env -> provider blocklist) and local_env_policy keeps stripping it.
The Home Assistant gateway platform (plugins/platforms/homeassistant) and the
homeassistant toolset (tools/homeassistant_tool.py) now ship as the official
catalog plugin `homeassistant` (NousResearch/hermes-homeassistant), with the
same platform name, toolset/tool names, config keys and env vars.
Migration (hermes_cli/left_core_migration.py, a generic "left core" table
reusing the memory-provider migration's helpers): every profile home that
used Home Assistant (HASS_TOKEN in its .env, platforms.homeassistant enabled
or holding a token, or the homeassistant toolset selected) gets the plugin
installed from the catalog at its reviewed pin by `hermes update`, and lazily
once per process at agent start / gateway start (honours
security.allow_lazy_installs). Every outcome reaches terminal, Desktop and
chat; a gateway-start outcome is handed to that home's first agent.
Core HA special cases become generic PlatformEntry seams the plugin uses:
trusted_inbound (allowlist bypass for event-bus platforms), display_tier
(built-in display defaults) and shared_env_prefixes (profile-clone ownership
rule for env shared with the plugin's tools). Cron delivery uses the existing
cron_deliver_env_var seam; HASS_TOKEN stays stripped from subprocesses via the
installed plugin's manifest secrets.
The guard previously scanned only SKILL.md files, but skill_view serves
references/, templates/ and scripts/ content verbatim too, so a token inside
a referenced doc travels into later requests just the same. Scan every
bundled *.md under skills/ and optional-skills/ instead (the .hub cache
stays excluded), and move the test to tests/skills/ beside the other
bundled-skill doc contract tests.
Mutation-checked: injecting the token into a references/*.md turns the suite
red; restoring turns it green.
(cherry picked from commit 52d2e4e3dbda9d99151696b889e79cd8ca44a73c)
skill_view returns a skill's full text, so bundled SKILL.md files are
part of the request surface: skill content loaded into a later request
travels verbatim to the provider. OpenRouter's gateway classifier treats
the angle-bracket tool placeholder documented in three bundled skills as
a role-tag injection attempt and rejects the whole request with HTTP 403
("Request blocked: prompt injection patterns detected"); Hermes'
per-provider retries then fail the same way, leaving the conversation
unrecoverable on that provider.
Switch the three documented placeholders to the brace form already used
by skills/autonomous-ai-agents/hermes-agent/references/native-mcp.md:
- skills/software-development/hermes-agent-skill-authoring/SKILL.md
(CLI-wrapper invocation example)
- skills/autonomous-ai-agents/claude-code/SKILL.md (MCP tool naming)
- optional-skills/creative/unreal-mcp/SKILL.md (tool-search mode)
Add tests/tools/test_bundled_skill_doc_placeholders.py, a data-driven
guard that scans the bundled skills tree (skills/ and optional-skills/,
skipping hub caches) for the angle-bracket tool placeholder and lists
every offending file:line. The scanned token is assembled from fragments
so the guard itself never carries the literal.
Note for operators: the hub index cache (skills/.hub/index-cache/) is
not tracked by git. After pulling this change, rebuild the cache so
runtime-loaded skill text picks up the corrected placeholders.
Fixes#132504
(cherry picked from commit c080bc17ae0cf133af90399aa09bdee3d818f7af)
Skill Sync only worked against a Nous Portal sync plane that was never made
publicly accessible, so no install has ever used it. We don't want it in core
right now, so this deletes it outright instead of moving it behind a plugin
hook surface nobody needs yet:
- `hermes sync` command, `tools/skills_sync_client*.py` and their tests
- the personal pull ticks (gateway housekeeping, classic CLI startup,
`hermes serve` maintenance) and the debounced post-write push
- the per-skill `sync` usage flag (`set_sync` / `is_sync_enabled`)
- the `skills/_org/<org_id>/` mirror: index gating on `.active_org`, the
`[org-shared]` / name-collision labels, the org provenance header and
`org_provenance` field on skill_view, the org delete guard and the
auto-propose `org_sharing` note on skill_manage
The code is preserved in the private NousResearch/hermes-skill-sync repo if
the feature comes back.
Since systemd 254, `systemd-run --scope` expands $VAR / ${VAR} / $$ in the
command line itself (--expand-environment defaults to yes), so a background
command under a supervised gateway ran rewritten: `$$` became a literal `$`,
`${arr[@]}` became '' with a warning on stderr.
_systemd_scope_argv is the single argv choke point shared by the availability
probe, the pipe/PTY background spawns and restart-safe cron/kanban children,
so pass `--expand-environment=no` there. systemd-run < 254 rejects the
option (and never expanded in scope mode), so the existing cached probe drops
it and re-probes once when systemd-run names it in the rejection — no extra
subprocess on modern hosts and no lost isolation on old ones.
Fixes#132385.
Gaps in the unanswered-/health handling, found in a second review:
- _probe_bridge_health swallowed a timeout while reading the JSON body
and reported an unparseable 200, so a bridge that sent headers but
stalled on the body still reached _kill_port_process. Timeouts now
propagate from either phase.
- The guard listed listeners with the POSIX-only lsof/ss scan, which
finds nothing on Windows, so Windows fell through to the netstat-based
kill. Both now use _port_listener_pids(); if listing fails, nothing
is killed.
- _standalone_send treated a non-200 /health as an older bridge and
sent anyway. It now sends nothing.
- Docs named a nonexistent `hermes whatsapp send`; the command is
`hermes send --to whatsapp:<chat_id>`.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
Two gaps left by the session check, both found in review:
- connect(): a port holder that does not answer /health in time fell
through to _kill_port_process, so another profile's busy bridge was
still killed. No answer is no ownership evidence: after this profile's
own bridge is reaped by its pidfile, a remaining listener is left
running and connect() fails with the retryable
whatsapp_bridge_unresponsive.
- _standalone_send (hermes whatsapp send, cron delivery) posted to the
port without asking whose bridge it was, so a profile without its own
bridge sent from another profile's WhatsApp account. It now reads
/health before every send and refuses one whose session is another
profile's. A bridge without the field (older bridge.js) is still used.
The session dir is resolved by one helper for the adapter and standalone
sends, so both compare the same absolute string.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
febd2af391 skipped WhatsApp on every secondary profile under multiplex on the
premise that "a secondary has no credential of its own to bring" because the
bridge is "one authenticated session tied to a single phone number". The
session directory is home-relative (`platforms/whatsapp/session` under the
profile's own home), so each profile already owns its own paired session.
What actually collided was the bridge port, 3000 for everyone, and the
adapter's startup path that kills whatever holds that port before spawning.
A fleet running one phone per profile lost WhatsApp on every profile but the
launching one, with a single INFO line to say so.
The credential gate for WhatsApp is now the profile's own paired session
(`creds.json` in its session directory), evaluated in that profile's scope.
An unpaired secondary is reported as `whatsapp_unpaired` with the pairing
command, once, no retry loop. A paired secondary gets an adapter of its own.
Port ownership without any probe of another profile's bridge
(`plugins/platforms/whatsapp/bridge_ownership.py`): an explicit
`platforms.whatsapp.extra.bridge_port` wins; otherwise a secondary takes the
first free port in 3001..3099 and records it in
`platforms/whatsapp/bridge_port` so it is stable across restarts. A
secondary never calls `_kill_port_process`, never adopts a running bridge
and never signals a pidfile process it did not spawn; a bound port or a
live prior process is a fatal for that profile only, naming the port and
the config key. The default profile's startup path is unchanged.
Out-of-process sends (`hermes -p X whatsapp send`) read the same record so
they reach the profile's own bridge.
Relay stays process-level shared ingress; its skip and remedy are untouched.
(cherry picked from commit f96c53df65cff45af2488bd13f22225ce1aec743)
Gate efficiency finding (Low): resolve_skill_catalog only remembered a
shadowed path once it had warned, so a byte-identical (non-symlink) copy
of a higher-tier skill -- e.g. an external_dirs mirror of local skills --
was re-read and SHA-256-hashed against every owner on each catalog
rebuild / prompt-index miss. Key the memo by (shadowed path, *sorted
higher-tier paths) and record it whether or not a warning fires.
Probe: provably_same_skill calls over 5 resolves of one identical
shadowed copy 5 -> 1. Warn-once behaviour is unchanged.
- test_skills_tool: the 59da8ec4e cross-dir refusal case becomes a parametrized
precedence test (local>external incl. same relative path, create_dir>external,
local>create_dir, project>local) asserting the winner loads, skills_list shows
one row, and the shadowed copy is warned about. Same-tier refusal tests are
unchanged except the equal-rank case also pins the new "use one of" paths.
- test_skill_commands: one end-to-end test that list, prompt index, slash,
skill_view, -s preload and cron agree on a same-tier and a cross-tier duplicate.
- test_org_skill_namespace: a personal/org clash now lists the org copy under its
exact loadable path and notes the unloadable root-level personal copy instead
of flagging two rows that both carried the refused bare name.
Co-authored-by: Tal Orbach <tal.orbach@gmail.com>
Co-authored-by: yu-xin-c <2182712990@qq.com>
Scoping a supervised dashboard/serve backend's background processes moved them out of the
backend's service cgroup, so they survived a backend restart, but nothing in dashboard/serve
startup recovers the process checkpoint: they kept running while process list/poll/kill could
not find them, and the next spawn dropped their checkpoint record. Recovering them in serve
would conflict with the gateway owning background processes (#106742). Revert to main's
behaviour for dashboard/serve (workers stay in the backend's cgroup and die with it) and leave
escapee cleanup there to the serve-to-gateway cutover.
Tests: the escapee in the hang test printed before the kill deadline, which ended the reader's
blocked read and let the old close finish in time, so the test passed without the fix. The hang
test's escapee is now silent; late-output rejection has its own test. Removing the reader-close
guard now fails the hang test.
The reader checked session.exited and then appended, so a chunk read before
the kill could pass the check, lose the CPU while kill_process snapshotted the
output and set exited, and then land in the killed session (and be streamed)
after the kill returned.
ProcessSession.append_output_if_running() checks exited and appends under the
same session lock the kill holds for its snapshot, and _ingest_output(...,
unless_exited=True) only scans and streams a chunk it admitted. A chunk is now
either in the kill's receipt or dropped. The pipe reader is unchanged.
Tests: a deterministic regression pauses the reader between reading a chunk
and buffering it, runs the real kill, then resumes, and asserts the chunk is
in neither the receipt, the buffer nor the live stream. The escapee spawn
helper now terminates the PTY child it spawned if its readiness check fails.
Review follow-ups on the deferred PTY close:
- The reader can now outlive a kill while a detached descendant holds the
slave open. It keeps draining, but no longer appends to or streams into the
killed session, so the output stays as the kill reported it.
- The live-reader guard in _release_finished_handles is POSIX-only. pywinpty
reads don't block, so Windows closes the PTY on the finish path as before.
Tests:
- The unscoped kill test has the escapee print after the kill and asserts the
killed session never receives that output.
- New test_start_server_marks_the_process_as_backend_host drives the real
web_server.start_server entry point, so removing the mark_backend_host()
call fails a test.
- The live scope test bounds its escapee to 30 s and terminates the PTY child
it spawned on failure (the reparented escapee is left to its bound).
`systemd-run --scope` expands the command line itself since systemd 254
(`--expand-environment` defaults to yes), so the `$$` in the escapee
command reached the shell as a literal `$` and the pidfile held `$\n`:
`test_scoped_backend_kill_reaps_an_escaped_descendant` failed with
`ValueError: invalid literal for int()` on any host where the live scope
test does not skip (systemd 259 here). Read the pid as a grandchild's
`$PPID` instead, which neither systemd-run nor the shell rewrites.
Review-fix commit on the contributor's branch; no source change.
Only the supervised messaging gateway ran background processes in their own
systemd scope (_is_supervised_gateway_process requires _HERMES_GATEWAY and the
gateway PID file). A dashboard or serve backend run by systemd hosts the same
kind of agent turns, but its background processes shared its cgroup, so a
descendant that setsid()s past the PTY/process-group kill survived it: the kill
reported success while the escapee kept running (and kept the PTY slave open).
web_server.start_server now marks the process as a backend host (process-local,
so terminal children and CLIs that inherit the supervisor env don't count), and
_scope_argv scopes when that host was launched by a service manager. The kill
already stops the scope, which reaps the whole cgroup. The self-kill guards that
also read _is_supervised_gateway_process are unchanged.
kill_process released the PTY master while the PTY reader thread was
still blocked in read(). ptyprocess wraps the master in a BufferedRWPair,
so close() waits on the buffer lock the reader holds, and that read only
ends when every holder of the slave side is gone. A descendant that
setsid()s past the kill keeps it open, so the kill never returned (and on
the TTL prune path the same close runs under ProcessRegistry._lock).
Leave the close to the reader while it is alive: its own teardown
(_finish_reader -> _move_to_finished) releases the PTY once its read ends.
Review and simplify pass on the guest fast-search restore.
- tools/tool_backend_helpers.py: _account_info was split out so two gates
could share one account read. Only managed_nous_tools_enabled is left, so
fold it back into its pre-salvage shape. Keep the branched force_fresh
call, because tests stub the reader with zero-arg lambdas.
- plugins/web/perplexity/provider.py: the managed= parameter on
_managed_gateway only fed the removed refusal helper. search() now calls
resolve_free_search_gateway() directly once it has the route answer. The
module docstring now names guests.
- hermes_cli/nous_account.py: the _error_info docstring described the
removed gate. The tier carried on a failed lookup still picks the "needs
a Nous account" copy over billing or re-login guidance.
- Tests: the case-insensitive tier match and the tier on error snapshots
lost their only coverage when the gate tests went, so cover them at
their consumer, format_nous_portal_entitlement_message. The fast-search
premise now asserts that the guest case really reads as the anonymous
tier. Remove the dead exp_offset parameter.
- Docs: the Perplexity section said there is no anonymous tier, which is
true for direct keys only. It now names the managed guest route.
The free managed Perplexity fast-search route exists for anonymous guest
Portal users. The salvage of #130121 (cee3000c24, via #130660) added a
registered-account gate, fast_search_entitled(), that excluded the
anonymous tier. That defeated the original change: guests fell back to
the keyless ring and never sent a gateway request.
This removes the gate, so resolve_free_search_gateway() decides on
identity alone again, as rewbs wrote it. The guest-disabled, expiry
refresh and dead-guest rules still apply through the token reader. Every
other vendor, and the billed managed-Firecrawl fallback, keep the paid
entitlement gate.
Kept from the salvage: the case-insensitive is_anonymous_tier, the
account tier carried on error snapshots, and the single route walk per
search dispatch. Tests: the anonymous identity is back in the autodetect
and rescue cases. The tests that pinned the guest exclusion are removed.
The terminal tool arms a heartbeat only after its spawn bookkeeping, and a
fast-starting process has already written its first lines by then.
arm_heartbeat reset the output baseline to the current total, so those lines
were in no heartbeat at all: the agent never saw them until completion.
Baseline is the spawn (field default 0). Every produced line now lands in
exactly one heartbeat regardless of how long the spawn->arm gap is.
CI: tests/tools/test_process_heartbeat.py FLAKY on main (run 36033989953),
beats read ["", "", "second\n", ""]. Reproduced locally with a 0.3 s gap
between spawn_local and arm_heartbeat: base loses "first", fixed keeps it.
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
A `GOCSPX-` literal is the client secret of a Google installed-app (desktop/CLI)
OAuth client. Google's OAuth docs state such secrets cannot be kept confidential:
gcloud ships its own in every copy. The generic
`hardcoded_secret` pattern scored it critical, so any plugin that talks to a
Google installed-app client (the catalog's quota plugin) scanned `dangerous` and
could not be installed even with --force. Cap that one shape at high: the
finding stays in the report and the install asks for review (caution); every
other secret-shaped literal keeps its critical severity.
Salvage of #131963 by @mekenthompson onto main after #131946.
The shutdown reaper's SIGKILL pass only checked whether the leader PID was
still alive. Once the leader had exited, a descendant that ignored SIGTERM
kept the process group alive and was never escalated. The pass now also
probes the group with killpg(pgid, 0) and sends SIGKILL through the same
start-time guard as the SIGTERM pass.
The per-spawn environment marker from #131963 is not carried over: it left
helpers launched with a scrubbed environment running on Linux and brought
back the dead-leader refusal on macOS when a start time was recorded.
Gate finding BN21 r1 quality (Lows):
- The race/eager-import snippets put the repo on sys.path three ways; PYTHONPATH and
cwd already do it, so drop the in-snippet sys.path.insert and the REPO_ROOT templating.
- The deadlock explanation was spelled out in the module docstring, again in the
__getattr__ docstring, and a third time in the test module. Cut it to a 2-line
pointer to the test, and give __getattr__ a one-line docstring that describes what
it does (cache the name in globals()).
- Unquote the dict[str, str] / list[str] annotations: requires-python is >=3.11.
No behaviour change. Both kept tests still pass on HEAD and fail with BASE
tools/connectors/__init__.py.
Gate finding BN21 r1 2c (Medium, flaky): the race snippet's own purge() pops
tools.connectors* from sys.modules while sibling threads are mid-import, so
importlib also raises AttributeError ('NoneType' object has no attribute
'__dict__') for tools.connectors.tool. That is a harness artifact, not the
deadlock, yet the old `!= "KeyError"` filter kept it and the fixed tree went
red intermittently (8/160 runs under 8x parallel load).
Filter on the bug class itself. Measured: HEAD 30/30 green serial and 160/160
under the same load; BASE tools/connectors/__init__.py 30/30 red
(_DeadlockError in every run).
Drop the __all__/dir() surface check: it is a change-detector over the export
list and the public names are already exercised by the existing connector
tests (from tools.connectors import ..., monkeypatch of
tools.connectors.connectors_available). The no-eager-submodule test and the
package-vs-submodule race test cover the deadlock invariant.
Co-authored-by: Rostislav Přidal <5829155+rpridal@users.noreply.github.com>
`tools/connectors/__init__.py` imported all five of its submodules eagerly, so a
thread importing the package held the package's module lock while reaching for a
submodule that another thread already held. Python 3.14 aborts one of the two
with:
_frozen_importlib._DeadlockError: deadlock detected by
_ModuleLock('tools.connectors.tool')
which is the failure in the Group Chat hosted-room worker startup path
(`gateway/run_startup.py::_ensure_hosted_room_worker` -> `import tui_gateway.server`
-> `methods_connectors._install_update_hook` -> `from tools.connectors import
operation`) while the tool registry's discovery scan imports
`tools.connectors.tool` from another thread.
Exporting the cross-package surface through a module-level `__getattr__` (PEP 562)
removes the parent->child edge entirely: the package `__init__` now imports no
submodule, so it cannot hold its lock while waiting for one. Attribute access,
`from tools.connectors import x` and `__all__` are unchanged.
Measured on Python 3.14.7 by racing `import tools.connectors` (3 threads) against
`import tools.connectors.tool` (3 threads), 40 iterations each:
| tree | outcomes |
| --- | --- |
| before | 120 `_DeadlockError`, 0 successes |
| after | 0 `_DeadlockError`, 0 threads left alive, `import tools.connectors` loads no submodule |
Regression tests run in subprocesses so they assert on a pristine `sys.modules`
(`tests/tools/test_connectors_import_deadlock.py`).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit 1803f53b1ed34487ff6a60cdbde915726be21338)
A custom_providers entry such as base_url: http://[::1/v1 made urlparse raise
ValueError out of _pick_provider_base_url, so every caller of the custom-provider
list (including cron's bare-custom base_url check) crashed on one malformed
entry. Treat it like the existing no-scheme/no-host case: warn and skip.
A base_url that urlparse rejects (e.g. an unclosed IPv6 bracket) was refused with the
stored-key/origin explanation, which tells the user to configure the endpoint as a custom
provider - advice that cannot help a URL that does not parse. It now says "It is not a valid
URL." while keeping the shared "not allowed" refusal wording; the existing test pins it.
The configured-origin list read custom providers through the private
agent.credential_pool._iter_custom_providers, which reloads config and only wraps
hermes_cli.config.get_compatible_custom_providers. Every entry that view returns is a dict
with a non-empty name and base_url, so the name filter in the private iterator never drops
one; calling rp.get_compatible_custom_providers on the config already loaded here gives the
same base_urls without a second config load or a private import.
A bare-custom base_url that urlparse rejects (an unclosed IPv6 bracket such as
http://[::1/v1) raised ValueError from the host-gated key lookup, so cronjob
create/update crashed instead of returning a refusal. Check the origin first
and refuse with the usual "not allowed for provider 'custom'" message.
Also read the raw model.base_url from load_config() rather than
_get_model_config(), which can network-probe a local model during a pure
validation call; drop the redundant _DIRECT_API_BASE_URLS (PROVIDER_REGISTRY's
openai-api already carries https://api.openai.com/v1); import get_secret_str
from its home in agent.secret_scope; and build the three refusal strings
through one _base_url_refused helper.
The bare 'custom' arm of _validate_cron_base_url returned early as pure BYOK, but the resolver attaches the host-gated env keys (OPENAI, OPENROUTER, OLLAMA, <VENDOR>_API_KEY) to such a job by hostname. A job could pair one with another scheme, port or subdomain. When a stored key would go with the base_url, require the origin of a configured endpoint or a registry provider; a base_url no stored key matches stays unrestricted.
(cherry picked from commit bb7c37c8d7b88077acd45de74f1efb1501adca1a)
_validate_cron_base_url accepted a job base_url whose hostname matched
the provider's endpoint or was a subdomain of it, so a cronjob tool call
could pair the stored key with plain HTTP, another port, or a subdomain.
Compare the full origin (scheme, host, effective port) via
utils.base_url_origin, the rule the other secret-to-new-URL checks use.
The scheduler backstop calls the same validator, so every surface that
stores a job is checked before the key is sent.
(cherry picked from commit a281efeedac02f87e168743b389b78f6feaf5472)
#131910 refused to signal a process group whenever its leader's start time
no longer matched the spawn-time baseline, and a REAPED leader reads as None,
so "leader gone" was treated as "PGID recycled". POSIX never reuses a PGID
while any member of the group lives, so a gone leader still leaves the group
ours, and its reparented grandchildren (an MCP server's pipe holder, a
terminal command's daemon) leaked past host shutdown.
Only a LIVE leader with a different start time (drift-tolerant since
a5e7df27) proves recycling now; the recycled-PID skip is unchanged.
a5e7df27 kept "gone leader refuses" and asserted it in
test_pgid_starttime_drift.py; that assertion now expects the kill. Same fix in the terminal
environment's twin guard (tools/environments/local.py::_leader_is_ours).
Red on main: tests/e2e/core/parity/test_mcp_lifecycle.py::
test_mcp_server_death_mid_call_fails_fast_and_the_turn_completes and
test_entrypoint_parity[*] zero_mcp_orphans (e2e lane on eaecc99c).
_real_profile_cdp acquires the process-global launch lock with a 30s bound
and fails closed with an actionable error when a previous (possibly
abandoned) resolver worker still holds it, instead of parking every later
real-profile call until the 420s outer tool deadline (#96731).
Conflict notes: rebased by hand from #96752 onto the extracted
browser_tool_real_profile module — the bounded acquire/finally-release
moves into _real_profile_cdp there; the regression test is the
synchronized version from the same PR.
Co-authored-by: fangliquanflq <280272527+fangliquanflq@users.noreply.github.com>
main replaced the linux_only/macos_only/windows_only marks with
platforms(...); the old marks now abort the whole test run with a
rewrite error. Follow up the rebase by switching the two capture-file
cleanup tests to platforms("linux"), matching test_spawn_site_child_env.
The timeout-path cleanup assertion failed on Windows: kill() returns
before Windows tears the killed CLI's handles down, so the best-effort
unlink hits a still-open file — the same Windows property as the
grandchild path, reached through the timeout branch instead.
Per AGENTS.md, express the platform split with @pytest.mark.linux_only
rather than inline sys.platform checks, so the cleanup contract stays
visible in both CI lanes (list_os_marked_tests.py selects the OS lanes
by the marker name).
The real-profile capture unlink is best-effort by design
(_unlink_command_output_files swallows OSError). On Windows the resident
daemon grandchild keeps its inherited handles open, so the temp files
survive the call until the daemon exits — the cleanup assertion must not
require unlink-while-open semantics there (#96731 follow-up, Windows
verification report).
Rebase of the #96731 fix onto the post-split layout: the call sites moved
from tools/browser_tool.py to tools/browser_tool_real_profile.py
(_agent_browser_session_cmd and the attach open).
The CLI forks a resident daemon that inherits its stdio and outlives the
CLI. With pipe capture the CLI can exit while its output never reaches
EOF: POSIX burns the whole 15s timeout draining pipes on every call, and
Windows' TimeoutExpired cleanup re-drains unbounded, blocking past the
outer tool deadline (#96731). Reuse _popen_agent_browser (the pattern
_run_browser_command already established) instead of adding a second
mechanism: wait for the CLI alone; a grandchild holding inherited file
handles is harmless.
TimeoutExpired semantics are preserved for both callers (session_cmd ->
None + debug log, attach -> the took-too-long message), and the daemon
env (reaper-visible socket dir, owner_pid claim, no idle timeout) is now
built once per capture inside the shared helper.
The ladder now ends by observing frontmost after the settle and re-assert:
the tool output says whether the opened document actually ended up in front,
and when another application holds focus it names that application — a focus
race can no longer read as plain success (#95261 requirement 2). The
not-front branch gets one extra activate+0.4s so the report reads the state
after a last raise attempt, not before it.
The side-effect screen on #131931 caught _parent_live_home's double
getattr turning any parent without a real _session_db.db_path (MagicMock
test doubles, duck-typed fakes) into a truthy Mock 'home', sending
transcripts to str(<MagicMock>) paths — red 3/3 in
tests/tools/test_delegation_recovery_diagnostics.py, green on main.
Validate the resolved db_path against concrete str/Path before trusting
its .parent; anything else falls back to None (ambient resolve) with a
debug log. Concrete types on purpose, not the os.PathLike protocol:
MagicMock registers __fspath__ and so IS PathLike, which is exactly how
the garbage pin slipped through a protocol check.
Also documents the answer to the positional-.parent invariant question
from Enough1122's review on source PR #92001: state.db's direct-under-home
placement is load-bearing across the codebase, asserted by a new test.
The cherry-pick of #92001's test corpus onto main's diverged
test_delegation_live_log.py dropped the imports its new tests rely on
(os, threading, time, pathlib.Path, delegation_live_log as dll,
prune_stale_live_dirs, update_manifest_statuses) — NameError on the new
profile-home tests. Restores the full import block from the PR's branch.
Review follow-ups on the profile-home pinning (#91996):
- extract _parent_live_home() and log a WARNING when the parent exposes
no _session_db: the ambient fallback is exactly the #91996 failure
mode, so the skipped pin must be diagnosable instead of silent
- prune_stale_live_dirs now accepts the root and create_live_transcripts
passes the same resolved root it writes to — previously retention
always swept the ambient root, so stale dirs under a pinned home were
never cleaned by its own dispatches
- module docstring documents the per-root retention scope
- new tests: no-_session_db parent warns and returns None, db-path
parent pins, and a pinned-home dispatch prunes its own stale dir
while leaving the ambient root alone
delegate_task live transcripts resolved their root through ambient
get_hermes_dir(): _HERMES_HOME_OVERRIDE is a ContextVar that raw
threading.Thread boundaries drop, and process-wide HERMES_HOME is
unstable under concurrent multi-profile workers, so transcripts (and
the manifest) could land under a different profile than the session
that initiated the delegation (#91996).
Thread an explicit home through the live-log seam instead, resolved
from stable parent-owned state at delegate_task entry — the parent's
per-profile SessionDB path (state.db sits directly under the home).
None falls back to today's ambient resolve.
`-mindepth 1` in the find-lane base command broke the #116270
symlinked-root contract: with `-H` the followed operand IS depth 0,
so the root itself was excluded — the only match when the root is a
symlink to a file. `search_files(target='files', path=<symlink-to-file>)`
answered total_count=0 on the find lane again, exactly the silent-zero
the invariant test pins (red on the previous head).
Drop `-mindepth 1` and exclude the search root inside the `-type d`
arm by path identity instead (`! -path <root>`, glob metacharacters in
the root escaped), so:
- a symlink-to-file root keeps its depth-0 `-type f` match (#116270)
- a directory root still never lists itself (#54347 intent preserved)
- a same-named descendant directory still matches