docs: add fork workflow documentation - #4
Conversation
ce6e585 to
9341508
Compare
|
9341508 to
1484230
Compare
|
1484230 to
24b0516
Compare
|
Rebased onto upstream main @ 0d0d27d (2026-04-12). Mechanical docs-only rebase, no conflicts. New head: 24b05166. |
24b0516 to
d9935c7
Compare
|
Refreshed PR body after FORK_WORKFLOW.md content update on 2026-04-12:
Branch force-pushed via |
d9935c7 to
5fdedcd
Compare
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
5fdedcd to
3d520c1
Compare
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
🔎 Lint report:
|
0106dfe to
9c641ef
Compare
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
9c641ef to
15b7e19
Compare
…NousResearch#34192) (NousResearch#34382) NousResearch#34192 reports Hostinger's 'Hermes WebUI' catalog crashes on startup with: /usr/bin/tini: No such file or directory The image moved from tini to s6-overlay as PID 1 (/init) earlier in 2026. Orchestration templates that still pin /usr/bin/tini as the entrypoint \u2014 like the Hostinger Hermes WebUI catalog \u2014 have no binary to exec and the container crashes immediately. Hermes has no control over the Hostinger catalog template, but we can make the image backward-compatible by symlinking /usr/bin/tini -> /init during the s6-overlay install step. External wrappers that exec /usr/bin/tini will land on the same s6-overlay reaper they would have landed on if they'd used the canonical /init entrypoint. The image's own ENTRYPOINT continues to be /init verbatim \u2014 the shim is purely for legacy external wrappers, not for the image's own runtime path. Once affected catalogs are updated, the symlink can be removed. Other issues NousResearch#34192 raises that are NOT addressed by this PR: * Problem #2 (UID 1024 vs 10000 mismatch): already fixed by NousResearch#33148 (S6_KEEP_ENV=1) and NousResearch#32412 (with-contenv shebangs). The Hostinger template likely needs to update its env-var propagation. * Problem #3 (incompatible session formats): RFC for pluggable SessionDB is tracked in NousResearch#23717. * Problem #4 (Telegram polling conflict): an operations problem on Hostinger's side, not in this codebase. This PR is scoped to the one issue that can be fixed inside Dockerfile: the missing /usr/bin/tini binary. Tests (3 in test_dockerfile_tini_compat_shim.py): - test_tini_compat_symlink_present Guard: the symlink line must exist in Dockerfile. - test_tini_compat_comment_explains_why The NousResearch#34192 anchor comment must be present so future readers know why the shim is there (avoid accidental removal). - test_entrypoint_still_init_not_tini Sanity check: ENTRYPOINT remains /init (s6-overlay). The shim is only for external wrappers. Refs: NousResearch#34192 Partial fix: addresses the immediate tini-binary crash. Catalog-side fixes still needed by Hostinger for the UID and session-format problems documented in the issue. Co-authored-by: Cursor <cursoragent@cursor.com>
…eSessionPage (NousResearch#43487) When auto-compression rotates the session tip (old #4 → new NousResearch#5), the incoming page carries the new tip but the previous list still holds the old one. The old tip's id differs from the new tip's id, so the existing id-only dedup in mergeSessionPage() preserves both as separate sidebar rows. Add lineage-level dedup: build a set of incoming lineage keys (`_lineage_root_id ?? id`) and filter survivors whose lineage key matches any incoming row. This mirrors the existing sessionPinId() logic used for pin stability. Fixes NousResearch#43483
15b7e19 to
9d70bc9
Compare
9d70bc9 to
2235f8a
Compare
…emory growth Follow-up review of the builder-declared cache boundary (NousResearch#81867) found three ways the split could silently stop paying off, or keep paying more than it should, on a long-lived gateway process. Flattening no longer consults the registry. `strip_anthropic_cache_control` matched the decorated split by looking the first block up in the prefix registry, so a mid-turn failover that re-decorates a request built many messages earlier (NousResearch#72626) would fail to flatten once _MAX_ENTRIES newer scaffolds had been registered in between, and would hand the next provider the two-part shape instead of the canonical string. The split is now matched by its shape: a marker on the *first* part of a user message is something no other decoration produces (list content otherwise gets its marker on the last part, and the two-part [static, volatile] split is role-gated to system), so the ""-join stays provably byte-exact without any process state. This drops `is_registered_stable_prefix` and one lock acquisition per stripped message. Lookups now refresh LRU position. A scaffold fired every minute by cron could be evicted by a burst of one-off skill invocations while still being the hottest prefix in the process, silently reverting it to whole-message caching. Registration now also evicts by total retained bytes (4 MiB). Entries hold whole expanded skill bodies, so a 32-entry cap alone does not bound memory. The newest entry is always kept, so a single oversized scaffold still gets a boundary instead of disabling the split. Tests: eviction-then-failover round-trip, LRU refresh on hit, byte-cap eviction, and oversized-single-entry survival. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he registry Follow-ups from review of NousResearch#82049: - extract append_user_instruction() into agent/skill_commands so the stable-prefix construction cannot drift between the skill and cron builders (the registered prefix must stay a byte-prefix of the built message); cron no longer imports the private _SINGLE_SKILL_INSTRUCTION - add the startswith guard to the skill builder registration site, matching the stronger cron guard - rename _MAX_BYTES to _MAX_CHARS (sum(map(len, ...)) counts characters, not bytes) and correct the comment - collapse find_stable_prefix's two-lock dance into a single critical section (scan is <=32 short-circuiting startswith calls, measured 2-4us; drops the snapshot copy and the TOCTOU re-check) - document the split-shape lifetime (marked-endpoint window) in the module docstring - add a contract test for the helper's byte-prefix invariant (mutation-checked)
A background memory/skill review (agent/background_review.py) forks a second, complete AIAgent in a daemon thread that deliberately shares the live agent's own session_id for prompt-cache warmth. Nothing previously stopped a user's next live turn from starting while that fork was still mid-conversation, letting both stream against the same session_id and credentials concurrently. That produced two observable failures: - Doubled prompt-token accounting on the live turn's own calls (the two concurrent request/response streams under one session_id confuse the token-usage bookkeeping), triggering premature context compression. - A lockup that a normal interrupt could not clear: the review fork is a fully independent AIAgent with its own _interrupt_requested flag, and was never added to the parent's _active_children list -- the only list AIAgent.interrupt() actually walks for cross-agent cancellation -- so a live-turn Ctrl+C had no propagation path to it at all. Fix, three files: 1. agent/agent_init.py -- add _background_review_agent / _background_review_lock tracking state to every AIAgent, mirroring the existing _active_children pattern. 2. agent/background_review.py -- the review fork now registers itself on the parent's _active_children right after construction (reusing the same list/lock interrupt() already fans out to for real subagent delegation), and unregisters on every exit path (success, the tool-whitelist finally, and the outer exception safety-net). All registration is defensive (getattr/try-except) so an AIAgent built without going through agent_init.py's setup degrades to "no cross-turn cancellation" instead of aborting the whole review. 3. agent/conversation_loop.py -- at the very start of every run_conversation() turn, if a prior background review is still in-flight, it is now proactively cancelled via interrupt() before the live turn proceeds -- fire-and-forget, non-blocking, adds no latency. Adds 3 regression tests to tests/run_agent/test_background_review.py, confirmed to fail against the pre-fix code via a scripted revert. Verified: ruff clean on all touched files; 66/66 background-review and interrupt-propagation tests pass; 256/256 across turn_finalizer + run_agent regression suites; no fork-only symbols in the diff.
…r test Simplify registration/unregistration to match delegate_tool.py's hasattr+getattr pattern instead of over-defensive try/except Exception blocks. Delete inspect.getsource() change-detector test (breaks on rename, proves nothing the behavioral test doesn't cover). Net: -73 lines, +35 lines = -38 lines.
…compaction aed114a taught _is_synthetic_compression_user_turn to recognize the max-iteration nudge as ephemeral runtime scaffolding rather than a human turn, since its role="user" metadata flag doesn't survive SessionDB projection and a crash/interrupt mid-turn can persist it durably — becoming the compaction anchor / auto-focus topic in place of the real task. conversation_loop.py's retry loop appends several more role="user" rows with the exact same "ephemeral, metadata-tag-only" shape, none of them recognized by the classifier: - The three _get_continuation_prompt variants (length-continuation nudge, tagged _length_continuation_nudge) — two fixed strings plus a third that interpolates the dropped-tool-call list. - _CODEX_INCOMPLETE_NUDGE (codex/responses reasoning-only retry). - The codex ack-continuation nudge (acknowledgment-only reply re-prompt). - The dropped-tool-call nudge (tagged _dropped_toolcall_nudge) — persisted across up to 3 consecutive retries before the finalization pop-loop strips it; an interrupt/crash before that pop can persist it same as the max-iteration case. Promote the previously-inline nudge strings to named module-level constants in conversation_loop.py (single source of truth for both construction and recognition), then extend the classifier to recognize all of them — exact match for the five fixed-content nudges, a stable-prefix check for the dropped-tool-call continuation variant (its tool list is interpolated so it can't be exact-matched, same treatment TODO_INJECTION_HEADER already gets). Imported lazily inside the classifier to avoid a module-load-order cycle — conversation_loop.py already imports FROM context_compressor.py at call time for the same reason.
…dge sibling
Fix: _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX ended with '(' but
_get_continuation_prompt still had f'({tool_list})', producing
'((write_file)' instead of '(write_file)'. Removed the '(' from
the prefix constant — the parenthesis belongs in the interpolation.
Widened: promoted the empty-response nudge (line 6993,
'You just executed tool calls but returned an empty response...')
to _EMPTY_TOOL_RESPONSE_NUDGE constant and added it to the
classifier's recognition set. Same bug class — its
_empty_recovery_synthetic metadata flag doesn't survive SessionDB
projection either.
Test: added parametrize case for the empty-response nudge (7→8 cases).
E2E: verified byte-for-byte string equivalence for all nudge constants.
A session row can say whether its work is open, merged or closed, and link to it. The join is the session's own repo + branch, asked of GitHub in one batched GraphQL request per repo (branch aliases, not a `gh pr list` page that a busy repo crowds ours out of), through the remote-aware git facade so a desktop on a remote gateway asks the backend's `gh`. Two ways a session's branch can't answer, both covered: - It ran on trunk. Fork PRs share our branch namespace, so asking about `main` badges a stranger's PR onto it — trunk is never asked about, and cross-repository PRs are dropped server-side either way. - It worked in a worktree, so the branch it recorded at start isn't where the PR came from. Creating a PR from the review pane binds the session to the branch it actually used, and for sessions that predate that, the PR is recovered from the transcript: `gh pr create` prints a bare PR url and nothing else, so a tool result whose whole output is one is a claim rather than a mention. Scanned read-only across profiles, once per session ever.
The publisher read the PR number from the CI run's pull_requests payload. GitHub keeps that payload empty for fork runs, so the job printed 'No pull request is associated' and stopped on every fork PR. Resolve the PR from the run's head owner, branch, and SHA instead. The SHA match skips runs that a newer push superseded. A fork PR also has no CI review comment, because the live poller skips forks. The publisher now logs this and exits clean instead of raising; the evidence stays in the workflow artifact.
The Kimi team noticed that traffic from Hermes Coding Plan users
identifies itself as Claude (User-Agent: claude-code/0.1.0) rather
than the actual client. They asked us to update the UA so they can
properly attribute traffic and understand how their services are
accessed — especially important as they open up to more third-party
agents.
Three code paths were sending wrong/attribution-less headers to Kimi:
1. run_agent.py — _apply_client_headers_for_base_url sent
{"User-Agent": "claude-code/0.1.0"} for api.kimi.com. Now sends
the same _AI_GATEWAY_HEADERS set used for Vercel AI Gateway:
HTTP-Referer + X-Title + HermesAgent/{version} User-Agent.
2. agent/anthropic_adapter.py — the Anthropic Messages path for
api.kimi.com/coding sent 'claude-code/0.1.0'. Now sends the same
three-header attribution set.
3. plugins/model-providers/kimi-coding/__init__.py — both kimi and
kimi_cn profiles sent a static 'hermes-agent/1.0' with no
HTTP-Referer or X-Title. Now sends the full three-header set with
a dynamic version, matching the pattern used by the gmi, fireworks,
xai, and ai-gateway provider profiles.
The attribution header set (HTTP-Referer + X-Title + User-Agent) is
the canonical Hermes pattern used for OpenRouter, Vercel AI Gateway,
Fireworks, and other providers that read these headers for traffic
attribution.
atomic_json_write() calls os.fsync(), which blocks until the write reaches stable storage. build_channel_directory() already offloads its builders with asyncio.to_thread (NousResearch#60794) but still called the persist step directly on the loop, so the Discord heartbeat waited on a disk flush.
Mirrors test_discord_builder_runs_off_event_loop_thread. Verified to FAIL against unpatched v0.19.0 and pass with the fix.
Completes the bug class from NousResearch#83906 — the same blocking fsync-on-event-loop pattern existed in two more async gateway paths: - slash_commands.py _handle_restart_command: two atomic_json_write calls for .restart_notify.json and .restart_last_processed.json were blocking on fsync inside an async function. Now offloaded via asyncio.to_thread. - run.py _clear_restart_failure_count: called from _handle_message_with_agent (async, per-turn path) after a successful agent turn. Made the method async and offloaded the atomic_json_write call via asyncio.to_thread. Caller updated to await. Shutdown-path calls in _stop_impl_body (_increment_restart_failure_counts, planned restart notification marker) are intentionally left synchronous — the event loop is draining/stopping and offloading adds complexity for no benefit.
…s-index workflows Three separate reds on main. Two are fixed here; the third needs no code. 1. tests/gateway/test_multiplex_busy_input_mode.py (blocks every merge) Fails "Python tests / Run tests slice 5/12" and therefore "All required checks pass". Semantic merge conflict between two PRs merged ~1h apart: a31be48 fix(gateway): respect routed profile busy modes (added the test) c8f235a feat(gateway): allow selective multiplex profile serving (added the gate) c8f235a taught _profile_name_for_source to reject a route whose target profile is not in the served set (profiles_to_serve). Each PR was green on its own base; neither ran against the other's merge result. The test asserts a route to profile "research" resolves to that profile's busy mode, but never patches profiles_to_serve — so it reads the runner's REAL on-disk profiles. "research" is not among them, the route is rejected before the busy-mode snapshot is consulted, and the assertion gets the gateway default: WARNING gateway.run: Rejecting profile route 'research-chat': target profile 'research' is not served AssertionError: assert 'interrupt' == 'steer' Patch profiles_to_serve for the assertion — the same seam every sibling test in tests/gateway/test_profile_resolution.py already patches (test_route_inside_allowlist_resolves, test_route_outside_allowlist_rejects). This also removes an ambient-state dependency: the test previously passed or failed based on which profiles happened to exist on the machine running it. Verified passing under an empty HERMES_HOME. Test-only. The serving gate from c8f235a is correct and left intact. 2. Skills-index workflows: local action used without actions/checkout check-freshness has failed on all 12 of its last 12 scheduled runs: ##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '.../.github/actions/get-app-token'. Did you forget to run actions/checkout before running your local action? ./.github/actions/get-app-token is a LOCAL composite action and cannot resolve without the repo on disk. skills-index-freshness.yml had no checkout step at all. The step is gated on `status != 'ok'`, so the watchdog broke exactly when it was supposed to file its issue — the live index is currently 521.4h stale (limit 26h) and nobody was told. An audit of all workflows for this bug class found one more instance: skills-index.yml's `trigger-deploy` job, which re-triggers the docs deploy so a refreshed index reaches the live site. Its sibling `build-index` job checks out; this one did not. That is plausibly why the index went stale in the first place. Both are fixed; the audit now reports zero remaining jobs that use a local action without a prior checkout. Pinned to the same actions/checkout SHA used by the other 35 call sites. 3. "Publish inline E2E evidence" — no fix needed Failed once at 13:33Z on a transient TLS error reaching api.github.com ("certificate is not valid for any names") while installing a gh extension. The last 25 runs of that workflow are 25/25 success. Infra blip, not a code defect.
The hand-off script's WinForms window was a 720x420 dashboard: streaming log box, wide marquee, warning label. Updating is a wait, not a dashboard -- it is now the same shape as the other update surfaces (NousResearch#75895): a fixed 280x320 panel, marquee loader, one title, one static line, following the OS light/dark theme (charcoal #232323 seeds, never brand blue). Failure gets a terse finale instead of a wall of log: 'Failed to update' + 'Run "hermes debug share" in a terminal to send a report' + Close (held max 5 minutes, then the relaunched Desktop re-surfaces the result banner as before). The result-json message points at debug share too. With nothing streamed to the window, the per-line stdout pump is gone: Invoke-HermesStep drains both pipes async (no deadlock on chatty children, no frozen marquee on quiet ones) and writes full output to the hand-off log afterwards, where hermes debug share picks it up.
scripts/desktop-update.ps1 moves to scripts/desktop-update/windows.ps1 (a compat forwarder stays at the old path for one asar/checkout skew cycle) and gains the shim: scripts/desktop-update/ui.html rendered in a chromeless Edge app window, fed done|error over a loopback /progress endpoint. The page is NousResearch#75895's hand-off screen ported verbatim (Fourier Flow loader, one title, one line, OS light/dark, charcoal dark seeds); failure is the terse card pointing at hermes debug share. The WinForms card stays as the no-Edge fallback, same shape. Salvaged from the web-shell spike: TcpListener runspace server, Edge --app spawn with throwaway profile, degradation ladder, -SelfTestUi. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
scripts/desktop-update/posix.sh is the mac/linux twin of windows.ps1: the Desktop spawns it detached and QUITS; it waits the app out, runs plain hermes update (retry-once across the update boundary, truthful desktop-rebuild completion), swaps/relaunches the .app bundle (mac) or the release/*-unpacked binary when its sandbox helper is launchable (linux), writes .hermes-update-result.json, and drives the same shim. Repo-owned, so every update refreshes the code that drives the next one. resolvePosixScriptHandoff mirrors the Windows resolver (with the flat-path fallback covering the scripts/ reorg skew).
applyUpdatesPosixInApp is gone: mac/linux Update now quits into the detached posix orchestrator, same shape as Windows. Deletes everything the in-app path dragged into main.ts -- runStreamedUpdate, the rebuild retry, the relaunch-outcome matrix (update-relaunch.ts/update-rebuild.ts and tests), shellQuote, resolveHermesCliBinary -- and with the app dead before the update starts, the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance (NousResearch#37532) is structurally unnecessary on the desktop path.
scripts/desktop-update/repro.sh drives the real code paths against a disposable HERMES_HOME under /tmp: shim/shim-fail (UI dry runs), fresh (literal install.sh), behind N (rewound checkout driven forward by the orchestrator), error (broken venv -> abort + result file). Exposed as npm run update:shim / update:shim:fail / update:repro:* from apps/desktop.
…escaping Address helix4u's review: - finish() now delivers the outcome BEFORE publishing it: mac bundle swap and the linux relaunch gate run first, then the result file, marker removal, and the shim event -- the app launch itself goes last so it can't race the result write. A gated/skewed linux install (AppImage/ deb/rpm, broken sandbox helper) surfaces its message in the result file AND holds the shim window open with it instead of closing on a false 'Opening Hermes...'. - mac swap is transactional with a checked rollback; a failed install restores the previous bundle and the result says so (exit 7 when even rollback fails). Failed 'open' rewrites the result truthfully. - linux gate is an exact port of the deleted update-relaunch.ts logic: anchored path-segment match on <root>/apps/desktop/release/linux-unpacked, chrome-sandbox absent = namespace build = fine, present = root+setuid required, with the real opt-outs (ELECTRON_DISABLE_SANDBOX, --no-sandbox among replayed args, or the Desktop vouching) instead of the invented HERMES_DESKTOP_NO_SANDBOX. collectRelaunchArgs/sandboxFallbackFromEnv live in updater-process.ts again; the Desktop passes filtered launch args (after --) and --relaunch-cwd so a deep-link or --no-sandbox launch survives the update. - result/status JSON strings are escaped (git permits '"' in branch names) and the result write is atomic (tmp + rename). - coverage: resolvePosixScriptHandoff + ported helpers in updater-process.test.ts (19 pass); repro.sh gate / npm run update:repro:gate asserts the whole gate matrix and round-trips a hostile branch name through the result JSON.
…repro The posix orchestrator inherited the Desktop's cwd, and parts of the update pipeline resolve the tree they mutate from the working directory -- the sandboxed behind-repro caught it updating the DEVELOPER'S primary checkout (cwd at spawn time) while reporting success against the sandbox. cd "$INSTALL_ROOT" before running hermes update, matching the cwd:updateRoot contract of the deleted in-app path. Verified: rerun leaves the outside checkout untouched (reflog clean). repro.sh fresh used a --no-interactive flag install.sh doesn't have; non-TTY stdin (</dev/null) + --skip-setup is the real non-interactive contract.
…hestrators gille's round-2 review: the terminal lifecycle claimed outcomes the launch hadn't delivered yet. - posix finish() reorders: outcome -> durable result+marker -> LAUNCH WITH ACCEPTANCE -> terminal event. mac acceptance is open's exit code (launchd rejects broken bundles loudly); linux verifies the setsid child is still alive 1.5s after spawn, so an instant exec failure downgrades to a held 'manual' state + truthful result instead of a vanished 'done'. Gated skew/manual outcomes publish a real 'manual' event (new third shim state -- still zero logic in the page). - Renderer-free linux recovery: when no chromium-family browser exists, manual/error outcomes fire notify-send/zenity/kdialog best-effort so a gated non-relaunch is never a silent disappearance. - windows.ps1 mirrors the contract: Start-DesktopRelaunch returns verified acceptance (WMI pid alive / fallback process alive; dying before the window appears counts as failure), and the finally block downgrades to Show-ManualFinale + rewritten result when the launch didn't land. Error path still relaunches after showing itself. - repro.sh launch / npm run update:repro:launch: real-orchestrator matrix for instant-exit relaunch downgrade and skew-message surfacing. - posix.sh cds into the install root before hermes update (found by the sandboxed behind-repro: parts of the update resolve the mutated tree from cwd, which is the Desktop's cwd -- it updated the DEVELOPER'S checkout while reporting success against the sandbox).
…covery surface gille's round 3: - cd into the install root FAILS CLOSED (set -u without set -e let a failed cd continue hermes update in the caller's tree -- the exact wrong-tree class the correction exists to kill). Honest result, exit 3. - A supplied mac relaunch target that is missing is a REJECTED launch -> manual downgrade; the launch matrix asserts the downgrade instead of codifying the old false success. A mac swap-failure DONE_NOTE now still relaunches the kept/rolled-back bundle before publishing manual. - notify_fallback: every rung falls through on EXECUTION failure (a notify-send that can't reach D-Bus no longer eats the message), mac gets osascript (present on every macOS -- Safari-only machines have no chromium shim), and the no-surface terminal case is an explicit logged contract: the result file carries the outcome to the next boot. - update:repro:fresh passes --non-interactive explicitly (prompt_yes_no falls back to /dev/tty, so </dev/null was not equivalent).
Round 4 of helix4u's review — the durable fallback is now real: - Result protocol gains `manual`: an ok result the user still must act on (reopen the app, reinstall the GUI package, fix the sandbox helper). Both orchestrators set it on every DONE_NOTE/downgrade path; the Desktop consumer surfaces manual results in a real dialog on next boot instead of a log line — the browserless-Linux disappearance now ends at a visible dialog, worst case one boot later. Older result files without the field parse as manual:false (covered). - notify ladder verifies EXECUTION, not existence: zenity/kdialog must survive their first second (an instant death means no display and falls through); the no-surface case is an explicit best-effort contract whose guaranteed channel is the result dialog. - mac DONE_NOTE + failed relaunch of the kept/rolled-back bundle is no longer swallowed (`|| true` dropped): the durable message carries both facts. - launch/gate matrices assert `manual` in the result JSON; consumer round-trip tested in handoff-result.test.ts.
Fix NousResearch#78906 当部署同时启用 basic 密码 provider 与一个 OAuth/OIDC session provider 时, list_session_providers() 会把密码 provider 也计入 "exactly one candidate" 判断(密码 provider 虽是 session provider,但下一行就会因 supports_password 被原生 OAuth broker 流程拒绝),导致 len == 2、自动选择被跳过,桌面端 空 provider 登录返回 404 "Unknown provider: ''"。 修复:自动选择只在可 broker 的 provider(supports_session 且非 supports_password)中计数,与 /api/status 的 native_pkce 能力宣告使用同一 "brokerable" 定义;当没有任何可 broker provider 时保留原有选择逻辑, 让显式的 400 错误继续解释密码 provider 不支持原生 OAuth。 新增回归测试:basic+OIDC 并存时自动选中 OIDC、单 OAuth provider 自动 选中、多 OAuth provider 歧义 404、纯密码部署保留 400。
A manual:true hand-off result is the durable action-required channel: on a browserless Linux box with no working notifier, the boot dialog is the first and only place the message ever surfaces. The 30-minute freshness gate discarded it if the user reopened Hermes later, stranding exactly the machine the channel exists to serve. Parse before the age check and skip the window for manual results; the file is still unlinked before any age check, so it's surfaced at most once. Ordinary results still expire. Regression: a stale ordinary result is discarded (and consumed) while a stale manual result is still returned once.
…owngrading The Browser Use CLI became the default browser backend, but nothing provisioned it: users without uv/uvx (field report from DongyangHe on macOS) silently fell back to the built-in browser tools with no notice. - install_cli() in tools/browser_use_cli.py: uv tool install browser-use via the managed uv (bootstrapped on demand), linked into $HERMES_HOME/bin (UV_TOOL_BIN_DIR) - _find_cli() now also probes $HERMES_HOME/bin for browser-use/uvx — Hermes' managed uv is not on the user's PATH - hermes tools post_setup actually installs (Camofox standard) instead of printing instructions - install.sh / install.ps1 provision the CLI at install time (best-effort, non-fatal, honors --skip-browser) - CLI startup shows a one-line notice (24h rate-limited) when the default backend downgraded to the built-in tools
… fallback - install.ps1 must stay pure ASCII (PowerShell 5.1 ANSI code-page decoding, NousResearch#66994/NousResearch#67000): em-dash -> '--' - tests/test_managed_runtime_resolution.py: install_cli()'s shutil.which('uv') is a reviewed fallback AFTER ensure_uv() misses
…-f2b15435 feat(browser): auto-install the Browser Use CLI instead of silently downgrading
… press (NousResearch#83677) * fix(relay): stop sibling gateways answering another instance's button press A Discord button press arrives on the passthrough plane, and the connector fans a passthrough forward out to EVERY live gateway session of the tenant (relayServer.routeBusMessage delivers `passthrough` via sessionsByTenant), unlike a message, which it narrows to the admitted instance set. The prompt went out from exactly one instance and _pending_prompts is process-local, so every sibling gateway saw an answer for a prompt it never minted, could not tell that from its own prompt expiring, and fell through to chat dispatch -- where the option-shaped text ("/c1") is not a real command and run.py replied "Unknown command `/c1`". One copy per sibling, under the single real ack. Prompt ids are now minted as `<per-process nonce>.<8 hex>`, so an answer can be attributed to the process that minted it. A prompt answer is always consumed, never re-dispatched as chat: a sibling's prompt and a repeat answer are both dropped silently, and an expired prompt of our own gets a short "no longer waiting" notice from the owning gateway only. Ids stay inside the connector codec's contract ([A-Za-z0-9_.-], <=32 chars, 64-byte callback budget -- verified against promptCodec.ts: 52 bytes worst case with a full-length option id). An id with no nonce segment (a prompt in flight across an in-place upgrade) is still treated as ours. Tests: 4 added, each verified to fail without the fix. Full relay suite green (160 tests). * style(tests): ruff-format the added relay prompt tests
…ousResearch#84074) * feat(relay): ambient token endpoint mode for gateway.idp.token_url When gateway.idp.token_url is configured WITHOUT client_id/client_secret, treat the URL as a metadata-server-style ambient credential endpoint: plain GET, response body is the token (raw JWT or {"access_token": ...} JSON envelope). Covers workload-identity proxies such as Domino's $DOMINO_API_PROXY/access-token, which mint short-lived user-scoped OIDC tokens with no client registration. Previously this configuration was a hard error (client_id/client_secret missing), so no working deployment changes behaviour: creds present keeps the OAuth2 client_credentials POST, no token_url keeps Nous Portal. The misconfig error now self-diagnoses (names the ambient fallback and how to select the client_credentials grant instead). * fix(relay): reject short plain-text bodies in ambient token shape gate Review finding: the shape gate accepted any base64url-alphabet word, so an IdP answering the ambient GET with a terse error body ('unauthorized', 'error', 'null') had that word returned as a bearer token instead of the fail-closed misconfiguration error. Tighten the gate to JWT-like dotted tokens (3+ segments) or long opaque tokens (>= 32 chars); short bare words now raise the self-diagnosing ambient error. * fix(relay): partial IdP client credentials keep the loud error, never select ambient GET The ambient-endpoint dispatch used 'not client_id or not client_secret', so configuring exactly one credential (a mistyped client_credentials setup) silently issued a GET at the IdP token endpoint and then raised 'no client_id/client_secret configured' — factually wrong for that operator, and a stray request the old hard error never made. Ambient mode now requires NEITHER credential; a partial pair raises immediately, names the missing key, and issues no HTTP request (tests assert urlopen is never called). Docstring and relay.md now say 'neither' instead of 'without'. * fix(relay): ambient JSON envelope requires a string access_token, no coercion Review finding (P2): the JSON-envelope branch accepted any truthy access_token via str() coercion — a number became '12345…', a boolean became 'True', an object became its Python repr — bypassing the fail- closed contract and deferring the failure to the connector, where it hides the real endpoint problem. The envelope value must now be a non-empty string, the same contract the client_credentials path enforces on its token response. Deliberately NO shape gate on envelope values: an envelope is an intentional token response (mode-1 symmetry), and opaque tokens may use the standard-base64 alphabet the raw-body gate rejects. Mutation check: reverting the branch to str() coercion sends the 3 coercion tests red (3 failed, 15 passed). --------- Co-authored-by: Ben Barclay <ben@nousresearch.com>
…ndow Detached update hand-off on every OS: quit → hermes update → reopen, with one dumb shim window
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
During the 2026-04-22 upstream rebase (main 0d0d27d → c96a548), the memorized incantation `jj rebase -s 'roots(::dashed/my-hermes ~ ::main)' -d main` rewrote docs/fork-workflow onto the new main but left the integration merge dashed/my-hermes with its non-rebased parent still pointing at OLD main. An orphan conflicted octopus-merge from an earlier rebase state also surfaced under `jj log -r 'conflicts()'`. Add a new section 'Re-parenting the Integration Merge After a Rebase' to FORK_WORKFLOW.md documenting detection, repair (`jj abandon <orphan>` + `jj rebase -r dashed/my-hermes -d main -d docs/fork-workflow`), the revset semantics that cause the staleness, and a concrete 2026-04-22 example showing parents going from {5fdedcd2, 0d0d27d} to {5fdedcd2, c96a548}. Bump the Last updated footer to 2026-04-22.
2235f8a to
7f78741
Compare
Summary
Adds
FORK_WORKFLOW.mddocumenting the modular feature branch strategy for maintaining this fork.Contents
maintracks upstream, feature branches based onmain, integration merge via jj)origin= fork vsupstream= NousResearch/hermes-agent)mainfrom upstreammain, then rebuild the integration merge)dashed/my-hermes) explanationWhy on its own branch?
This file was previously added directly to the integration merge commit and was lost when the integration was rebuilt. Files on the integration branch are dropped on every rebuild — only files on dedicated feature branches survive. This PR ensures
FORK_WORKFLOW.mdpersists across integration rebuilds.That rule has earned its keep: since this PR was opened, two sibling feature branches (
fix/slack-mrkdwn-formatting,fix/container-systemctl-status) have been absorbed/superseded upstream and dropped from the integration. Without dedicated branches they would have been silently lost on the next rebuild. As of 2026-04-12,docs/fork-workflowis the only active feature branch remaining in the integration merge.Current state
0d0d27d4(2026-04-12)8b861b77 → 0d0d27d4, no conflictsd9935c71dashed/my-hermes@2b2bdbe9817dTest plan
FORK_WORKFLOW.mdrenders correctly on GitHubdashed/my-hermes@2b2bdbe9817dsuccessfully combines this branch withmain(single-file diff:FORK_WORKFLOW.md)