Sync current Nous Hermes main into Ace patches - #41
Merged
Conversation
…f discarding When the per-session pending transcript queue hits _MAX_PENDING_PER_SESSION (200) while the session DB is broken, the gateway previously popped the oldest message and discarded it permanently — silent user data loss during live operation (NousResearch#78182). The on-disk pending spool only ran at shutdown via flush_pending_to_file. Extend that existing spool machinery for runtime drops: - gateway/shutdown_flush.py: add spool_dropped_transcript_message() and drain_transcript_spool(), reusing _get_flush_dir/_write_payload (same atomic-JSON pending_messages/ spool format). recover_pending_to_db() now also replays transcript_cap_drop payloads left over across restarts. - gateway/session.py: on cap eviction, spool the dropped message and log a WARNING that includes the spool path; if spooling fails, degrade to the previous drop-and-warn behavior. On the next fully successful transcript flush for that session, drain and replay spooled messages in drop order; replay failures keep the spool files for the next attempt. - tests/gateway/test_pending_queue_spool.py: drop→spool→drain roundtrip, per-session drain isolation, spool-failure degradation, replay-failure retention, and spool primitive ordering/reason filtering. No new config; extends existing flush_pending_to_file infrastructure per AGENTS.md guidance. Refs NousResearch#82616, NousResearch#78182
…pace mounts Two bugs reported on the docker terminal backend (desktop app, sandboxed profiles with container_persistent: false): 1. A NEW chat's container inherited the PREVIOUS session's workspace, bind-mounted rw at /workspace, because the mount source was the process-global TERMINAL_CWD env var (written by the workspace picker, outliving its session) and all sessions shared one 'default' container. 2. Every command failed with exit 126 because the desktop gateway recorded the HOST launch directory as the session cwd, and each command was prefixed with 'cd /Users/<user>/...' inside the container. Fixes (class-wide, single owners): - container_persistent: false + docker now keys containers PER SESSION: fresh container per chat, removed at session close/idle. delegate_task children share the parent's container via an explicit alias registry. container_persistent: true keeps the documented ONE-long-lived-container contract unchanged. - _resolve_task_host_cwd() is the single owner of the cwd->/workspace mount policy across all four env-creation sites; under isolation it refuses process-global cwd sources and mounts only the session's own attached workspace (tui_gateway now tags overrides with cwd_source). - _resolve_command_cwd() gains the same host-path guard the env-creation sites already had (NousResearch#50636/NousResearch#54447 sibling site): a recorded host cwd is discarded on container backends instead of cd-ing every command into a nonexistent path. E2E-tested against real Docker: distinct containers per session, no stale mount in a fresh session, no exit 126 from host cwd records, containers removed at session teardown.
…demod optional skill Vendors the ast-grep skill from oh-my-openagent's shared-skills bundle (upstream code-yeongyu/ast-grep-skill @ 3148c69, MIT) into optional-skills/software-development/ast-grep with Hermes conventions: - SKILL.md rewritten with Hermes frontmatter (platforms, tags, category) and Hermes tool routing (search_files instead of raw rg, terminal for sg invocations, patch-vs-ast-grep division of labor) - scripts/ast_grep_helper.py: fixed argparse so trailing paths after an optional flag parse (parse_known_args + fold extras into paths); upstream errored 'unrecognized arguments: .' on the documented 'search PATTERN --lang js .' form - 7 reference docs, install.sh/install.ps1 (pinned-release GitHub fallback), smoke tests carried over verbatim E2E validated: install (github method, ast-grep 0.45.0), doctor, search, validate (regex rejection), replace dry-run + apply two-pass, scan with YAML rule, tests/smoke.sh 15/15 pass.
…mand The gateway sent only an 80-char preview (context) for a tool call. The desktop rebuilds the expanded tool row from the args of the part. When the args were absent, the row showed the preview, and long commands ended in '...' after the user expanded them. Two paths had this fault: - tool.start: the payload had no args until tool.complete, so the expanded row was truncated while the tool ran. Now tool.start ships the args, the same as tool.complete already does. - _history_to_messages: the projection read the full arguments, then discarded them. Hydration from this projection (watch windows, compress, branch, seeded create) kept only the preview, so the truncation was permanent. Now tool rows carry the args. This projection is the display view of the transcript — each renderer decides what to paint, and the preview stays for collapsed titles. The DB rows do not change: the args already persist in tool_calls.
test_authoring_standards.py::test_description_hardline red on main since 461c493 landed with a 383-char description. The trimmed detail is all preserved in the SKILL.md body (When-to-use, decision tree, search_files comparison). Unbreaks every open PR's slice 4.
…sessions too Same defect as the compression-rotation fix in the prior commit, found during a full-audit of every create_session() call site per the repo's 'fix the whole bug class, sibling call paths included' contribution guidance. _handle_branch_command() (gateway/slash_commands.py) creates the branched child session via create_session() without chat_id/chat_type/thread_id. The routing columns are only backfilled later, when switch_session() runs at the end of the function and calls _record_gateway_session_peer(). In between, the function copies the parent's conversation history to the new session_id one message at a time, with each append_message() call independently try/excepted (best-effort) — a crash/kill anywhere in that window leaves the branched session permanently unroutable, same failure mode as the compression bug: NULL chat_id/thread_id can never be found by find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR guard (which requires the row's chat_id/thread_id to match the caller's). Fix: forward source.chat_id/chat_type/thread_id at create_session() time, mirroring the existing correct pattern already used by /title's auto-create path a few hundred lines up in the same file (which has an explicit IDOR-scoping comment justifying it). Tests: tests/gateway/test_branch_routing_columns.py drives the real _handle_branch_command against a real SessionStore + SessionDB (SQLite in tmp_path, no DB/session-store mocks). Patches switch_session to simulate a crash landing before it runs (the exact gap the routing columns need to survive), then asserts the branched child's chat_id/chat_type/thread_id are already correct in state.db at that point. RED verified against unpatched code (assert None == '170829464'), GREEN after the fix. Regression: 102/102 across the new test + pre-existing /branch, session boundary, compression rotation, DM thread seeding, session API, and resume-command suites. Broader tests/gateway/ -k "branch or session_api or resume or topic_mode or session_boundary" sweep: 255/255 passed, 1 (unrelated) skip.
…reates The sweeper flagged two gaps in the routing-columns fix: 1. /branch create_session() omitted user_id and session_key — the fallback lookup path (find_latest_gateway_session_for_peer) requires user_id to match the complete peer tuple when session_key lookup fails, and /resume IDOR guards reject sessions without matching user_id. 2. Compression-rotation create_session() omitted agent._user_id — same problem: rotated child cannot satisfy persisted /resume ownership proof before the later gateway backfill. Forward user_id and session_key at CREATE time in both call sites so the child row is immediately fully routable with zero backfill gap. Extended tests: compression rotation asserts user_id is carried (and None for CLI sessions). Branch routing asserts both user_id and session_key on the child row before switch_session runs.
…ons too Complete the /branch routing-identity fix (salvaged from PR NousResearch#62278 by @jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id, forward origin_json and display_name at create_session() time, matching the reset-path db_create_kwargs pattern (NousResearch#82633) so the branch row is born with full identity — no backfill gap for state.db consumers (mcp_serve, mirror, channel directory) if a crash lands before switch_session(). The obsolete compression-rotation half of NousResearch#62278 was dropped: rotation now goes exclusively through publish_compression_child, which already copies all identity columns in-transaction.
…68539) find_latest_gateway_session_for_peer filtered non-recoverable rows out of candidacy BEFORE ordering, so recovery could search behind a /new reset boundary and resurrect an older still-open row for the same peer — silently restoring the exact context the user reset. Rebuilt against the NousResearch#82633 finder (has-messages ranking + COALESCE(last_activity_at, started_at) recency): the fence is expressed as a NOT EXISTS guard inside both the exact-key and peer-fallback queries — a candidate is rejected when an intentional boundary row (session_reset / session_switch / idle / daily / suspended / resume_pending_expired) for the same peer ended after the candidate's last activity. If the conversation's most recent event is an intentional reset, recovery returns nothing rather than reaching behind it. Cherry-picked from NousResearch#68617 and adapted to the rewritten finder. (cherry picked from commit bb2c562)
Both session recovery paths (the startup stale-entry repoint and the lazy in-message recovery) rebuilt the routing entry with updated_at=now and never consulted _should_reset, so an opt-in idle/daily session_reset policy was silently dead across any gateway restart: a recovered session always looked freshly active, and since every subsequent message bumps updated_at, a session recovered stale could then never age out at all. Fix in three parts: - _create_entry_from_recovered_row derives updated_at from the durable last_activity_at the finder already returns on the row (no extra DB round-trip; the original PR added SessionDB.get_last_activity for this, unnecessary post-NousResearch#82633), falling back to created_at. An invalid or missing started_at now maps to epoch 0 instead of now — an invalid durable timestamp must look old, never freshly active. reset_had_activity is set from the row's durable activity/message signals so the continuity hint stays accurate. - _recover_session_from_db evaluates _should_reset on the rebuilt entry: an overdue session is durably promoted to a reset boundary (promote_to_session_reset, falling back to end_session) and the stale mapping is dropped instead of repointed. - _query_recoverable_session no longer reopens the row; the get_or_create_session recovery phase evaluates _should_reset first and either feeds the normal auto-reset create path (reset notice, prev_session_id continuity, durable promotion) or reopens and publishes the recovered entry exactly as before. Behavior is unchanged under the default session_reset mode "none": _should_reset returns None there, so recovery still resumes every recoverable row — only users who opted into idle/daily resets see the policy actually applied across restarts. Cherry-picked from NousResearch#78618 and adapted to the NousResearch#82633 finder. (cherry picked from commit 31c71f7)
…mes (NousResearch#74411) Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers and returned ONLY the python interpreter path, discarding the script. This made probeHermesVersion() run '<python> --version', which always printed 'Python x.y.z' instead of the Hermes version. And remoteSupportsSshOwnership() ran '<python> serve --help' which failed entirely because no 'serve' module exists in the python stdlib. Problem 2: When the user set remoteHermesPath (an explicit override), resolveLauncher() resolved it to the python interpreter, replacing the user's specified path. The override was effectively ignored for version checking and capability probing. Fix: resolveLauncher now returns the candidate path directly. The hermes binary or wrapper script is already executable and handles argument forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own. No additional remote SSH round-trip or python script needed.
…hrough in locateHermes Replaces the canonicalization test (which pinned the behavior NousResearch#74425 removes) with wrapper-preservation coverage for auto-detection and an explicit remoteHermesPath, both asserting no python3 -c parser call is issued. Verified both fail against the pre-fix implementation.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ght theme streaming code blocks in the light theme render near-white text on the white code card until shiki's highlight lands, then snap to normal token colors. the pale text is @tailwindcss/typography's pre foreground: its prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on a gray-800 bg). we strip the bg for our own code card but the near-white foreground survives on the container. shiki's opaque per-token span colors normally hide it — it shows through wherever text renders without spans: the streaming delay window, the lazy-chunk suspense fallback, and over-budget blocks that never highlight. traced on the live renderer: computed color on the wrapper of mid-stream code was oklch(0.928 0.006 264.531) (gray-200), supplied by the .prose :where(pre) rule. fix: prose-pre:text-foreground on the markdown container, so every fenced path inherits the transcript foreground instead. the utility layer is emitted after typography's base rule in the built css, so the override wins by order at equal specificity.
many tests patched sys.platform or a module's _IS_WINDOWS flag, then ran on linux ci. the patch selects the branch under test, but the host does not have the behavior the branch exists for. the test proves the patch, not the platform. some gated assertions never ran on any host. this commit adds three markers: linux_only, macos_only, windows_only. a conftest hook skips a marked test on the other hosts, with a clear reason. no test fakes a host now. two documented fakes remain (android/termux, freebsd) because no ci runner exists for them. each fake site got one of four treatments: - gate it: the real host supplies the platform; mocks cover real dependencies only, never host identity - patch the module's own probe when the subject is the probe's consumer - assert against the real host when the fake stood in for any non-x host - delete the patch when it set the value the host already has bare skipif(sys.platform != ...) guards became markers too. the lane model skips these on linux and never imports them on windows, so they ran on no host. platform parametrize tables are now one marked test per os. running on real hosts found real errors: a chrome-sandbox failure in test_gui_command that main hides, and two windows failures fixed here. the agents.md testing section now documents the policy.
the markers from the previous commit skip off-host. without a host to run them on, every marked test is a silent skip. this commit adds the hosts. - tests-os.yml runs -m macos_only on macos-latest and -m windows_only on windows-latest. ci.yml requires both lanes in all-checks-pass. - a lane fails on pytest exit code 5 (zero tests selected). a renamed marker cannot produce a green job that ran nothing. - each lane repeats 'not integration' because a command-line -m replaces the addopts filter. - scripts/ci/list_os_marked_tests.py selects which files each lane imports. -m filters after collection, and collection imports every module. without this helper, one unrelated ImportError on the foreign host fails a job whose own tests passed. the helper exits non-zero when a marker matches no file, and writes bytes with explicit lf so windows crlf translation cannot corrupt the bash file list. it has its own tests in tests/ci/. - the local runner now reports the skipped count and prints a note: macos_only/windows_only tests were skipped on this host, and this ci lane runs them. a green local run on linux no longer reads as coverage of the other hosts. - the runner default job count is now #cpu, not #cpu*2.
`shell: bash` runs the step with -e injected, and `set -uo pipefail` does not clear it. A non-zero pytest exit killed the script before `status=$?`, so the -eq 5 branch and its ::error message never ran. The job still failed red, but the diagnostic that names the cause never printed.
Six test files still selected an OS branch with a faked host. Each one now carries the marker for the host that owns the branch, or derives the expectation from the real host: - test_clipboard: macos_only on the has_clipboard_image dispatch. The fake picked the branch, but _macos_has_image needs osascript. - test_claw: windows_only on the tasklist/powershell scan, with return_value in place of a side_effect list that pinned the call count. - test_linux_desktop_entry: the parametrize over "darwin"/"win32" becomes one marked test per host. A fake left POSIX paths and a POSIX XDG layout. - test_graphical_browser_detection: linux_only on the display-server arm. The $BROWSER check runs before the platform branch, so its test stays unmarked. - test_auth_nous_provider: the fixture pinned linux so the macOS certifi fallback could not change the result. The assertion now reads the host, so the macOS lane covers the fallback too. - test_tts_macos_output and test_voice_mode: the afplay policy exists because CoreAudio init raises a TCC prompt, which no Linux runner reproduces. tests/conftest.py refuses collection when one test carries two OS markers. Each marker skips on all but one host, so two of them make a test that runs nowhere while every lane reports green. tests/test_os_marker_gating.py pins that behavior. The docstring on TestConfirmDestructiveSlash said the Windows job runs it. The class has no marker, so -m windows_only deselects it.
The live comment poller inferred completion from the job list. An empty job list looks the same as a finished run: GitHub has not spawned the jobs yet, so nothing is pending, and the poller posted a final "all good!" comment and exited. The run status is now the authoritative signal. collect_run_jobs() returns whether the CI run and every watched sibling run report status=completed, and the loop exits only when no job is pending AND all runs are complete. While a run is still queued or in progress with no visible jobs, the comment shows "waiting for jobs to start" instead of a final banner.
…esearch#81641) A pure-text assistant turn (finish_reason=stop) had no durable write of its own. Its answer reached the user through the streaming / interim display path, which is display-only and never touches state.db, and the first durable write was finalize_turn's _persist_session — after the loop exits and behind post-turn work that can include micro-compaction's aux-LLM call. Anything that ended the process or tore the session down inside that window lost a reply the user had already been shown. On a remote (non-loopback) backend the window is easy to hit: WS 1006 closures drive ws_orphan_reap teardown, and affected sessions ended up with user rows and zero assistant rows in state.db. The neighbouring exits of the same loop already close this gap: * the tool-call exit flushes the assistant(tool_calls) block before handing control to _execute_tool_calls (NousResearch#49045) * the verify-on-stop and pre_verify exits flush final_msg before appending their nudge (NousResearch#65919 §7) Apply that same idiom to the ordinary text exit rather than adding a new persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the later _persist_session a no-op for this row, so no duplicate rows and no extra write — the same write, just earlier. Unlike the tool-call exit, a failed flush must not abort the turn: no side effect runs after this point and the answer is already produced, so the failure is logged and _persist_session remains the retry. Co-Authored-By: Claude Opus 5 <[email protected]>
- warn (not debug) on final text-turn flush failure: a failure here reopens the exact NousResearch#81641 data-loss window with _persist_session as the only remaining retry, unlike the verify siblings which retry in-loop; include session id for triage - trim the flush-site comment to sibling proportion, pointing to the test module for the full incident narrative - test: assert _persist_session presence before indexing, so a wiring change fails with a clean assertion instead of ValueError from max()
…ip trailing stubs
Trim verbose comments in conversation_loop.py and run_agent.py to 2 lines each. Fix the same bug class in the compression summary path at chat_completion_helpers.py: remove _thinking_prefill from the explicit pop tuple and move the generic underscore-key sweep to after _drop_thinking_only_and_merge_users, so the drop pass can recognize prefill stubs there too.
_normalize_bundle_path rejected absolute paths, .. traversal, and a bare
drive-letter prefix, but permitted a colon inside a later path component.
On NTFS a bundle member named scripts/helper.py:payload writes a hidden
Alternate Data Stream into the visible file scripts/helper.py. The skill
scanner walks with rglob('*'), which does not enumerate streams, so both
operator review and the guard scanner miss the executable bytes.
Reject a colon in any component (the whole class, not just the trailing
one). This subsumes the previous bare drive-letter check, which is folded
into the single colon guard. '/' is the only legal separator once
normalized, so no portable bundle path needs a colon.
Adds an OS-independent quarantine_bundle regression plus a direct
normalizer unit test covering leading/mid/trailing-component colons,
bare/qualified drive letters, and the empty stream name.
Reported-by: JoaoMarcos44 <[email protected]>
The TUI resolves the CLI via process.env.HERMES_BIN (externalCli.ts) and falls back to a bare 'hermes', which is not on PATH for nix run / nix profile installs that only expose the wrapped binaries. Set a --set-default so the wrapper advertises its own hermes while an explicit operator override (documented in kanban_db.py) still wins.
…ows (NousResearch#84364) * fix: warn agents off driving interactive console TUIs via pty on Windows Driving 'gh auth login' (and other survey-style console TUIs) through a pty background process on Windows silently hangs: these programs read Win32 console key events via ReadConsoleInput, not the stdin byte stream, so Enter keypresses submitted over process stdin never register. The agent-visible symptom is a prompt frozen at 'Press Enter to open browser...' while the user sees nothing, and a turn interrupt then kills the process, invalidating any device code the user already entered on github.com. Two guidance fixes, both proven in a live session on Windows 10: - agent/prompt_builder.py: extend _WINDOWS_BASH_SHELL_HINT to steer agents toward non-interactive paths (flags, --with-token, config files, curl-polled OAuth device flow) instead of answering console prompts programmatically. - skills/github/github-auth: document the pitfall and add the manual OAuth device-flow procedure (curl against gh's public client_id, poll for the token, finish with 'gh auth login --with-token'), which succeeded first try after two interactive attempts hung. * fix: send CRLF for Enter on Windows PTY submit; correct root cause in guidance Review feedback (helix4u) was right on both counts: 1. Root cause correction. gh's 'Press Enter to open browser' prompt is waitForEnter -> bufio.Scanner reading stdin, not a survey/console-API prompt. The real bug is ours: submit_stdin appended a bare \n, and through pywinpty/ConPTY a lone \n is not delivered as a line terminator, so the child's blocking line read never returns. Verified empirically against pywinpty 2.0.15 with a readline() child: \n -> hang, \r -> line delivered, \r\n -> line delivered. Fix: submit_stdin now appends \r\n for Windows PTY sessions (POSIX PTYs and Popen pipes keep \n). Windows-only regression tests cover the PTY and pipe branches. 2. Prompt hint rewritten: instead of claiming Windows console TUIs cannot be driven, it now says to use process(submit) rather than raw writes with bare \n, and to prefer non-interactive paths when a CLI offers one. 3. Skill device flow rewritten as an executable script: parses the device-code response, polls per the returned interval, handles authorization_pending / slow_down (+5s per GitHub docs) / expired_token / access_denied / unexpected responses, pipes the token straight into gh without echoing it, and drops the undocumented workflow scope (repo,read:org,gist is the documented minimum for gh auth login --with-token). The pitfall note is narrowed to the reproduced condition.
…ousResearch#84383) * fix: make verify_on_stop opt-in everywhere (default False, not auto) The verify-on-stop nudge was already judged more noise than signal: the v31 migration flips existing installs off, the v32 migration catches the baked-in literal-true population, and the docs tell users to 'treat off as the effective default and opt in explicitly'. But DEFAULT_CONFIG still shipped the "auto" sentinel, so exactly one population kept getting the nudges: fresh installs (and any config missing the key), where "auto" resolves ON for CLI/TUI/desktop surfaces. Live symptom: repeated '[System: You edited code ... run verification]' interruptions the user never asked for and had to hunt down in source to disable. - DEFAULT_CONFIG: agent.verify_on_stop "auto" -> False (opt-in). - verify_on_stop_enabled(): missing/unrecognized value now falls back OFF instead of surface-aware; explicit "auto" still selects the legacy surface-aware behavior, explicit bools unchanged, and the HERMES_VERIFY_ON_STOP env override is untouched. - No migration needed: v31/v32 already normalized existing installs, and this only changes the merged default for configs without the key. - Docs updated; default-path E2E test now asserts OFF, plus a new missing-value regression test. Also added the standard win32 skip marker to the symlink-based temp-dir test (pre-existing Windows failure, same class as tests/cron/test_cron_script.py). * test: update config goldens — verify_on_stop=False is now stripped as default With the DEFAULT_CONFIG flip to False, the migration-write invariant (_persist_migration / save_config strip_defaults) no longer materialises verify_on_stop: false to disk unless the user explicitly set the key: - V20 floor fixture (agent: {} on disk): v31's write is stripped — agent stays {} and load_config() supplies False at read time. - V12 floor fixture (explicit verify_on_stop: true on disk): the key is a user-set path, so the v32 flip stays materialised as false. - Partial-write and _persist_migration regressions now assert the key is absent from disk and (for the merge case) that the merged view still resolves False. Behavior verified with a one-shot migrate_config run against both fixture shapes.
…drift (NousResearch#84378) * fix: Windows path handling in search_files rg calls and patch escape drift Two related Windows failures from a live session (Windows 10, git-bash terminal backend, winget-installed native ripgrep): 1. search_files was unusable on drive-letter paths. _escape_shell_arg rewrites C:\... to the MSYS form /c/... so bash builtins resolve it, but rg is a native Windows binary and Hermes disables MSYS argument conversion for its bash subprocesses (MSYS_NO_PATHCONV=1 / MSYS2_ARG_CONV_EXCL=*, see _apply_windows_msys_bash_env_defaults) — so nothing ever translated /c/... back and every search failed with 'The system cannot find the path specified. (os error 3)'. Fix: new _escape_native_tool_arg emits the forward-slash NATIVE form (C:/Users/...), which native binaries accept, bash passes through untouched, and MSYS builds also handle. Applied to the six rg call sites (content search, --files search x2, zero-match probe x3); the grep fallback keeps the MSYS form since MSYS grep wants it. 2. The patch tool silently doubled backslash runs when tool-call args arrived JSON-escaped one extra time (file had \ where old_string had \\). Similarity strategies (context_aware) matched the region anyway and wrote new_string verbatim, corrupting every backslash run (reproduced: 6 backslashes on the line became 12). _detect_escape_drift now also blocks when every backslash run in old_string is exactly twice its counterpart in the matched region and new_string repeats the doubling — with guardrails so exact matches, intentional backslash edits, model-corrected new_strings, and single weak-signal runs all still apply. Blocking returns the standard escape-drift guidance so the model re-reads and retries with correct counts. Tests: TestEscapeNativeToolArg (5 cases, including an end-to-end _search_with_rg command capture) and TestBackslashDoublingDrift (6 cases). The 8 pre-existing failures in tests/tools/test_file_operations.py on a Windows host (umask/symlink POSIX assumptions) are identical on unmodified main and unrelated. * fix: shell linters get native Windows paths too (node C:\c\... double-prefix) Same class as the rg fix: LINTERS commands (python -m py_compile, node --check, npx tsc, go vet, rustfmt) invoke native Windows binaries, but _check_lint interpolated the MSYS /c/... form. node resolves that as C:\c\Users\... (double-prefixed), so on Windows hosts every .js write reported a phantom ENOENT lint failure that could mask real syntax errors (issue NousResearch#84303). Route the {file} arg through _escape_native_tool_arg like the rg call sites. Regression test asserts node --check receives 'C:/...' and never '/c/...'.
_strip_quotes documented that it stripped heredoc bodies but only handled single/double/backtick quotes. As a result _foreground_background_guidance scanned heredoc body text for a backgrounding '&' and wrongly rejected valid foreground commands whose heredoc body contained a spaced ampersand — e.g. AppleScript string concat (osascript <<'EOF' ... "a" & b ... EOF), Python bitwise-and, or literal UI text like 'FaceTime & Privacy'. Add a _strip_heredocs pass (runs before quote-stripping, since a heredoc delimiter may itself be quoted) covering <<EOF, <<-EOF, <<'EOF', <<"EOF". The same-line tail after the opener (redirects/args) is preserved and the opener token is blanked so a real backgrounding '&' after the heredoc is still detected. Adds tests/tools/test_terminal_heredoc_background_guard.py.
The previous commit's regex-based stripper removed EVERY heredoc body,
which review flagged as bypassable: a fake '<<EOF' marker inside a
comment or quoted string enters the unterminated path and swallows a
later REAL background operator, and unquoted ('cat <<EOF' — expansion
runs) or shell-consumed ('bash <<'EOF'' — body IS shell) bodies are
executable content that must stay visible to the guard.
Replace it with tools/shell_heredoc.strip_inert_heredoc_bodies(), a
conservative shell-state scanner: a body is masked ONLY when every
delimiter on the opener is quoted (no expansion), every heredoc is
terminated by an exact delimiter line, the opener composes a single
command (no list/pipeline operators, no nested $()/backtick/process-
substitution scope), and the consumer is an allowlisted non-shell
interpreter (python/osascript/cat). Anything ambiguous is returned
unchanged — a false positive on exotic syntax is acceptable; hiding a
real background operator is not. Masked bodies become newlines so line
structure is preserved for MULTILINE regexes.
The helper is a standalone stdlib-only module (precedent:
tools/ansi_strip.py) because the same heredoc-as-data false-positive
class exists in the blocked-command regex checks (NousResearch#83104) and the
gateway lifecycle guard (NousResearch#81721/NousResearch#79835, cron/lifecycle_guard.py) —
which must not import the terminal-tool module graph.
Adapted from Wolfram Ravenwolf's security-hardened rework of NousResearch#63788
(69c7663); test scenarios for the
bypass cases derive from his suite.
Co-authored-by: Wolfram Ravenwolf <[email protected]>
Efficiency review (measured with timeit probes) found two unbounded costs on adversarial inputs: - The masked-range rebuild copied the whole string once per range (O(n*k)): 50k tiny heredocs took 1.7s. Replaced with a single-pass segment join over the (sorted, non-overlapping) ranges: 152ms, and newlines are now counted on the original command instead of re-slicing. - After the last '<<' occurrence no opener can start, but the scanner still walked the remaining text per-char: one heredoc followed by a 1MB tail cost ~150ms. An rfind bound breaks out of the unit loop once the scan passes it: 0.3ms. Typical commands are unaffected (the '<<' fast path already returns first). 30/30 guard tests pass; mutation check re-run on the final stack (no-op mutation -> 11 tests fail, restore -> green).
MiniMax-M3 ships server-side automatic prefix caching on the
Anthropic-compatible endpoint (content-keyed, no marker needed —
see platform.minimax.io/docs/api-reference/text-prompt-caching).
cache_control markers are NOT on its explicit-cache support list
(which covers only M2.7/M2.5/M2.1/M2).
Emitting markers on M3:
- wasted serialization overhead
- risked perturbing the server-side prefix hash
- gave users a false sense of explicit-cache savings (the
cache_read_input_tokens field carries a +128 constant floor
and cache_creation_input_tokens is always 0 for M3)
Also add an opt-in debug=True parameter to normalize_usage() that
emits a debug-level log line carrying the observable cache fields.
This is the only reliable cache signal for M3 — off by default,
debug-level, scoped to the anthropic_messages wire, so production
callers see no impact.
Pin both changes with 8 new tests:
- 4 M3 tests covering provider, host, and custom-provider paths
- 1 regression guard ensuring M2.x caching is unaffected
- 3 observability tests (off-by-default, on-with-M3, on-with-Claude)
Verified end-to-end against api.minimaxi.com/anthropic/v1/messages
with MiniMax-M3[1m]: identical system prompt hit-rate with and
without markers; cache_read field is unreliable (128 floor),
input_tokens drop (8467 -> 1) is the real hit signal.
…ervability Follow-up fixes on top of the salvaged NousResearch#83678 commit: 1. Hoist the MiniMax-M3 marker exclusion ABOVE the native-Anthropic early return. provider="anthropic" pointed at a MiniMax /anthropic proxy is a supported override (_anthropic_base_url_override_ok), and the is_native_anthropic branch matched on provider alone — returning (True, True) before the M3 exclusion was reached. Two regression tests pin the proxy route (M3 off, M2.7 still on). 2. Reuse the existing _model_name_suggests_minimax_m3() helper from agent/model_metadata.py instead of a second inline substring copy. 3. Drop the debug kwarg on normalize_usage() — it had zero production callers and duplicated standard logging level gating. The cache-observability line is now a plain logger.debug scoped to MiniMax providers on the Anthropic wire only, so the "+128 floor" note can no longer appear for native Anthropic where it is false. Tests updated accordingly (MiniMax logs, native Anthropic does not).
Salvaged PR NousResearch#83678's commit is authored under a generic local agent identity with no linked GitHub account; map it to the PR opener for release attribution (same pattern as [email protected]).
…lete, screenshots, OS detection (NousResearch#84419) Sweep of open Windows issues affecting day-to-day agent operation (explicitly excluding install/setup and locale classes): - hermes_cli/_subprocess_compat.py: new split_command_line() — Windows- safe command-line tokenizer (posix=False + quote stripping) so backslash paths survive. POSIX behavior unchanged (plain shlex.split). - hermes_cli/console_engine.py (NousResearch#83934): console commands like 'sessions export C:\Users\me\out.jsonl' no longer silently mangle the path into a relative filename in the cwd. - agent/shell_hooks.py (NousResearch#78293): hook commands with backslash paths now spawn, resolve their script path, and pass hooks doctor instead of reporting 'not executable'. All three shlex sites routed through the shared splitter. - agent/prompt_builder.py (NousResearch#51755): system prompt now reports Windows (11) on Windows 11 — platform.release() returns 10 for both; distinguish via sys.getwindowsversion().build >= 22000. - hermes_cli/commands.py (NousResearch#42016): @ autocomplete no longer crashes the prompt_toolkit event loop when rg emits a path on a different mount (device paths \.\nul, other drive letters) — relpath ValueError is skipped per-entry. - tools/browser_use_cli.py (NousResearch#83884): screenshot-path detection now matches Windows drive-letter paths (C:\... and C:/...) in addition to POSIX; Browser Use screenshots attach on Windows. - tools/skills_hub.py + tools/skills_guard.py (NousResearch#62310): the two 'MUST stay symmetric' skill content hashes actually agree on Windows now. Bundle keys are normalized to POSIX separators before hashing, and the disk digest sorts by rel-posix STRING (case-sensitive) instead of Path objects (case-insensitive on Windows). Fixes permanent false-positive update_available for every installed skill. Tests: tests/tools/test_windows_agent_loop_papercuts.py — 16 cases covering each fix, including a disk-vs-bundle hash symmetry check built with native Windows separators and a mixed-case filename.
…eservation (NousResearch#84426) Two follow-ups from live Windows sessions: 1. agent/prompt_builder.py: extend the Windows shell hint with the native-binary path rule. Hermes disables MSYS path conversion for its bash, so agents passing /c/Users/... or /tmp/... to NATIVE programs (git -C, node, python, rg) hit 'cannot change to' / 'not found' while the same path works in bash builtins — observed repeatedly in a live session (git -C failures, git apply /tmp/x.patch failures). The hint now says: forward-slash native form (C:/Users/x) for native tools, $LOCALAPPDATA/Temp over /tmp for scratch files native tools read. (/tmp is pure model habit from Linux training data — nothing instructs it — so the hint is the right layer.) 2. tests: pin LF/CRLF preservation through write_file and patch_replace. A live session saw a repo-LF file come back full-CRLF after an edit (4699-line diff churn); not reproducible through current tool APIs, so pin the correct behavior — LF files stay LF, CRLF files stay CRLF, no mixed endings — to catch any regression on the Windows write path.
…d paths (NousResearch#84428) Fixes NousResearch#69472. On a Windows host every destructive native command passed approval silently — DANGEROUS_PATTERNS were POSIX-shaped, and the normalizer strips backslashes as shell escapes so no Windows path could ever match a path rule. Probed live before the fix: 15 of 15 destructive Windows commands (Remove-Item -Recurse -Force, del /s /q, iwr | iex, taskkill /F, Format-Volume, diskpart, icacls /grant Everyone, vssadmin delete shadows, bcdedit /set, reg delete, cipher /w, ...) sailed through undetected. Two changes: 1. Windows destructive tier in DANGEROUS_PATTERNS: PowerShell deletes (bare Remove-Item -Recurse/-Force), cmd builtins with /s|/q switches, iwr|iex remote execution (pipe and subexpression forms), taskkill /F / Stop-Process -Force, volume/disk destruction (Format-Volume, Clear-Disk, diskpart, format.com, cipher /w), icacls Everyone-grant / /reset, backup destruction (vssadmin delete shadows, wbadmin delete, bcdedit /set), reg delete / Remove-ItemProperty -Force, and service stop/delete (Stop-Service -Force, sc stop|delete). Each pattern requires the destructive flag so graceful/read-only usage (taskkill /IM without /F, reg query, icacls inspect, sc query, plain del file) does not prompt. Patterns live in the main list, not a win32-gated tier: a Linux-hosted Hermes can drive a Windows box over SSH. 2. Windows-path detection variant in _command_detection_variants: when the raw command contains a drive-letter/UNC backslash path, also yield a variant with backslashes flattened to forward slashes BEFORE normalization strips them, plus Windows spellings of the credential path rules (Users/<u>/.ssh, AppData/{Local,Roaming}/hermes .env). Gated on a real path shape so POSIX escape semantics are untouched. Tests: tests/tools/test_approval_windows.py — 48 cases (27 destructive flagged, 13 benign not flagged, 5 credential paths in both separator spellings, 4 POSIX-escape non-regressions). The 8 pre-existing failures under '-k approval' on this Windows host are identical on unmodified main (ordering artifacts + known symlink cases) and unrelated.
…form skills (NousResearch#84429) Two Windows agent-loop friction fixes: 1. tools/mcp_tool.py (NousResearch#56536): shutil.which(cmd, path=env_path) reads executable extensions from the PARENT process PATHEXT, not the MCP subprocess env — a stdio MCP config supplying both PATH and PATHEXT could fail to resolve a command its own env can locate, and startup then got a bare command name. On Windows, when the first which() call misses and the config env carries PATHEXT (any key casing), retry the resolution with the config's PATHEXT temporarily applied. 2. skills/ + optional-skills/ (NousResearch#50606): 42 SKILL.md files that declare platforms: [.., windows] used python3 in their command examples. python3 does not exist on native Windows (the toolchain probe in the system prompt reports python3=missing), so every copy-pasted example burned a failed agent turn before self-correction. Replaced the command word python3 -> python (python3-config / python3.x version strings untouched). python is the spelling that exists in every Hermes-managed environment (Windows native, uv-managed venvs on all three OSes); agents on POSIX hosts additionally see the probed toolchain line and adapt either way.
The 3-sentence identical-edit message was snapshot-asserted verbatim in two tests. House style avoids exact-string change-detector assertions; both tests now import the constant from tools/fuzzy_match so rewording the message can't silently break them.
…hema skill_manage's patch action uses the same fuzzy_find_and_replace engine as the file patch tool and surfaces the identical-strings error verbatim — and unlike the file path it has NO is_already_applied no-op rescue, so identical old/new ALWAYS errors there. Mirror the new_string description so the schema warns before the error fires (sibling-site parity with tools/file_tools.py PATCH_SCHEMA).
The apply phase already skips a hunk whose -/+ lines are identical (patch_parser.py '(search_lines == replace_lines): continue'), but the validation phase lacked the guard: such a hunk reached fuzzy_find_and_replace, whose identical-strings error names old_string/new_string — parameters that don't exist in patch mode — and failed the whole atomic patch that apply would have accepted. Mirror the apply-phase skip in validation; regression test drives a mixed degenerate+live patch end-to-end (short text dodges the is_already_applied >=8-char rescue).
…thon (NousResearch#84452) * fix(windows): SSH ControlMaster gating + stop hijacking the user's python Two Windows environment-integrity fixes: 1. tools/environments/ssh.py (NousResearch#73927): Windows OpenSSH has no Unix-domain-socket ControlMaster support, so unconditionally passing ControlPath/ControlMaster/ControlPersist failed EVERY tool call on a Windows-hosted ssh terminal backend with 'getsockname failed: Not a socket'. Gate the three multiplexing options behind a module-level _SSH_MULTIPLEX = (os.name != 'nt'); the scp upload path is gated the same way. On Windows the backend now works without connection pooling (each command a fresh connection); POSIX behavior is unchanged. The teardown 'ssh -O exit' is naturally inert because the socket never exists on Windows. 2. scripts/install.ps1 (NousResearch#83797): the installer put the whole venv\Scripts directory on the user PATH, which contains python.exe / pythonw.exe / pip.exe and so silently hijacked the 'python' command in every terminal on the machine — unrelated projects started resolving python to Hermes' runtime interpreter. Now copy only the launchers (hermes.exe, hermes-acp.exe) into a dedicated $InstallDir\bin and put THAT on PATH. Existing installs are migrated: the legacy venv\Scripts entry is stripped from the user PATH on the next install/update. The new bin dir is under $InstallDir (…\hermes-agent), which the uninstall PATH sweep already matches via its \hermes-agent marker. Updated the stale hermes_cli/update_cmd.py docstring that described the old venv\Scripts-on-PATH layout. Tests: SSH ControlMaster gating pinned both directions (multiplex on → flags present; off → absent but BatchMode/StrictHostKeyChecking retained). install.ps1 parses clean via the PowerShell AST parser. * docs: update windows-native install docs for the bin\ launcher layout CI (test_windows_native_docs) pins the docs and installer to the same PATH layout. The NousResearch#83797 fix moved the PATH entry from venv\Scripts to a dedicated $InstallDir\bin holding only the hermes launchers, so update the Windows-native guide to match: PATH-after-install section, the install-steps list, the directory-layout table, the Get-Command verification line, and the 'command not found' pitfall. Test now asserts the bin\ layout and guards against a regression back to venv\Scripts on PATH. * fix: keep install.ps1 pure ASCII (PowerShell 5.1 codepage safety) The two comments I added in the NousResearch#83797 PATH-hijack fix used em-dashes, tripping tests/test_install_ps1_ascii_only.py — Windows PowerShell 5.1 reads a BOM-less .ps1 in the system ANSI codepage (not UTF-8), so a non-ASCII byte can misdecode into a stray quote and desync the parser (issues NousResearch#66994/NousResearch#67000). Replace the em-dashes with ASCII '--'.
# Conflicts: # contributors/emails/[email protected] # gateway/platforms/api_server.py # gateway/run.py # hermes_cli/web_routers/sessions.py # hermes_state.py # tests/hermes_cli/test_web_server.py # tests/test_tui_gateway_server.py # tui_gateway/methods_prompt.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated fail-closed upstream sync. Locally verified head: 2d12e5d