fix(#66030): add auth-posture warning to dashboard platform test - #6
Closed
webtecnica wants to merge 252 commits into
Closed
webtecnica wants to merge 252 commits into
webtecnica wants to merge 252 commits into
Conversation
…sResearch#60145) hermes-agent is public/OSS; the forensic-logging comment in _quarantine_nous_oauth_state named 'Fly' (the specific managed-hosting compute provider) twice. Reword generically ('a hosted agent', 'a managed log drain may be WARNING-only') — the behaviour is unchanged, only the comment. Follows the same scrub applied to the boot re-seed helper (NousResearch#59983) before merge; this one slipped through in NousResearch#59976.
* fix(auth): apply newer hosted bootstrap session * fix(auth): validate rebootstrap replacement seeds
…lias (NousResearch#65237) * fix(nix): fold makeWrapper line continuations into optionalStrings When rev == null (any dirty-tree build), the empty optionalString expansion left the previous line's trailing backslash dangling onto a blank line, ending the makeWrapper command early and running `--suffix PYTHONPATH ...` as its own shell command (`--suffix: command not found`, exit 127). Clean trees passed CI; dirty trees with extraPythonPackages failed — exactly the path the NixOS module exercises. The continuation now lives inside each optionalString (" \\\n --set ..."), so the makeWrapper chain stays intact whether or not the optional flags expand. Verified by building with rev = null + extraPythonPackages = [ pyfiglet ]: wrapper builds, PYTHONPATH suffix lands inside the makeWrapper call, wrapped `hermes --version` runs, and the collision check still executes (certifi correctly rejected). * perf(nix): filter derivation sources to shrink rebuild scope Every derivation previously saw the whole repo, so any file change rebuilt everything. Each derivation now gets a filtered src with only the files it consumes: - lib.nix: derive npm workspace topology from the root package.json `workspaces` globs (single source of truth — a new workspace member is picked up with zero nix edits). pythonSrc (cleanSourceWith) excludes the JS workspace trees, docs/website, docker/.github, tests, nix/, flake.nix/flake.lock, root docs, and skills/ + optional-skills/. importNpmLock reads from a fileset-filtered npmRoot (root manifests + member package.jsons only). - mkNpmPassthru takes `dirs` — the workspace dirs the package contains — and builds a per-package fileset src from them. web and desktop include apps/shared (file: dep). One shared `nix run .#update-npm-lockfile` replaces the per-package update_*_lockfile bins that only existed inside build sandboxes. - python.nix: release venv loads the uv2nix workspace from pythonSrc. The editable venv keeps an unfiltered ./.. root — mkEditablePyprojectOverlay calls lib.path.splitRoot, which rejects a cleanSourceWith set, and the editable install reads the live checkout anyway. - hermes-agent.nix: skills ship exclusively via HERMES_BUNDLED_SKILLS / HERMES_OPTIONAL_SKILLS (same mechanism as Homebrew packaging; setup.py's _data_file_tree returns [] for missing dirs), so SKILL.md edits no longer rebuild the venv. optional-mcps stays in the wheel — pyproject.toml lists its manifests as explicit data-files. Bundled assets are symlinked instead of copied, making the wrapper drv near-instant when only an input changed. __pycache__ filtered from bundled skills. - checks.nix: find -L through the new symlinks; assert optional-skills presence + HERMES_OPTIONAL_SKILLS in the wrapper. - run_tests.sh: fall back to $HERMES_PYTHON when no local venv exists, guarded by an `import pytest` probe (HERMES_PYTHON from a wrapped hermes binary points at the release venv, which has no pytest — without the guard every test file dies with "No module named pytest" while the runner exits 0). Verified: nix flake check exit 0; built .#default .#tui .#web .#desktop; SKILL.md and flake.nix edits leave the venv drvPath unchanged; .py edits leave the tui drvPath unchanged; .tsx edits leave the venv drvPath unchanged (and do change the tui drv); scripts/run_tests.sh runs 299 tests green through both the venv and HERMES_PYTHON paths, and rejects a pytest-less HERMES_PYTHON. * refactor(nix): overlay aliases the flake's own package instead of re-instantiating The overlay previously re-called callPackage against the consumer's nixpkgs (final), so pkgs.hermes-agent could be a different derivation than nix build .#default and the NixOS module's default — an untested build matrix against arbitrary consumer nixpkgs versions, for a package whose Python side is uv2nix-locked anyway. Now the overlay is a pure alias for the flake's own locked package: one callPackage site (packages.nix), everything else references it. .override { ... } still works — callPackage's makeOverridable travels with the derivation. Verified: direct drvPath == overlaid drvPath; .override produces a distinct drv. * fix(nix): dedupe extraPlugins assertions, replace MESSAGING_CWD with terminal.cwd - Delete the duplicated extraPlugins duplicate-name assertions block (same assertion declared twice back to back). - Stop setting the deprecated MESSAGING_CWD env var, which made the module trip hermes' own startup deprecation warning. The working directory is now injected as terminal.cwd into the generated config.yaml; cfg.settings wins via recursiveUpdate, and container mode maps to the in-container mount path.
…ousResearch#65206) git diff on a lockfile is unreadable: npm reorders entries, rewrites integrity hashes, and moves packages between nesting levels, so a one-line package.json bump produces a thousand-line textual diff. scripts/ci/lockfile_diff.py instead parses the `packages` map out of both versions of every tracked package-lock.json (via `git show`), reduces each to {install path: version}, and set-diffs the maps — reorder/hash churn vanishes, leaving only actual version movement (added / removed / updated, with nested dedup copies tracked separately). The lockfile-diff workflow posts the result as a Markdown table in a PR comment gated behind a hidden marker: subsequent pushes PATCH the existing comment instead of stacking new ones, and a push that reverts all lockfile changes updates the comment to say so. Advisory only — never fails on findings; fork PRs (read-only token) degrade to a warning. Wired through the ci.yml orchestrator with a new npm_lock lane in classify_changes.py (fails open on .github/ changes per the existing contract).
…aned events Two complementary fixes for cross-session background-process notification leakage in the TUI/Desktop multi-session path (NousResearch#42674, NousResearch#35652). 1. Poller orphan guard: after _notification_event_belongs_elsewhere returns False, check whether the event has a non-empty session_key that differs from the current session. If so the owner session is gone — drop the event instead of hijacking it into an unrelated session transcript. 2. Post-turn drain filter: the existing drain_notifications() pops every event from the global queue regardless of ownership. Added _drain_owned_notifications() which applies the same ownership routing used by the poller (consume own, requeue foreign-live, drop orphan), and wired it into the post-turn safety drain. Complementary to PR NousResearch#42731 which addresses a separate code path in the same bug class. Together they close NousResearch#42674.
Apply positive-proof routing to every addressed notification in the registry and TUI poller while preserving ownerless legacy behavior and TUI delivery for poll-observed completions. Remove the unused exact-key drain helper and cover ordinary success and failure, origin, compression-lineage, orphan, and poll-observed paths. Complements NousResearch#54785.
Adapt the strongest NousResearch#63317 live-loop handoff regression and cover lineage lookup failure plus addressed live-loop orphans. Co-authored-by: Abhinav Bansal <abhibansal-sg@users.noreply.github.com>
…st (NousResearch#65214) Moves the fireworks entry in CANONICAL_PROVIDERS from its old slot (after GMI Cloud) to directly below Nous Portal, ahead of OpenRouter. Order propagates automatically to hermes model, the setup wizard, Telegram /model, and the desktop provider catalog.
…adapter The webhook adapter defaulted to host='0.0.0.0' — IPv4 only. On Fly.io hosted agents the edge router (hermes-agent-router) reverse-proxies public webhook traffic to <app>.internal:8644 over 6PN, Fly's private network, which is IPv6-only (.internal resolves to an fdaa:… address). An IPv4-only listener is unreachable there, so public webhook POSTs to https://<agent>.agents.nousresearch.com/webhooks/<route> never landed on the adapter — the router's dial was refused. Fix: DEFAULT_HOST = None, which makes aiohttp/asyncio create_server bind BOTH address families. '::' is NOT a valid substitute: on hosts where the kernel sets bindv6only=1 (verified on Fly machines) it yields an IPv6-only socket, breaking the IPv4 loopback /health check and the AF_INET port-conflict probe in connect(). None binds per-family regardless of the sysctl. An explicit empty-string/null host in config now also normalises to None (dual-stack) rather than an invalid host=''. Users can still pin a specific host via platforms.webhook.extra.host. Validated live on a Fly staging agent: with this default and no config override, the adapter binds both v4 and v6 (127.0.0.1:8644 and [::1]:8644 both answer), and a public signed webhook POST through the router returns 202 (valid sig) / 401 (bad sig) instead of the router's 502. Tests: new TestDualStackBind asserts the None default, config resolution (missing/empty→None, pinned preserved), and a real dual-stack bind opens both AF_INET and AF_INET6 listeners. Red-proof: these fail on the old '0.0.0.0' default.
Disable address reuse so an existing family-specific listener cannot silently split traffic with the webhook server. Normalize wildcard bind hosts for local CLI URLs and align setup documentation with the dual-stack default.
…rite) First step of the cwd rearchitecture (see PR NousResearch#65185 for the targeted leak fixes this will eventually supersede, and .hermes/plans/cwd-rearch-audit.md for the full audit + sequencing). The root cause of the wrong-worktree bug class is that cwd lives on the SHARED terminal env — a global mutable timeshared between sessions. env.cwd_owner stamping, _last_known_cwd, and file_tools' ownership ladder are all patches over that misplacement. This adds the replacement store: _session_cwd, keyed by the raw session/task key, with record/get/clear accessors. Step 1 is dual-write only — every site that learns a session's live cwd also records it: - terminal_tool foreground path: after env.execute() the env's own post-command tracking has updated env.cwd; mirror it under the session key that drove the command - register_task_env_overrides: a registered workspace cwd (ACP/TUI/ desktop) seeds the session record - clear_task_env_overrides: drops the record on teardown Readers are untouched — behavior is identical. Later steps flip file_tools resolution and _resolve_command_cwd to read this store, then delete env-side tracking, cwd_owner, and _last_known_cwd. Also hardens terminal_tool's env acquisition with an explicit env-is-None guard (previously implicitly unbound on an unreachable branch, flagged by pyright once the dual-write read env post-loop).
…step 2)
Flips the read side of the cwd rearchitecture onto the _session_cwd
store introduced in the previous commit.
_authoritative_workspace_root now resolves:
1. the session's own cwd record (get_session_cwd) — per-session by
construction, so one session's cd can never leak into another
session's file resolution, with no ownership heuristics at all
2. registered override (fallback for cleared/never-written records)
3. legacy shared-env live cwd + preserved anchor (transition-only,
for commands that ran before this code loaded)
4. sentinel-free absolute TERMINAL_CWD
delegate_task children get their record seeded from the parent's at
spawn: they keep starting in the parent's directory (current behavior)
but their subsequent cds stay isolated in their own record instead of
bleeding back through the shared env.
The wrong-worktree leak class is now solved structurally on this path —
there is no shared cwd for sessions to inherit. The legacy env-side
tracking (cwd_owner, _live_cwd_if_owned, _last_known_cwd) remains only
as a transition fallback and is deleted in the next step.
…p 3) Third step of the cwd rearchitecture: _resolve_command_cwd now prefers the session's own cwd record over the shared env's live cwd. New resolution order: workdir > session record > legacy env.cwd (ownership-gated, transition-only) > config/override default. The record is written after every completed command for the session, so it IS the session's cd state — another session's cd lands in another record and cannot affect this session's commands. The legacy env.cwd branch only fires for a session with no record yet (no command has completed since this code loaded); it keeps the prev_owner ownership guard for that transition window and is deleted in step 4 along with env.cwd_owner stamping and file_tools' _last_known_cwd machinery. Adds command-path regression tests including the terminal sibling of the leak-A scenario (unowned shared env cwd vs session record) and an E2E cd round-trip through terminal_tool.
…tep 4) The per-session record store is now the ONLY cwd mechanism. Deleted: - env.cwd_owner stamping + prev_owner threading (terminal_tool): the shared env no longer carries ownership metadata at all - _resolve_command_cwd's env/prev_owner params: resolution is workdir > session record > config/override default - file_tools._live_cwd_if_owned + _get_live_tracking_cwd: path resolution never consults the shared env's live cwd - file_tools._last_known_cwd + _remember_last_known_cwd + _last_known_cwd_for: the NousResearch#26211 preserved-anchor registry is subsumed by the session record, which never lived on the env and therefore cannot be lost to env cleanup. The _get_file_ops stale-cache rescue now writes the record instead. - env recreation (both _get_file_ops and terminal_tool) seeds the fresh env from override > session record > config Why no transition fallback: the legacy state was process-local and in-memory exactly like the record store — after a restart both start empty, and within a running process every legacy write site has been dual-writing the record since step 1. There is no populated-legacy/ empty-record state to fall back for. Tests updated to drive the record store instead of the deleted mechanism; the cross-session isolation suite now asserts the same behavior contracts (no leak, cd isolation, NousResearch#26211 persistence) against the new architecture, plus a new "session C inherits nothing" case that the old ownership guard could not express.
plugins/platforms/photon/adapter.py launches the Node sidecar (and the spectrum-ts mixed-attachment patch run) via subprocess without creationflags. On Windows this opens a visible console window on every sidecar (re)start -- and because a failed sidecar is retried on a timer, it flashes repeatedly. Wire windows_hide_flags() (hermes_cli/_subprocess_compat) into both spawns, the same helper the discord and whatsapp adapters already use for their sidecar spawns -- photon was the one platform adapter this pattern missed. CREATE_NO_WINDOW only (no DETACHED_PROCESS) so the persistent sidecar's stdin/stdout pipes stay usable for the supervisor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Widen @lEWFkRAD's sidecar-headless fix (PR NousResearch#54565) to the sibling spawn sites: the npm ci / npm install self-heal runs in _reinstall_sidecar_deps also popped a brief console window per run on Windows. Same windows_hide_flags() helper (CREATE_NO_WINDOW only, so capture_output stays usable).
The end-to-end _build_api_kwargs parity test asserted the Nous Portal tags exactly equal the base two-tag list. With the per-session conversation tag, a real agent (which has a session_id) now emits a third `conversation=<session_id>` tag. Assert against nous_portal_tags(session_id=agent.session_id) so the check stays exact.
Update max-iteration summary assertions to include the agent session ID now attached to Nous Portal requests.
… calls Extends the conversation=<id> Portal tag (salvaged from PR NousResearch#65183 by @J-SUPHA) from main-loop-only to every LLM call in a conversation: - agent/portal_tags.py: ContextVar-based conversation context. nous_portal_tags() falls back to the ambient id when no explicit session_id is passed, so every aux tag site (auxiliary_client, chat_completion_helpers summary path, web_tools) inherits the tag with zero per-call-site plumbing. Ambient id wins over explicit per-segment ids since it carries the lineage root. - hermes_state.py: SessionDB.get_conversation_root() — public wrapper over the lineage walk; returns the ROOT session id, so one user-facing conversation keeps a single conversation= value across context-compression rotation, and delegate subagent trees tag as their parent conversation. - run_agent.py: run_conversation() publishes the root id for the turn and resets it in finally. _conversation_root_id() resolves via _parent_session_id for subagents. - agent/moa_loop.py: MoA reference fan-out workers now run under propagate_context_to_thread so advisor slots attribute to the acting conversation (also fixes approval-callback propagation on that path). - agent/title_generator.py: bare title thread republishes the context from its session id (spawned after turn reset). Tests: ContextVar semantics, cross-context isolation, thread-hop propagation, lineage-root resolution incl. cycle guard.
tools_disable_enable_command filters platform-restricted toolsets out of
toolset_targets and prints an error for each, but the success summary at
the end is built from the raw targets list and only excludes unknown
toolsets and failed MCP servers. Running e.g.
hermes tools enable discord --platform telegram
prints the 'not available on platform' error followed by 'Enabled:
discord' for a toolset that was never written to the config.
Exclude restricted_targets from the success summary, matching how
unknown toolsets and failed MCP servers are already handled.
Two regression tests: a restricted toolset alone must not print
'Enabled', and a mixed allowed+restricted invocation must report only
the allowed toolset (both fail before the fix).
The sidebar working dot didn't update for background sessions until the user opened them. Two coupled causes: 1. The gateway's session.info event payload omitted stored_session_id, so the desktop app had no way to map a background session's runtime id to its stored id. Without the stored id, setSessionWorking(null, ...) was a no-op — the $workingSessionIds atom never updated. 2. The running→busy transition in the session.info handler was gated on `apply` (active session only). The gate correctly scopes view-only side effects (setCurrentModel, setCurrentCwd, etc.) to the focused chat, but the per-session busy state drives the sidebar indicator and must reach every session. updateSessionState only mutates the per-runtime cache entry, and syncSessionStateToView already guards the view publish to the active session, so ungating is safe. Fix: add stored_session_id to _session_info() in tui_gateway/server.py, add the field to GatewayEventPayload, pass it to updateSessionState in the session.info handler, and ungate the running→busy transition.
…usResearch#65898) Switching between chat sessions in the desktop app froze for up to ~1–2s on large transcripts. Profiling the switch path surfaced three main-thread blockers, fixed here minimally and without changing behavior. 1. JSON.stringify deep-compare (worst case). chatMessagesEquivalent compared message parts with JSON.stringify(a) === JSON.stringify(b) on every switch. On image-/large-blob-bearing transcripts this serialized every part twice and cost well over a second. Replaced with a structural compare that never stringifies: array-level identity fast-path, per-part reference fast-path, then type-aware field comparison. The compare's only consumer asks "did the transcript change, should I setMessages?", so it is deliberately conservative — a false-negative just causes one extra idempotent setMessages, while a false-positive (the unsafe direction) is avoided. 2. Scroll-settle loop. thread-list ran a requestAnimationFrame settle loop up to 90 frames (or 5 stable frames) on every sessionKey change, each frame forcing a synchronous layout read + write — racing the markdown paint for up to ~1.5s. A normal synchronous switch stabilizes within a couple frames, so the ceiling is now 2 stable frames / 15 max. 3. Synchronous first paint of up to 300 parts. On switch, thread-list reset the render budget to the full RENDER_BUDGET=300, so up to 300 parts went through markdown + shiki syntax-highlighting synchronously on the switch commit. It now paints a small FIRST_PAINT_BUDGET=60 first, then bumps to the full 300 in a requestAnimationFrame after the first commit. Salvaged from PR NousResearch#49807 by professorpalmer — re-applied to the restructured file layout (use-session-actions/utils.ts, thread/list.tsx) and tests merged into the existing utils.test.ts. Co-authored-by: Cary Palmer <professorpalmer@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
… unified worktree dialog (NousResearch#65204) * feat(desktop): add useKeybindHint hook and TipKeybindLabel Add a shared hook that reads the current keybind combo for an action id from the $bindings store (rebindable) or KEYBIND_READONLY (fixed), returning a formatted string or null when unbound. Add TipKeybindLabel — a convenience component that auto-reads both its label (from i18n) and keybind combo from the action registry. Pass only actionId for the common case; pass text to override when the tooltip is context-dependent. * fix(desktop): replace native title= on buttons with themed Tip Migrate all <button>/<Button> elements using the native HTML title= attribute to the instant, themed <Tip> component. Native tooltips are unstyled, delayed (~500ms OS default), and visually inconsistent with the app's instant themed tooltips. Also adds <Tip> wrappers to icon-only buttons that were missing tooltips entirely (dialog close, search clear, overlay close, master-detail pane controls, keybind panel rebind/reset buttons). Adds an enforcement test (no-native-title.test.ts) that scans all .tsx files for <button>/<Button> with title= and fails if any are found. Updates DESIGN.md with the icon-only button tooltip rule and keybind hint guidance. * feat(desktop): wire keybind hints into button tooltips Add actionId to TitlebarTool, StatusbarItem, and SidebarNavItem so their tooltips show the current keybind combo via TipKeybindLabel. Fix the hardcoded NEW_SESSION_KBD in the sidebar to read from $bindings so it stays live on rebind. Wired surfaces: - Titlebar: sidebar toggle, flip panes, keybinds, settings - Statusbar: terminal toggle (view.showTerminal) - Status stack: open agents button (nav.agents) - Sidebar nav: new session, skills, messaging, artifacts * feat(desktop): move keybind panel to settings tab with search filter Move the keyboard shortcuts panel from a Radix Dialog into a proper Settings tab (/settings?tab=keybinds). The ⌘/ shortcut and titlebar keyboard button now navigate to this settings tab instead of toggling a dialog. Adds a search filter to filter shortcuts by label. - New: src/app/settings/keybind-settings.tsx (extracted from keybind-panel.tsx) - Delete: src/app/shell/keybind-panel.tsx (dialog wrapper removed) - Remove: $keybindPanelOpen atom and toggle/open/close functions - Add: IconKeyboard to lib/icons.ts - i18n: keybinds.search + settings.nav.keybinds (en, zh, zh-hant, ja) * refactor(desktop): unify worktree dialog into shared WorktreeDialog Extract the worktree creation dialog from StartWorkButton (sidebar) into a shared WorktreeDialog component. Both the sidebar's StartWorkButton and the composer's CodingStatusRow now use the same dialog, eliminating the duplicated UI. The shared dialog keeps the sidebar version's full feature set: - BaseBranchPicker (filterable base branch combobox) - Convert mode (check out an existing branch into a worktree) - Sanitized branch name input The coding row passes repoPath (from cwd) and onOpenWorktree (which carries the composer draft to the new session) so the unified dialog works everywhere there's a repo, not just inside an entered project.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ad reload (NousResearch#65984) Auto-compression ends the SessionDB session and forks a continuation, rotating the stored session id. The gateway emits `session.info` with the new `stored_session_id`, and the desktop's cache entry was updated via `ensureSessionState` — but the URL route and `$selectedStoredSessionId` never followed the rotation. On the next send, `getRuntimeIdForStoredSession(oldStoredId)` returned null (the cache entry's `storedSessionId` no longer matched the old id), so `routedSessionNeedsResume` evaluated true, triggering a full `session.resume` + REST transcript prefetch — the whole thread reloaded. Fix: a new `$activeSessionStoredId` atom is set in `ensureSessionState` when the active session's stored id changes. A `useEffect` in `use-session-actions` subscribes to it and re-anchors the route + selection (`setSelectedStoredSessionId` + `navigate(replace: true)`), and cleans up the stale stored→runtime mapping. `replace: true` because it's the same conversation — compression is transparent to the user, so back-button stays correct.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…actively (NousResearch#54489) (NousResearch#63786) * fix(dashboard): unblock basic auth plugin during interactive password setup When the dashboard prompts for username/password on a non-loopback bind, also remove the bundled basic provider from plugins.disabled so discover_plugins(force=True) can register it (NousResearch#54489). * test(dashboard): cover basic auth plugin blocked by plugins.disabled Regression harness for NousResearch#54489: credentials in config are not enough when the bundled basic provider is on the deny-list.
…usResearch#52482) The web dashboard runs inside the gateway process, so `os.environ` carries `_HERMES_GATEWAY=1`. `_spawn_hermes_action` spread that into the subprocess env, so a spawned `hermes gateway restart` (dashboard "Enable webhooks", Telegram QR apply) tripped the in-process restart-loop guard and exited 1 — the gateway never restarted, but the dashboard reported `restart_started: true` because it only checks that the spawn succeeded. Scrub `_HERMES_GATEWAY` from the spawned action's env, matching what the gateway's own restart watcher already does (gateway/run.py). Fixes NousResearch#52470. Adds a test asserting the spawned env drops the loop-guard var while keeping HERMES_NONINTERACTIVE.
…rch#66001) Co-authored-by: Jakub Wolniewicz <4850809+frizikk@users.noreply.github.com>
…usResearch#66002) Desktop launch and the update-chain rebuild install npm deps whose child scripts shell out to a bare `node` (e.g. electron-winstaller's select-7z-arch.js). When launched from the desktop updater chain (Desktop -> hermes-setup -> hermes update) the shell PATH customizations are lost, so the install dies with `'node' is not recognized` / `node: not found`. - cmd_gui: wrap the npm-install env with with_hermes_node_path(_nixos_build_env()) so managed Node is prepended even on a stripped PATH — mirrors the idiom already used by the update deps refresh. (The original fix merged nixos_env on TOP of the managed env, whose full os.environ copy clobbered the managed PATH back to bare; wrapping fixes that merge order.) - _cmd_update_impl: spawn the `desktop --build-only` subprocess with with_hermes_node_path() so the child starts with managed Node from the outset. Regression test: the desktop install env now prepends the managed Node dir ahead of a bare updater PATH instead of passing env=None. Co-authored-by: F4TB0Yz <jfduarte09@gmail.com>
…way reconnect recovers (NousResearch#65756)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…engine MCP catalog entry Companion to optional-mcps/unreal-engine (Epic's official editor-embedded MCP server, UE 5.8 experimental). Mirrors the blender-mcp catalog-entry + companion-skill pattern, sized up for Unreal's discovery-based surface: - SKILL.md: tool-search discovery contract (list_toolsets/describe_toolset/ call_tool), serial game-thread call discipline (explicitly overrides the parallel-batching default), plain-English->scene translation workflow, save/verify hygiene, art-direction loop - references/tool-surface.md: architecture (Unreal MCP / Toolset Registry / AllToolsets), confirmed shipped toolsets, call_tool dispatch semantics, project Agent Skills (AgentSkillToolset), capture paths, custom Python/C++ toolset authoring, config/CVar/console reference, cooked-build notes - references/scene-craft.md: physically-based lighting values (lux/lumens/ Kelvin/EV100), mood recipes, Lumen Movable-mobility rule, scale tables, content-path conventions, CineCamera framing, editor Python entry points - references/recipes.md: four end-to-end builds in INTENT/DISCOVER/VALUES/ VERIFY grammar (exterior, night interior, golden-hour cinematic still, import+populate) that stay honest about the project-dependent surface - references/pitfalls.md: 25+ failure modes with fixes: start order, modal deadlocks, Hermes-timeout-vs-editor-completion, _C class suffix, PascalCase silent no-op writes, referenced-asset delete crash, async shader compiles, editor sprite icons in screenshots, PIE interference Grounded in Epic's UE 5.8 docs and Epic's agent-facing skill pack for this server; no fabricated tool names — live describe_toolset schemas are the contract throughout.
…encode e2e test findings Ran the full loop against a real editor (blank project, ModelContextProtocol + ToolsetRegistry + AllToolsets enabled): raw MCP handshake, discovery walk, environment relight for golden hour, primitive monument build, virtual-camera captures with vision judgment, exposure debugging, annotated spatial capture. 67 toolsets advertised; every dispatch semantic below observed, not inferred. Corrections and additions from the live run: - Qualified toolset names (editor_toolset.toolsets.scene.SceneTools) with SHORT tool_name; TOptional params must be explicit null; find_actors requires ''/[] for its schema-required optionals; ObjectTools values is a JSON *string*; refPath object references; returnValue wrapping; per-property failure lists with schema-in-error - HTTP wire contract: initialize=JSON + session header, tools/call=SSE frame after game-thread completion (plain-JSON clients read empty body) - CaptureViewport as virtual camera (captureTransform, meter-unit annotation grid + actor callouts) verified with pixel evidence; recipes rewritten to use it instead of viewport piloting - New pitfalls from real failures: template-level environment-actor duplication compounding into whiteouts (find-first/spawn-if-missing rule), template exposure calibration vs physical lux (12b), objective exposure check via ffprobe YAVG (12c), untitled-level Save-As modal deadlock, macOS full-Xcode + Metal Toolchain requirement (xcodebuild -downloadComponent MetalToolchain) - Live toolset census (67 on blank project), LogsToolset/ConfigSettings/ SemanticSearch highlights, UE EULA 6(e) licensing note
New references/advanced-workflows.md covering the sophisticated-workflow surface, each section exercised against a running editor: - ProgrammaticToolset batching: full contract (get_execution_environment gate, execute_tool fully-qualified names, JSON-string inputs, returnValue unwrapping, allowed imports) + a verified worked example (12-column colonnade, 36 components in one round-trip vs 37 serial calls) - Blueprint DSL authoring loop, verified end-to-end: create -> list_graphs -> get_graph_dsl_docs -> find_node_types per node -> write_graph_dsl -> compile_blueprint -> spawn instance. Every node-ID gotcha hit live is recorded (EventTick not Tick, Math|Rotator|MakeRotator, registry categories vs doc categories, no (self) node) - PIE session options schema (bSimulate/playMode/warmupSeconds/ startTransform, out-of-process downgrade behavior) - Sequencer orientation: 140-tool surface mapped by capability group + sibling keyframing/controlrig/conditions toolsets + minimal cinematic skeleton - LogsToolset self-debugging, AutomationTestToolset CI loop, SemanticSearch, ConfigSettings, project AgentSkillToolset precedence - Per-situation decision table New pitfalls from this round's live failures: 10b (refPath-object vs plain-string params; schema-in-error as tiebreaker), 10c (DSL node IDs must come from find_node_types). SKILL.md: batching exception wired into the operating loop, reference table row, description updated.
…ction 21c: the viewport axis gizmo survives bShowUI=false — measured extent on 5.8, deterministic ffmpeg post-crop recipe. 21d: frame-sequence discipline (one session, serial captures, idempotent resumable loop, s/frame budget, smoothstep easing, VolumetricCloud artifact removal) — all from producing a real 240-frame orbit through CaptureViewport.
…ide sprites at source Post-hoc sprite removal is a losing battle (three inpainting strategies failed QC on letter-edge overlap frames). The production answer: sprites are BillboardComponent/SpriteComponent/ArrowComponent subobjects — set bVisible:false via ObjectTools (remove_component fails on default subobjects), swept scene-wide in one ProgrammaticToolset script (148 actors, 13 sprites, one round-trip, verified).
…Research#66033) Follow-up to NousResearch#65890 (router transitions off) and NousResearch#65898 (structural compare + first-paint budget): profiling the switch path on real 1000+- message sessions with a new CDP harness showed the remaining freeze is NOT markdown rendering — it's a forced-reflow cascade from mount-time layout reads interleaved with style writes across the transcript's layout effects, plus the first-paint budget cut landing too late to stop the full-budget commit. Measured on the two largest local sessions (996 and 1363 messages), main-thread longtask totals per switch: warm 2450ms -> 557ms and 1158ms -> 194ms; first paint 1690ms -> 444ms. Harness: scripts/profile-session-switch.mjs (same CDP family as profile-real-stream.mjs). - use-resize-observer: drop the synchronous initial callback and ride the observer's spec-guaranteed first delivery instead (same frame, after layout, before paint). The sync call ran while the commit's layout was dirty, so every size read in a callback forced a full reflow — with one instance per user bubble (measureClamp read scrollHeight, then WROTE --human-msg-full, re-dirtying layout for the next bubble), the switch commit thrashed for over a second. Inside RO timing the same reads are free. Composer metrics (2x getBoundingClientRect + documentElement style writes) rides the same fix. - Same class, same fix at the remaining call sites profiling surfaced: ExpandableBlock and TerminalOutput (dozens per tool-heavy transcript) now measure/pin via RO initial delivery; the tool-window and thinking-preview pins drop their sync pin() call; the thread timeline's initial active-tick compute joins its existing scroll-time rAF batching so back-to-back transcript updates coalesce. - thread/list: cut the render budget in the RENDER phase (state-from- props adjustment) instead of the post-commit layout effect. The effect-time cut was too late — on a warm switch React first built and committed the full 300-part tree, then re-rendered at 60, then bumped back to 300, so the expensive commit still happened (and on a cold switch the bump rAF usually fired while the transcript was still empty, so the prefetched messages rendered at full budget anyway). The render-phase cut restarts the component before any child renders; a second trigger handles the cold path where messages land later under the same sessionKey. - thread/list: backfill 60 -> 300 inside startTransition so the older turns' markdown+shiki render is interruptible background work instead of a synchronous freeze one frame after the switch paints. Functional Math.max so an urgent "Show earlier" click can't be rebased back down. - composer focus: skip the rAF/timeout focus retries when the element is already focused — focus() runs the full focusing steps (forcing layout) even on the active element, ~585ms per switch on a large dirty DOM. - Replace the tautological render-budget test (it re-declared the constants locally and asserted 60 < 300) with behavior tests of the now-exported buildGroups + firstVisibleGroupIndex. Verification: apps/desktop `npx tsc --noEmit` clean; full `npx vitest run` 210 files / 1763 passed; manual CDP check confirms the deferred backfill commits the full transcript, stays pinned to bottom, and "Show earlier" still pages.
…ch#66040) * fix(desktop): hide Windows updater console (NousResearch#56884) * test(desktop): cover hidden updater handoffs behaviorally --------- Co-authored-by: Kyssta <218078013+kyssta-exe@users.noreply.github.com>
…usResearch#64783) Add a developer-guide page for running the Ink TUI and Electron desktop app from a git worktree without a full npm install per checkout, via the htui/hgui shell helpers that share node_modules from a canonical deps checkout by symlink (falling back to a local npm ci when the lockfile diverges). Registers it in the sidebar, cross-links from the TUI and git -worktrees pages, and documents the previously-undocumented HERMES_DESKTOP_PYTHON / HERMES_DESKTOP_DEV_SERVER env vars the desktop backend reads.
…arch#66017) * fix: detect env-var-configured providers absent from PROVIDER_REGISTRY is_provider_explicitly_configured() only checked PROVIDER_REGISTRY (a manually-maintained dict) for env-var names. Providers that exist solely in the models.dev catalog — e.g. openrouter — were never recognised as explicitly configured, so they were filtered out of the desktop model picker even when their API key was set in .env. Add a fallback to get_provider() (which reads the models.dev catalog) when PROVIDER_REGISTRY returns None. Both ProviderConfig and ProviderDef expose .auth_type and .api_key_env_vars with the same shape. * test: keep OpenRouter provider gate assertion behavioral * chore(release): map salvaged OpenRouter contributor --------- Co-authored-by: zzpigpinggai <zzpigpinggai@users.noreply.github.com>
…rm test When the Channels page 'Test' button reports a platform as connected but the gateway's auth posture denies all users (no allowlists configured, no allow-all flag set), the response was misleading — it only said 'connected' without surfacing the deny-all state. Fix adds which mirrors the auth logic from : - Checks GATEWAY_ALLOW_ALL_USERS (global allow-all) - Checks per-platform ALLOW_ALL_USERS flags (e.g. TELEGRAM_ALLOW_ALL_USERS) - Checks per-platform ALLOWED_USERS allowlists (e.g. TELEGRAM_ALLOWED_USERS) - Checks plugin platform registry for custom auth env var names - Returns a warning string when none of the above are configured The warning is added as a field in the response JSON alongside the existing — no breaking changes to the API contract.
webtecnica
pushed a commit
that referenced
this pull request
Aug 20, 2026
… the relay (gateway half) (NousResearch#85796) * feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half) NS-658. Three additive ops within contract v1, emitted only when the connector's negotiated descriptor advertises them: {op: draft, chat_id, draft_id, content, final, metadata} {op: task_card, chat_id, card_id, chunks, metadata} {op: task_card_stop, chat_id, card_id, metadata} The gateway side is deliberately dumb: no platform API knowledge, no new config keys. Slack mechanics (chat.startStream/appendStream/stopStream, per-workspace feature-gate cache, send+edit fallback) live connector-side where the platform adapter lives in the relay model. Semantic bridge: base send_draft is Telegram-shaped (draft clears; final is a separate send). Slack native streaming makes the stream THE message. The adapter tracks the open draft per chat and converts the turn-final send() into draft(final=true) so the connector seals the stream instead of posting a duplicate; the stream ts returns as the message identity. A failed frame disarms interception so the edit-based fallback's real send goes through untouched. BEHAVIOR CHANGE (deliberate): relay supports_draft_streaming() now requires the descriptor flag AND the draft op. Flag-only was a latent lie — send_draft inherited NotImplementedError, so a connector setting the flag without the op would have crashed the stream consumer's draft path. supported_ops stays fail-open for legacy (pre-contract) ops; draft/task_card did not exist pre-contract and must not fail open. Task cards ride NousResearch#85476's adapter-agnostic TurnRunner seam (hasattr on send_native_task_card_progress); supports_native_task_cards() is the descriptor probe. Connector half + E2E harness pair follow in the gg repo. * fix(relay): expose native_task_cards_enabled() on the relay adapter Live-canary finding (Alice, staging): the TurnRunner's task-card lane probes adapter.native_task_cards_enabled() (the native Slack adapter's opt-in contract). The relay adapter only offered supports_native_task_cards(), so the hasattr gate failed silently and tool progress stayed on the text path — draft streaming worked, cards never rendered. Alias it to the descriptor probe. * fix(relay): match task-card methods to the TurnRunner's native keyword contract Live-canary finding #2 (Alice, staging): gateway/run.py's card lane calls send/stop_native_task_card_progress with the NATIVE Slack adapter's signature (tasks/title/reply_to/metadata/fallback_text, keyword-only) — PR 85796's relay methods took a positional card_id, so every call raised TypeError('unexpected keyword argument reply_to') in the progress task, repeatedly killing the card publisher (and the retry loop resent the final delivery 4-5x). Card id now derives per turn thread (turn:<reply_to>), thread_ts anchored like draft; title/fallback_text accepted for parity, not forwarded (plan-mode stream renders chunks). * fix(relay): one draft stream per turn for stream-is-the-message adapters Live-canary finding #4 (Alice, staging): the stream consumer bumps draft_id at every tool boundary so Telegram-shaped drafts animate each text segment as a fresh preview. On relay Slack NATIVE streaming a new draft_id opens a brand-new chat.startStream — the user saw one frozen message per segment (stuck streaming cursor ▉, never sealed: only the LAST stream gets the final=true seal) plus the real final; 5-6 cumulative snapshots per turn. Adapters that mark draft_stream_is_message keep ONE stream per turn: tool progress lives in the native task card, and the connector's suffix-delta falls back to whole-text append on prefix mismatch, so segments append cleanly. Telegram-shaped drafts keep the per-segment bump. * fix(relay): don't seal the native stream at tool boundaries — only the turn-final does Live-canary finding #5 (Alice; supersedes the incomplete #4 which was necessary but not sufficient). Root cause CONFIRMED by integration trace (test_live_cards_flow_trace.py, real consumer semantics + real adapter + stub transport): at every tool boundary the consumer calls _send_or_edit(finalize=True), which skips the draft path and issues a real send(); the relay adapter's seal-interception converts THAT into draft(final=true) — sealing the stream once per segment. Timeline showed 3 seals for a 3-segment turn: exactly the frozen cumulative ▉ snapshots seen live (the replaced stream never gets stopStream, keeping its cursor). Fix: for draft_stream_is_message adapters, a segment-break finalize (finalize=True, is_turn_final=False) stays ON the draft path as another cumulative frame; only got_done (is_turn_final=True) falls through to send() and seals. Telegram-shaped platforms unchanged. Trace test now pins the invariant: ONE user-visible message per turn. * fix(relay): strip the text cursor from native draft frames Live-canary finding #6 (Alice) — the ACTUAL duplicate-content mechanism, confirmed by full-flow scan of both sides' code + logs. The consumer appends its text cursor (▉) to every non-final display_text tick. The connector's stream sender diffs CUMULATIVE frames via prefix check: 'abc▉'.startsWith → 'abc def▉' is NEVER a prefix match (the cursor sits mid-string), so deltaFor falls back to whole-text append on EVERY tick — chat.appendStream stacks each full cumulative snapshot (cursor included) into the ONE stream message. Exactly the observed thread: repeated blocks, each ending in a frozen ▉, growing per tick. Fixes #4/#5 were real (one stream per turn now) but this was the last mechanism standing. Native streams render their own typing indicator, so the text cursor is pure noise on this path: strip it from draft frames. Prefix check now holds; every tick appends only its true suffix delta. * fix(relay): seal-interception covers EVERY egress door, not just send() Live-canary finding #7 (Alice): one duplication remained after #6 — the stream froze mid-word with the live indicator (never sealed) and the final posted as a separate message. Log receipt: 'Queued follow-up: final text delivery confirmed; delivering explicit media before continuing' — the turn's final went out via the DELIVERY RESOLVER lane (gateway/delivery.py), which calls send_for_platform() DIRECTLY, bypassing send() and its seal-interception. The open stream never absorbed the final; it arrived as a plain 'send' op → chat.postMessage. Fix: hoist the open-draft check to the top of send() (ahead of the explicit-platform branch) AND add it to send_for_platform() — an open native stream absorbs the turn-final regardless of which egress door it arrives through. The stream IS the message. * fix(relay): failed seal falls back to plain send (PR 85796 AI-review point 1) A turn-final seal that fails at the transport must never swallow the final answer: the stream consumer has already disabled the draft transport for the run, so a failed _seal_open_draft returning success=False meant the user got NOTHING. Both seal-interception sites (send + send_for_platform) now fall through to the regular plain-send path on seal failure, with a warning receipt. Also mitigates AI-review point 2 (sticky _open_draft_by_chat after an abandoned turn): a stale entry's failed seal no longer blocks the next turn's delivery. * fix(relay): arm seal-interception optimistically; never disarm on ambiguous failure (audit G-D1) Deep-audit defect G-D1 (HIGH): the outbound leg is at-most-once on the wire but its ack channel is lossy — send_outbound timeout (30s) and WS-drop 'failures' frequently mean the frame WAS delivered and the connector stream is open. send_draft popped _open_draft_by_chat on any failure, disarming seal-interception while the connector stream lived: the turn-final went out as a plain send → orphaned mid-word stream + complete duplicate final (intermittent; needs a drop/timeout inside the draft window). Fix: arm the entry BEFORE the transport call and keep it armed on failure/exception. Safe in every case: sealing a non-existent stream opens+seals a single complete message connector-side, and a truly failed seal already falls back to plain send at both interception sites. Stale-entry damage is self-healing (one warning + plain send). * fix(relay): gateway-side sealed-draft tombstone — G-D1 arming must not resurrect sealed streams Regression fix on G-D1 (live: 'worse than before' — escalating frozen prefixes). Optimistic arming had no seal-awareness: a straggler frame arriving AFTER the seal re-armed _open_draft_by_chat for the already- sealed draft_id; the next send was converted to draft(final=true) on the tombstoned connector key, which CLEARED the connector tombstone (final frame = new-turn signal), re-opened a stream with cumulative content, and left it frozen — repeating per straggler: 4-5 escalating frozen snapshots. Mirror the connector: _sealed_draft_by_chat records the sealed draft_id per chat (tombstoned BEFORE the seal's transport call); send_draft for a sealed draft_id is a success no-op (content already in the sealed message) and never arms. A new turn's fresh draft_id arms normally. * fix(relay): key stream/card state per (chat, turn anchor) — parallel turns must not collide (finding #10) Live finding #10 (Alice; three concurrent turns in one flat DM): all coordination state was keyed per CHAT on a one-active-turn assumption. Three parallel turns produced: turn B's task card merged into turn A's (both were card 'turn:root' — reply_to is None in flat DMs), B left cardless, and _open/_sealed_draft_by_chat clobbered across writers (3x duplicate finals on the last turn). Per-turn machinery was correct; the keys were not. Fix: _draft_key(chat, metadata) = chat + the turn's thread anchor (inbound stamps thread_ts = event.thread_ts or ts on every top-level message, so each turn has one even in flat DMs). draft arming, seal tombstones, both interception sites, and the task-card id all derive from the same anchor. New trace test pins two interleaved turns: distinct cards, own-stream seals, no leaked plain send, no cross-turn tombstone drops (289 tests green). * fix(gateway): preserve cumulative native stream across tools * fix(gateway): consumer-declared final — the seal carries the true final Three composed fixes for the Slack live-cards duplicate-final class: 1. finish(final_text): TurnRunner passes the completed final_response (verifier footer, completion explainer included) as the authoritative finalize payload. The native-stream seal delivers the TRUE final, so post-stream mutation no longer forks a corrective plain send (#11). 2. Interim-send contract: commentary and segment-tail sends carry a gateway-internal _interim_send marker; relay seal-interception skips them at both egress doors. A mid-turn interim send can no longer seal the live stream and orphan the real final into a duplicate. 3. Queued-follow-up lane reconciles an unconfirmed final by EDITING the consumer's delivered message in place (sealed stream = regular message, chat.update live-verified); plain send only as fallback. This was the actual duplicate lane in the parallel canaries — every duplicated turn logged 'final stream delivery not confirmed; sending first response' (subagent-completion queued inbound), not parallelism. Also: draft frames stay prefix-stable gateway-side (no fence-closing, no segment state reset, no commentary reset for stream-is-the-message adapters; MagicMock-safe 'is True' guards). * test+docs: streaming-contract coverage completeness + maintenance guidelines Coverage: two gaps closed on the consumer-declared-final contract — (1) send_for_platform (the delivery-resolver egress door) honors the _interim_send contract: no seal, marker stripped before the wire; (2) finish(final_text) on a turn that never streamed does not adopt the final (delivery ownership stays with the gateway's normal send path for non-streaming models / tool-only turns). Docs: AGENTS.md 'Known Pitfalls' gains the streaming delivery contract — the four invariants of stream-is-the-message adapters (prefix-stable frames, consumer-declared final, interim-send marker, reconcile-by-edit), each traced to its live incident, plus the live-probed Slack streaming API ground truth and the MagicMock 'is True' guard-style note. * fix(relay): seal transport failure must never silently lose the final (review B1) Two halves of one silent-loss path, live-probed on the review branch: 1. adapter: _seal_open_draft did not catch transport exceptions. A socket drop at seal time raised out of send(), skipping the fail-open plain send entirely. Now: retry the SAME idempotent final frame once (the connector's sealed-key tombstone returns the original stream ts for a repeated final — a retry can never open a second stream or duplicate), then report failure so the caller's fail-open path runs. 2. consumer: the turn-final retry (elif not _already_sent) called _send_or_edit with finalize=False, which re-entered the DRAFT-FRAME branch. Its no-op dedupe compared the adopted final against the last unsealed frame, matched, and returned True with ZERO transport calls — final_response_sent went green, delivered_final_matches reconciled, the gateway suppressed its fallback, and the user never received the answer. finalize=True keeps this retry out of the draft branch. Regression suite: tests/gateway/test_relay_seal_failure.py (3 tests). Mutation evidence in follow-up verification: reverting either half sends the suite red. * fix(relay): draft ids unique across gateway incarnations (review B3) The relay connector tombstones sealed streams by (channel, draft_id) and keeps up to 512 of them; they outlive the gateway process. Relay gateways are disposable BY DESIGN (scale-to-zero), and _draft_id_counter restarted at zero every incarnation — so the first turns after every scale-from-zero in a recently-active channel replayed already-sealed wire identities. The connector answered those frames straight out of the old tombstone: zero Slack API calls, the OLD message ts returned as the new turn's identity, the new answer silently dropped while gateway-side flags recorded success. Seed the counter from wall-clock milliseconds at process start. Ids stay plain ints within the existing contract op; incarnations cannot overlap for realistic turn counts and restart gaps. Regression: tests/gateway/test_draft_id_restart_uniqueness.py — the seed test fails on the old code (seed 0 is not epoch-scale). * fix(relay): stream/card state keyed per TURN, not per thread anchor (review B2) The thread anchor is the wrong coordination identity — simultaneously: - too coarse: two parallel turns replying INSIDE ONE Slack thread share thread_ts. Live-probed on the review branch: turn A's final sealed turn B's stream with A's content while A's own stream stayed open, and B's final degraded to a plain send. - too fragile: a flat DM with no thread metadata degraded to the bare chat id, re-creating the original finding-#10 collision the anchor was meant to fix. _draft_key now prefers the triggering inbound message id (message_id / reply_to_message_id — per-turn by construction; the gateway's Slack thread metadata and the consumer's send path both stamp it), falling back to the thread anchor, then the bare chat. The consumer stamps the same reply_to_message_id on draft frames so frames and the turn-final resolve to one key. Task-card ids share the derivation via _card_key (one helper for send AND stop, so the stop always hits the stream the send opened). Legacy resolver-lane callers with placement-only metadata still seal via _match_open_draft's fallback — but ONLY when exactly one stream is open. With several open, an identity-less send stays a plain send: a duplicate message is recoverable, sealing someone else's stream is not. Regression: tests/gateway/relay/test_relay_turn_keying.py (7 tests). * fix(relay): stream-is-the-message is a Slack semantic, gate it on the descriptor (review B4) draft_stream_is_message was hardcoded True on the relay adapter class, i.e. for EVERY relay platform. The base send_draft contract is Telegram-shaped — the draft clears client-side and the final arrives as a separate real send that becomes the history message. With the flag forced on, any non-Slack connector advertising the draft op had its turn-final intercepted into draft(final=true): probed on the review branch with a telegram descriptor, the op stream was [draft(final=false), draft(final=true)] and NO send — no history message would ever be posted. Gate the flag on the negotiated descriptor platform (slack), and skip arming seal-interception entirely when it is off. A future platform with genuine stream-is-the-message native streaming should advertise it via the descriptor rather than widening the platform check by guesswork. Regression: tests/gateway/relay/test_relay_stream_semantics_gating.py (4 tests: gating both ways, telegram final is a real send, slack final still seals). * fix(gateway): mark every mid-turn status lane interim — heartbeats must not seal the stream (review B5) Seal-interception treats the first unmarked send to an armed (chat, turn) key as the turn-final. The consumer's own interim lanes (commentary, tail flush) carry _interim_send, but four gateway-side lanes that fire DURING a streaming turn did not: - long-running heartbeat (default every 180s — probed live: at 3 minutes it sealed the live stream with '⏳ Working — 3 min', the real final posted as a duplicate, and later frames were silently swallowed by the seal tombstone) - inactivity warning - plain-text approval fallback (button lane failed) - background-review notice Add _interim_metadata() beside _non_conversational_metadata and wrap all four call sites. The marker is gateway-internal; the relay adapter strips it before the wire (existing behavior, pinned by test). Note for follow-up: the opt-out shape remains fragile — any FUTURE unmarked mid-turn send lane re-creates this bug. Inverting the contract (explicitly mark the one turn-final send) is the durable fix but touches every adapter's final-delivery path; deliberately kept out of this review-fix series. Regression: tests/gateway/test_interim_send_lanes.py (4 tests). * fix(gateway): interrupted/incomplete turns must not adopt the diagnostic as the stream final (review B6) The finish(final_text) adoption gate checked only 'not failed', but the interrupt/abort returns in agent/conversation_loop.py are {completed: False, interrupted: True, final_response: 'Operation interrupted during …'} with NO failed key. Adopting that diagnostic: 1. sealed the user's streamed partial answer over with the interrupt text (stream-is-the-message: the seal rewrites the whole message), and 2. recorded the diagnostic as the turn-final payload, so delivered_final_matches reconciled and the gateway suppressed its own error-delivery path — the diagnostic became the ONLY thing delivered. Enumerated all 27 final_response-bearing return shapes in conversation_loop.py: every non-happy-path shape carries completed: False (several with a diagnostic final_response and neither failed nor interrupted — retry exhaustion, truncation, codex-incomplete); the happy path routes through turn_finalizer.finalize_turn (completed=True). Gate is therefore: not failed AND not interrupted AND completed is not False. Results lacking the completed key entirely (older callers/test doubles) keep the previous behavior. Regression: tests/gateway/test_stream_final_adoption_gate.py (6 tests, incl. a source-level pin on the run.py call site). * fix(relay): task-card transport failures degrade to failed SendResults (review B7) send_native_task_card_progress and stop_native_task_card_progress let transport exceptions escape. The stop runs inside the progress loop's finally block on the turn-cleanup path, and the post-cancel awaits in gateway/run.py caught only CancelledError — a socket drop during a card publish/stop therefore aborted cleanup BEFORE the final-delivery bookkeeping ran. Three layers, outermost defends any adapter: - both adapter methods catch transport exceptions and return failed SendResults (progress is advisory; the TurnRunner's text fallback already handles failure results) - the progress loop's finally wraps the stop (best-effort; the connector seals orphaned card streams on its own via recycling/eviction) - the cleanup awaits log-and-continue on non-cancellation errors so final-delivery bookkeeping always runs Regression: tests/gateway/relay/test_relay_task_card_failures.py. * fix(relay): a dying turn seals its native stream instead of orphaning it (review B8) Stale-generation exits (/new, /stop mid-stream) and cancellations returned from the consumer's run() with the native stream still open: - the Slack message kept its live streaming indicator forever (the cancellation best-effort edit only runs when _message_id exists, and the native draft path deliberately keeps it None); - the adapter's armed interception state survived the turn, so the next turn on the same key could inherit it and seal a dead draft_id. New adapter op abandon_open_draft(chat, content): seals in place with the text already on screen (the consumer passes its last delivered frame) — the seal adds nothing and claims nothing; delivery flags are never set, so the gateway's normal paths still own whatever happens next. Best-effort by contract (failure reported, never raised); the connector reaps truly orphaned streams via recycling/eviction. The consumer calls it from both death paths: the stale-generation early return and the CancelledError handler. Regression: tests/gateway/test_stream_abandon_on_turn_death.py (4 tests, incl. the next-turn-inheritance hazard). * fix(relay): bound the draft/seal coordination dicts (review M1) _sealed_draft_by_chat's key embeds a per-turn identity, so every completed turn wrote a permanent entry — unbounded growth for the life of a long-running gateway process (the docstring said 'one entry per chat', which stopped being true when the key gained the turn anchor). _open_draft_by_chat could grow the same way via abandoned entries. FIFO-evict both at 512 entries — the same idiom as the sibling bounded cache (_auto_thread_by_chat, capped at 256) and the same size as the connector's own tombstone store. The straggler window the tombstone exists for is seconds long; FIFO is more than enough. Regression: tests/gateway/relay/test_relay_state_bounds.py. * fix(relay): explicit connector rejection disarms interception; exceptions stay armed (review P3) The G-D1 optimistic-arming change silently dropped disarm-on-failure entirely: after an EXPLICIT connector rejection (success=False result — not a transport ambiguity), interception stayed armed even though the stream consumer disables the draft transport on that failure and falls back to edit-based streaming. Its turn-final would then be converted into a seal on a stream the connector just told us is unusable. test_draft_failure_result_propagates claimed to cover this ('must NOT leave seal-interception armed') but passed for an unrelated reason: the stub's canned failure also failed the SEAL, whose fail-open path did the plain send. Split the two semantics and pin each honestly: - explicit rejection (result success=False): disarm — turn-final is a real send (test_draft_failure_result_propagates, now testing what its comment says) - transport exception: ambiguous, stay armed — turn-final still seals (test_draft_transport_exception_keeps_interception_armed, the G-D1 contract) Also corrects commit ba3a24a's claim ('a failed frame disarms interception so the edit-based fallback's real send goes through untouched') to hold again for the rejection case it described. * fix(relay): lost acks are ambiguous, not rejections — on the RESULT channel too (review r2, finding 1) The production ws transport does not raise on ack timeout — it returns {"success": False, "error": "relay outbound timed out"}. The round-1 ambiguity handling keyed entirely on the exception channel, so the shape production actually produces was misclassified as a definite connector rejection. Probed on the head: - lost SEAL ack: skipped the idempotent retry, fell straight to a plain send — duplicate final whenever the seal had actually applied; - lost FRAME ack: the round-1 disarm-on-rejection fired — interception disarmed, frozen native stream beside a plain final. This re-created the original G-D1 ambiguous-ack defect on the result channel. Contract now spans both channels: - transport: the ack-timeout branch tags ambiguous=True. The fail-fast branches (closing / not connected) never sent anything and stay unmarked — they are definite non-delivery. - adapter frame path: ambiguous results keep interception armed (same as exceptions); only definite rejections disarm. - adapter seal path: one shared _attempt() classifier — exception and ambiguous result both mean "unknown"; the SAME idempotent frame is retried once (connector tombstone returns the original stream ts for a repeated final). Only after both attempts stay ambiguous does the caller's fail-open plain send run: a possible duplicate after double ack loss beats a silent loss, and double ack loss on one socket almost always means the transport is down for the plain send too. Regression: tests/gateway/relay/test_relay_ack_ambiguity.py (6 tests, incl. a source-of-truth check that the transport tags the timeout branch and leaves fail-fast branches unmarked). * fix(relay): stream semantics + draft capability resolve per CHAT, not per primary (review r2, finding 2) One RelayAdapter fronts N platforms (Phase 1.5): descriptors accumulate per platform on the transport and egress is tagged per chat — but the round-1 gate keyed draft_stream_is_message and supports_draft_streaming() off the PRIMARY scalar descriptor. Probed on the head: - Slack primary + Telegram chat: the Telegram chat's turn-final was intercepted into draft(final=true) — no real Telegram history message; - Telegram primary + Slack chat: the Slack chat was denied native streaming entirely. Resolve both through _descriptor_for_chat — the same per-chat machinery max_message_length already uses (added for the identical class of bug: the primary's 39000-char cap over-sending into Discord 400s): - new stream_is_message_for_chat(chat_id) on the adapter; arming and NotImplementedError gating use it. The class attribute remains as the single-platform value and legacy-probe fallback. - supports_draft_streaming() gains an optional chat_id kwarg (base signature updated; single-platform adapters ignore it). The consumer passes chat_id with a TypeError fallback for out-of-tree adapters. - the consumer's four draft_stream_is_message reads collapse into one _stream_is_message() helper that prefers the per-chat probe (class-resolved, MagicMock-safe) over the attribute. Platform-name inference ("slack") stays deliberate: a descriptor-level semantic field is the right eventual contract but is a cross-repo wire change — noted for the gg follow-up so future platforms advertise the semantic explicitly. Regression: tests/gateway/relay/test_relay_multiplatform_semantics.py (5 tests: both starvation directions, scalar fallback, per-chat capability gate). * fix(gateway): split delivery + authoritative footer reconciles by suffix, not full resend (review r2, finding 3) The _FINAL_TEXT adoption guard refuses wholesale adoption on split turns — correct (NousResearch#78541: sealed heads would repeat inside the tail) but it was absolute: a post-split verifier footer never entered the ledger, delivered_final_matches() reported a mismatch, and the gateway resent the ENTIRE body+footer after the split chunks (the #11 duplicate class, one level up). When the authoritative final strictly prefix-extends the split ledger, the missing suffix is the only undelivered content: append it to the live tail and the ledger, so the finalize carries it and the recorded payload reconciles. Non-prefix rewrites keep the full-resend fallback — a rewrite cannot be patched onto sealed heads. Regression: tests/gateway/test_split_final_suffix_reconcile.py (3 tests: suffix rides the tail + reconciles, rewrite still mismatches, unsplit adoption unchanged). * fix(relay): cancellation mid-seal restores open state so abandon can close the stream (review r2, finding 4) _seal_open_draft pops the open entry and writes the local tombstone BEFORE awaiting transport I/O — correct ordering for the straggler race, but CancelledError is not an Exception: a cancel during the await bypassed all failure handling, leaving the remote stream live (visible streaming indicator until connector eviction) while the local state said 'nothing open'. The consumer's abandon pass — added for exactly this turn-death case — found nothing to close and no-oped. On CancelledError: restore the open entry, drop the premature tombstone (only if it is still ours), re-raise. The abandon path then seals the stream in place with the on-screen text. Regression: tests/gateway/relay/test_relay_seal_cancellation.py (2 tests: state restoration, and end-to-end cancel→abandon→remote seal). * fix(relay): thread anchors are placement, not turn identity — revive the placement-only fallback (review r2, finding 5) _match_open_draft's single-open-stream fallback was dead for its primary intended callers: metadata carrying thread_ts/thread_id (placement-only resolver lanes) was classified as having 'turn identity', so those sends never reached the fallback — probed: a plain final posted beside the still-open turn-keyed stream. Only per-turn MESSAGE ids are identity now. Thread-anchored and bare callers share the fallback: absorb into the chat's open stream when EXACTLY one is open; stay a plain send when several are (duplicate is recoverable, wrong-stream seal is not). Callers WITH a message id whose key misses never fall back — their identity is authoritative and a miss means the stream belongs to a different turn. Regression: 4 new tests in test_relay_turn_keying.py (thread-anchored seal, both ambiguous-stay-plain shapes, id-mismatch never steals). * fix(relay): random process nonce for draft-id seeding (review r2, follow-up 6) The epoch-millisecond seed (round-1 B3 fix) mitigates the restart-replay class but is not a uniqueness guarantee: two gateways starting in the same millisecond, a forked process inheriting the class state, or a clock step backwards can all mint colliding wire identities against the connector's per-(channel, draft_id) tombstone store. Seed from secrets.randbits(49) instead: collision probability negligible, no clock dependence, and ids + realistic per-process turn counts stay comfortably inside the connector's JS number range (draft_id?: number, 2^53). Regression test now spawns two real interpreters and asserts their seeds differ — the exact scale-to-zero restart shape, and both start within the same second so a clock-locked seed would fail it. * fix(relay): stamp per-turn Slack egress identity — cache is fallback only (R3-5) The connector (gateway-gateway#210) fills chat.startStream's recipient_user_id / recipient_team_id — required by Slack when streaming to a channel — from metadata.user_id / metadata.scope_id. The gateway stamped only slack_team_id per-turn and left user_id (and scope_id) to RelayAdapter._with_scope, whose per-chat caches are keyed on chat_id alone and overwritten by every inbound message: with users U1 and U2 running overlapping turns in one channel, U2's arrival overwrote the cache before U1's stream opened, and U1's stream carried U2 as recipient_user_id. _thread_metadata_for_source now stamps scope_id and user_id from the turn's OWN source (setdefault — explicit values win), so identity is turn-scoped data on the wire. _with_scope is unchanged and fill-only: the caches keep serving restart/synthetic sends that carry no per-turn identity, which is all they were ever safe for. Mutation evidence: reverting the run.py hunk sends test_thread_metadata_stamps_per_turn_user_and_scope and test_concurrent_turns_carry_their_own_identity red; restore returns green. The _with_scope fill-only tests pass on both trees (existing correct behavior, now pinned against regression). --------- Co-authored-by: Ben Barclay <ben@nousresearch.com>
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.
Fixes NousResearch#66030
Problem
The Dashboard's Channels page "Test" button (
POST /api/messaging/platforms/{platform_id}/test) reports<Platform> is connected.even when the gateway's auth posture denies all users — i.e., no allowlists configured (TELEGRAM_ALLOWED_USERS, etc.) andGATEWAY_ALLOW_ALL_USERSnot set.This is misleading: the platform adapter is technically connected, but zero users can actually interact with the bot.
Solution
After confirming the adapter is connected, the handler now calls
_check_messaging_platform_auth()which mirrors the auth logic fromgateway/authz_mixin.AuthorizationMixin._is_user_authorized():GATEWAY_ALLOW_ALL_USERSenv varTELEGRAM_ALLOW_ALL_USERS,DISCORD_ALLOW_ALL_USERS, etc.TELEGRAM_ALLOWED_USERS,DISCORD_ALLOWED_USERS, etc. (non-empty = users explicitly listed)PlatformRegistryfor custom auth env var namesThe warning is added as a
warningsfield in the response JSON alongside the existingok: True— no breaking changes to the API contract.Example response when in deny-all:
{ "ok": true, "state": "connected", "message": "Telegram is connected.", "warnings": "Connected, but no users are authorized — configure TELEGRAM_ALLOWED_USERS or set GATEWAY_ALLOW_ALL_USERS=true" }Platforms that bypass user-level auth entirely (
homeassistant,webhook,relay,api_server) are skipped.Changes
hermes_cli/web_server.py: added_check_messaging_platform_auth()(99 lines) and modified the connected-response block intest_messaging_platform()to include auth warnings