Commit Graph

24361 Commits

Author SHA1 Message Date
Teknium 0a8cdec697 fix(desktop): silence wsl.exe stderr banner + detached explorer relaunch rung
Follow-ups to the salvaged WSL-bridge gating (#66447):
- wsl-path-bridge.ts: discard wsl.exe stderr so the 'WSL is not installed'
  banner can never leak into an attached console on WSL-less machines (#80184).
- scripts/desktop-update/windows.ps1: add an explorer.exe-mediated detached
  relaunch rung between the WMI attempt and the tethered Start-Process
  fallback. When Win32_Process.Create fails (observed ReturnValue 8), the
  Desktop no longer re-attaches to the hand-off console, so its stdout stops
  flooding the window and the console can close.
2026-08-21 00:49:41 -07:00
Tranquil-Flow deec043276 fix(desktop): scope WSL bridge state by profile 2026-08-21 00:49:41 -07:00
Tranquil-Flow b634032fa4 fix(desktop): gate WSL bridge for remote backends
Seed backend-mode state before creating the first window and update it after runtime resolution. Keep remote reconnects gated until a local backend is confirmed.

Cover Windows child-process suppression and bridge state transitions.
2026-08-21 00:49:41 -07:00
Teknium 930be347f1 feat: keyless flag on the provider catalog — GUI contract tests exempt anonymous providers
opencode-free broke two provider-surface contract tests: 'api_key
providers must expose a credential env var' and 'GUI ⊇ hermes model
universe'. Both premises assume a credential exists. Add a keyless flag
to HermesOverlay + ProviderDescriptor (same derived-exemption pattern as
virtual providers) so any future anonymous provider is covered without
hardcoded slugs. Nothing to configure = no Providers-tab card, by design;
the model picker remains the selection surface.
2026-08-21 00:24:32 -07:00
Teknium a63da06340 docs: opencode-free in provider choice lists (cli-commands, aux providers, fallback table) 2026-08-21 00:24:32 -07:00
Teknium ca06b87689 feat: opencode-free is fully keyless — no env var, no account, anonymous wire
Reworks the salvaged OpenCode Free provider to match the tier's real
auth contract (verified live 2026-08-21): the Zen relay serves free
models ANONYMOUSLY and 401s any unrecognized bearer, so the provider now
declares no credentials at all and routes every model through the shared
keyless machinery from the Ox Alpha fix (empty Authorization default
header overriding the SDK bearer).

On top of the salvaged base:
- auth.py: no api_key_env_vars; drop the keyed-auth special case
- runtime_provider.py: restore the plain fail-closed path (opencode-free
  never reaches it — the keyless runtime resolves first)
- models.py: opencode-free joins the opencode family (prefix stripping,
  Zen endpoint routing incl. muse->responses); keyless predicate extended
  with unsuffixed free slugs (big-pickle); free runtime pins EVERY
  opencode-free model keyless; curated catalog replaces the models.dev
  cost==0 filter (it lags reality: deepseek-v4-flash-free stayed 'free'
  there after its promo ended and the relay began 401ing it — delisted)
- agent_runtime_helpers.py: replace the httpx transport-sharing auth-strip
  wrapper with the shared header policy (no proxy-mount loss)
- model_setup_flows.py: skip the API-key prompt for opencode-free
- plugin profile: keyless headers, no env vars
- .env.example + providers.md: keyless docs (no OPENCODE_FREE_API_KEY)
- tests rewritten to the keyless contract, incl. catalog-membership
  invariant (every curated model must satisfy the keyless predicate)

E2E: full AIAgent turns with zero keys complete on x-preview-f-free via
provider opencode-free and alias 'free', incl. a real terminal tool
round-trip; muse routes to /v1/responses; picker lists 8 keyless models.
2026-08-21 00:24:32 -07:00
Rudraksh Chahal 28a9b6c565 feat(providers): add OpenCode Free provider with keyed auth and opencode User-Agent
Adds an OpenCode Free provider plugin. Free model discovery uses models.dev
(cost.input == 0 AND status != "deprecated"), matching opencode CLI's exact
filter logic.

The free tier requires a real account API key and throttles third-party
clients by User-Agent:

- With OPENCODE_FREE_API_KEY configured, the key is sent as a Bearer token
  and requests identify as "opencode/latest".
- Without a key, the keyless fallback strips the SDK's always-injected empty
  Authorization header and still sends the opencode User-Agent.
- The credential resolver no longer blanks OPENCODE_FREE_API_KEY
  unconditionally (the stale keyless-tier assumption), and credential-pool
  exhaustion no longer surfaces the misleading "Set OPENCODE_FREE_API_KEY"
  message.

Co-authored-by: Jean-François <jfm@laposte.net>
Signed-off-by: Rudraksh Chahal <131520192+rudrakshchahal@users.noreply.github.com>
2026-08-21 00:24:32 -07:00
kshitijk4poor 8e77d03188 test(prompt_caching): home empty-tools fallback test in TestPromptCachePlan
It exercises build_prompt_cache_plan's direct_native_tool_cache fallback,
not repeated apply on pre-decorated input, so it belongs with the other
plan-layout tests rather than in TestApplyIdempotency.
2026-08-21 12:23:47 +05:30
kshitijk4poor c26357ad6a refactor(prompt_caching): shallow strip copy, exact-count guards, dedupe idempotency tests
Follow-up to the #90972 salvage:

- strip loop: copy.deepcopy(msg) -> dict(msg). strip_anthropic_cache_control
  is copy-on-write on content parts by contract (pops the top-level key,
  rebuilds content lists/part dicts fresh), so a shallow top-level copy
  preserves the caller-non-mutation guarantee — verified for all four
  marker shapes — and removes the redundant second deepcopy the re-mark
  path paid on already-decorated input. Docstring updated to match.
- tests: moved the surviving idempotency tests into
  tests/agent/test_prompt_caching.py (where this module's tests live) as
  TestApplyIdempotency; dropped the three tests that duplicated existing
  coverage (dynamic_tool_accounting ~= TestPromptCachePlan::
  test_copies_sections_and_keeps_canonical_tools_plain which already
  asserts == 4; can_carry_marker_envelope_vs_native ~= TestCanCarryMarker;
  never_exceeds_four_markers subsumed by the idempotency test).
- exact-count assertions per review: idempotency fixture pins == 4,
  no-tools fallback pins == 3 (marker loss can no longer masquerade as
  safety); added the one new _can_carry_marker assertion (native=True
  empty assistant) to TestCanCarryMarker.
- new part-level stale-marker mutation guard (the other detection branch,
  where part-dict aliasing is the risk); fails on pre-fix base with
  marker accumulation (9 > 4), passes with the fix.
2026-08-21 12:23:47 +05:30
joaomarcos 0fc52b055f fix(prompt_caching): make apply_anthropic_cache_control idempotent on pre-decorated input
apply_anthropic_cache_control never stripped pre-existing cache_control
markers before placing new ones, so calling it twice (or handing it
messages a prior call already marked) accumulated markers past
Anthropic's 4-breakpoint limit and produced HTTP 400
'cache_control can only be specified up to 4 times'.

Strip any pre-existing markers from per-message copies before marking,
mirroring the strip-then-mark pattern build_prompt_cache_plan already
uses. Only messages that already carry a marker pay the copy cost; the
copy-on-write contract (caller-owned messages are never mutated) is
preserved. Repeated calls now converge to byte-identical output.

Salvaged from #90972 by @JoaoMarcos44 (net diff of the PR's commit
stack, intermediate reverts collapsed).

Related: #90971
2026-08-21 12:23:47 +05:30
xxxigm a86569bd11 test(teams-pipeline): cover quoted-user Graph @odata.id paths
Lock in users('{id}')/onlineMeetings('{id}') parsing, job creation from that
notification shape, and replay that re-reads a stored transcript id.
2026-08-21 12:15:36 +05:30
xxxigm b577353636 fix(teams-pipeline): parse Graph users('id') meeting paths
getAllTranscripts @odata.id often uses users('{organizer}')/onlineMeetings('{id}'),
which the slash-only parser missed, so new jobs still stored the transcript id
and hit the refuse-GET guard. Accept the quoted-user form and re-parse the
stored notification on run so replay picks the meeting id.
2026-08-21 12:15:36 +05:30
Teknium 15328b4db7 test(gateway): accept all_profiles kwarg in macOS reaper _get_service_pids mocks 2026-08-20 23:41:00 -07:00
Teknium 42bd567a80 fix(gateway): widen orphan-reaper service exclusion to the whole launchd fleet
Follow-up to the #74075 salvage: _reap-path _get_service_pids() call now
passes all_profiles=True. With the ps scan fixed, the reaper's process
scan surfaces sibling-profile launchd gateways on macOS; excluding only
the current profile's label would misclassify them as unsupervised
orphans and reap them (same class as the update-sweep sites the
contributor fixed). Also refresh the stale 'ps -A eww' comment.
2026-08-20 23:41:00 -07:00
Tranquil-Flow d8047c303b fix(gateway): BSD-compatible ps flags and all-profile launchd pid discovery (#74075)
- Replace ps -A eww with ps -Aww: the BSD e flag is illegal
  on macOS/BSD ps, making the fallback silently return [] on every macOS
  machine. The matcher only needs argv (not env vars), so e is
  unnecessary. -ww keeps unlimited-width output on both BSD and
  procps ps.
- Add all_profiles parameter to _get_service_pids(). When True
  on macOS, enumerate every ai.hermes.gateway* launchd agent across
  profiles via bare launchctl list instead of only the current
  profile's label. This prevents the update sweep from misclassifying
  sibling-profile launchd gateways as manual processes (#73626).
- Thread all_profiles through find_gateway_pids() to
  _get_service_pids().
- Update two _get_service_pids() call sites in update_cmd.py to
  pass all_profiles=True so the update fleet sweep excludes every
  service-managed gateway across all profiles.
- Add TestPsFallbackBsdCompat: verifies ps argv uses -Aww
  not -A eww, and that pid=,command= output columns are present.
- Add TestGetServicePidsAllProfiles: verifies default scope uses
  launchctl list <label>, all_profiles uses bare launchctl list
  with prefix filtering, handles empty/broken output gracefully, and
  preserves systemd behavior.

Tranquil-Flow
2026-08-20 23:41:00 -07:00
Brooklyn Nicholson 524b062289 fix(desktop): don't re-list a just-unpinned session under Pinned
The Pinned section falls back to the server `pinned` flag for rows the
local set doesn't hold, so a backend pin stays reachable when
localStorage is cold (#85969). But an unpin leaves the local set the
instant the user clicks, while the loaded row keeps reporting
`pinned: true` until a page issued after the PATCH lands — so the
fallback read the user's own unpin as a foreign pin, parked the session
at the bottom of Pinned, and only released it a refresh cycle later.

session-pin-sync already knows which rows its own in-flight writes
contradict; publish that fence and have the fallback skip them.
2026-08-21 01:19:15 -05:00
Teknium c01cd26f95 feat: stealth/ox-alpha free model in the OpenRouter catalog
Adds OpenRouter's free "Ox Alpha" stealth reasoning model
(stealth/ox-alpha) to the OpenRouter fallback snapshot, plus the
provider-agnostic metadata it needs:

- OPENROUTER_MODELS: free-tier entry (1M ctx)
- DEFAULT_CONTEXT_LENGTHS: ox-alpha -> 1,048,576 (verified against
  OpenRouter live /api/v1/models; without this the slug fell through
  to no match)
- reasoning_timeouts.py: 300s stale floor for ox-alpha and the
  OpenCode Zen twin slug x-preview-f-free (reasoning model,
  long-horizon agentic work per its model card)
- model-catalog.json regenerated

Pricing snapshot skipped: openrouter bills via official_models_api
(live pricing; model is free anyway).
2026-08-20 23:12:38 -07:00
Teknium 1017a56274 fix: OpenCode Zen free-tier models (Ox Alpha) work keyless — any bearer 401s them
The Zen relay serves *-free models (x-preview-f-free / Ox Alpha) ONLY
anonymously: any Authorization bearer it doesn't recognize is a 401
'Invalid API key' — including our no-key-required placeholder and valid
OpenCode GO subscription keys. The Go relay doesn't serve the free tier
at all ('Model x is not supported'). So the free model failed for every
Hermes user: keyless setups got the placeholder bearer, and OpenCode
subscribers sent a Go key to a relay that rejects it.

Fix (class-wide for all 8 current *-free Zen slugs, not just Ox Alpha):
- hermes_cli/models.py: is_opencode_zen_free_model / opencode_zen_free_runtime
  / opencode_zen_free_headers — one shared policy: free slugs pin to the
  Zen relay with a keyless placeholder and an empty Authorization header
  that overrides the OpenAI SDK's 'Bearer <key>'.
- runtime_provider.py: free slugs route through the keyless runtime before
  the credential-pool/explicit/api_key paths (no key required; Go
  selections heal to Zen). Paid models still fail closed without a key.
- agent_init.py + auxiliary_client.py: the placeholder key swaps in the
  empty-Authorization headers at both client-build chokepoints.

Verified live (2026-08-21): anonymous chat/completions 200 incl. tools,
streaming, parallel; bad bearer 401; full E2E AIAgent turn with a real
terminal tool round-trip completes keyless under both opencode-zen and
opencode-go providers. Sabotage run: routing tests fail without the fix.
2026-08-20 23:05:57 -07:00
brooklyn! 02e270a47e fix: editing a message in an old session fails (profile DB + window-relative ordinal) (#91302)
* fix(gateway): persist prompt.submit truncation to the session's own profile DB

`_get_db()` returns the LAUNCH profile's SessionDB handle. App-global
remote mode gives a session its own profile (`session["profile_home"]`)
whose transcript lives in that profile's `state.db`, so a write keyed on
`session_key` that goes through `_get_db()` addresses the wrong database.

In the `prompt.submit` truncate branch that has two consequences. The
edit/resend never sticks — `session.resume` reopens the profile db and
resurrects the undone turns — and when the launch profile happens to hold
a row under the same session id, the truncated transcript is inserted
into a profile the session does not belong to.

It also silently voids the branch's own fail-closed contract. The handler
persists before it rewrites `session["history"]` precisely so that a
failed write refuses the turn and leaves memory and DB aligned; that only
holds if the handle it checks is the one that owns the row.

`_session_db(session)` is the profile-aware resolver that already exists
for this: the profile's `state.db` when `profile_home` is set, otherwise
the shared launch handle. Non-profile sessions are unaffected —
`_session_db` borrows the same shared handle and leaves it open.

`active_only=True` and `archive_dropped=True` are carried through
unchanged; only the handle the call is made against changes.

* fix(gateway): resolve the /undo command against the session's own profile DB

`command.dispatch`'s `/undo` branch opened the launch profile's handle via
`_get_db()`, but every read and write under it is scoped by session id:
`list_recent_user_messages`, `rewind_to_message` and the
`get_messages_as_conversation` reload all key on `session_key`.

For a session with its own profile (`session["profile_home"]`) the rows
live in that profile's `state.db`, so against the launch handle
`list_recent_user_messages` returns nothing and the command fails closed
with `4018 "no user messages to undo"` — for the entire session, on every
invocation, even though the transcript is right there in the profile db.

Route the whole branch through `_session_db(session)`, which yields the db
that owns the session's row and closes a profile handle on exit. Sessions
without a profile keep borrowing the shared launch handle exactly as
before, so this is behaviourally identical for them.

* fix(gateway): read /history and /context from the session's own profile DB

`_format_live_history_output` and `_format_live_context_output` rebuild the
transcript from the database rather than from `session["history"]`, because
the in-memory list is empty for a session this process did not run itself.
Both reads are scoped by session id but were issued against `_get_db()`,
the launch profile's handle.

A session with its own profile (`session["profile_home"]`) keeps its rows
in that profile's `state.db`, so both reads come back empty and the
commands under-report: `/history` renders "No conversation history yet."
and `/context` falls back to the empty in-memory list and reports a
conversation of zero messages. Both swallow their exceptions, so there is
no error either — just a wrong answer about the user's own transcript.

Resolve both through `_session_db(session)`, the profile-aware resolver
used by the rest of the session-scoped paths.

* test(gateway): cover session-scoped transcript ops against a profile DB

Regression coverage for the three session-scoped sites that resolved
against the launch profile's handle instead of the db owning the
session's row. Each test drives the real JSON-RPC entry point with a
session carrying `profile_home`, seeds the transcript into the profile's
own `state.db`, and asserts against both databases.

Per site, with the production change reverted to its pre-fix form:

- `prompt.submit` truncation — `test_truncation_persists_to_the_profile_db`
  and `test_truncation_does_not_copy_rows_into_the_launch_profile` fail.
  The second seeds a row under the same session id in the launch db so the
  foreign write succeeds instead of failing a key check, which is the case
  that copies a transcript into a profile it does not belong to.
- `/undo` — `test_undo_rewinds_the_profile_transcript` fails with
  `4018 "no user messages to undo"`.
- `/history` and `/context` — `test_history_reads_the_profile_transcript`
  and `test_context_reads_the_profile_transcript` fail, reporting an empty
  conversation.

`test_undo_still_uses_the_shared_handle_without_a_profile` and
`test_truncation_without_a_profile_uses_the_shared_handle` pin the
unchanged path: with no `profile_home` the resolver must borrow the shared
launch handle and leave it open. Both stay green in every direction, so a
future change cannot satisfy the profile cases by abandoning the shared
one.

* fix(desktop): aim truncations by durable id alone on tail-only transcripts

The cold-open transcript is a newest-first prefetch page
(LATEST_SESSION_MESSAGES_LIMIT = 120) with the resume RPC sent
omit_messages — older rows only arrive via "Show earlier" backfill.
planEdit/planReload/planRestore still counted truncate ordinals over
that windowed list, so every edit/reload/restore in a session longer
than the prefetch page sent a window-relative ordinal alongside the
durable row/message id. The gateway's #82959 cross-check resolved the
durable id to its full-history ordinal, read the offset as drift, and
refused with 4030 — making the Edit affordance permanently dead in
long sessions.

When the transcript may be tail-only (the transcript-tail
bookkeeping's possiblyTruncated), drop the client ordinal and address
the truncation by durable id alone — the same rule runRewindSubmit
already applies to content-resolved row ids (#87059). The ordinal
tripwire stays on whenever the transcript is complete.

Closes #88082

* fix(desktop): drop client rewind ordinal whenever a durable id is present

#88092 gated the drop on tail-only prefetch. After in-place compact the
live scrollback is treated as complete, so Restore still sent a
display-lineage ordinal next to a resolved row id and the gateway
refused with 4030 (#89244). prefix_user_count is structurally 0 on
in-place because get_ancestor_display_prefix is cross-session.

Same choke point: if a durable truncate_before_row_id or a real
truncate_before_message_id is present, omit the client ordinal.
confirm_empty_truncate is still carried from a caller ordinal of 0.
Unknown ids still fail closed at 4018.

Closes #89244

---------

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: zengzheqing <yuntianqing@yahoo.com>
2026-08-21 06:02:19 +00:00
brooklyn! 6e64e6b9c6 fix(desktop): Windows glass windows stop rendering when they lose focus (#91307)
* fix(desktop): stop layering Windows glass windows, and don't make them transparent

A Windows chat window under glass rendered while focused and went dead the
moment it lost it. Two things put it on a compositing path DWM will not draw
acrylic behind, both no-ops that looked free:

`opacity: windowOpacity()` was passed on every window. Under glass on Windows
fade is 0, so the value is always 1 — but Electron's `SetOpacity` calls
`SetLayered()` and `SetLayeredWindowAttributes(..., LWA_ALPHA)` before it looks
at the value, and nothing ever takes `WS_EX_LAYERED` back off. A layered window
composites through the legacy redirection surface, which Windows documents as
mutually exclusive with `UpdateLayeredWindow`. Opacity is now only passed when
the state actually fades, and the runtime path keeps setting it for a window
that is already faded so it can still come back to opaque.

`transparent: true` was set on every glass-capable Windows chat window, on the
premise that DWM materials only reach the client area that way (electron#49443,
which was closed as need-info against an EOL Electron 28). They do not need it:
`IsTranslucent` answers yes off `background_material_` alone, which is what
gives the page its transparent default backing, and `SetBackgroundMaterial`
flips widget translucency live. Its one gate is a frameless window, and
`titleBarStyle: 'hidden'` already satisfies it. What `transparent` did add was
permanent — the widget pinned to kTranslucent for the window's whole life, so
even glass-OFF windows paid a DirectComposition redraw per frame
(electron#39895), plus the documented transparent-window limits, including that
a resizable transparent window is unsupported and breaks (electron#48421).

Both landed latent in #89837 and only surfaced when #90587 turned glass on by
default and dropped the opaque backing that had been hiding them.

* docs(desktop): name the one thing the opacity guard cannot undo

Electron exposes no way back off WS_EX_LAYERED, so a Windows window that has
been faded once keeps the layered compositing path until it is recreated. Not
opening the door on the default path is the whole of the fix; say so where the
guard lives rather than leaving a reviewer to work out the gap.
2026-08-21 05:57:14 +00:00
Teknium 40643cbaf9 docs(telegram): rich_drafts controls draft rendering, not the draft transport 2026-08-20 21:58:18 -07:00
Teknium 216c98aaed fix(gateway): scope draft-final finalize skip to fresh persistent sends
The salvaged skip condition keyed on _use_draft_streaming alone, which
also suppressed the explicit REQUIRES_EDIT_FINALIZE pass when a
draft-streaming run had degraded to edit-based delivery (draft failure
fallback sets _message_id). Key the skip on the got_done update being a
fresh persistent send through the native-draft transport (_message_id is
None), which is the only case where the update already carried its own
finalize.
2026-08-20 21:58:18 -07:00
Gille 790c850144 fix(telegram): preserve rich finals after DM drafts 2026-08-20 21:58:18 -07:00
shannonsands 7a17a1b8a6 fix(docker): stage2 API_SERVER_KEY bootstrap no longer depends on .env existing (OOF-285) (#88926)
* fix(docker): stage2 API_SERVER_KEY bootstrap no longer depends on .env existing (OOF-285)

Fleet sweep found 144/351 started hosted instances (41%) on v2026.8.13+
with no API_SERVER_KEY: the loopback gateway api_server (which serves
/api/cron/fire on :8642) never started, so every scheduled cron fire was
silently lost until the NAS retry budget exhausted.

Root cause chain:
- .dockerignore excludes .env.example (image-size optimization), so
  /opt/hermes/.env.example does not exist in shipped images
- stage2's first-boot seed `seed_one ".env" ".env.example"` is a silent
  no-op when the source is missing -> fresh volumes never get a .env
- the API_SERVER_KEY generation added in #84339 was gated on
  `[ -f "$HERMES_HOME/.env" ]` -> never ran on those instances

Fixes:
- stage2-hook.sh: keygen now creates an owner-only .env when missing
  instead of requiring it to exist; still append-only w.r.t. operator
  keys, still refuses symlinked paths
- .dockerignore: re-include .env.example (negation after the .env.*
  exclusion) so the first-boot template seed works again
- tests: new tests/tools/test_stage2_hook_api_server_keygen.py covers
  create-when-missing, append-without-clobber, operator-key preservation,
  symlink refusal, and a .dockerignore contract test for .env.example

* fix(docker): container-provided API_SERVER_KEY wins over stage2 keygen (review)

The bootstrap generated a key whenever .env lacked one, without checking
the inherited container environment. That broke the documented
`docker run -e API_SERVER_KEY=...` flow: Hermes loads $HERMES_HOME/.env
with override=True (hermes_cli/env_loader.py), so the generated key
silently shadowed the operator's env key and 401'd existing clients.

- stage2-hook.sh: skip generation when API_SERVER_KEY is present in the
  container environment; if BOTH the env and .env carry keys, warn that
  the .env value wins at runtime and touch nothing
- tests: regression tests for the env-provided path (skip + no .env
  write; env+file conflict warns without clobbering); sandbox runner now
  pins/unsets API_SERVER_KEY explicitly so results don't depend on the
  host environment

* fix(docker): drop stale empty API_SERVER_KEY= line when container env provides the key

A leftover empty 'API_SERVER_KEY=' assignment in .env clobbers a
container-provided key at runtime (.env loads with override=True and
python-dotenv sets the empty string), so the api_server startup guard
fails and every scheduled cron fire is silently lost — the exact
symptom class this PR fixes, reintroduced in the env-key branch.

Remove the stale empty line (behind the existing symlink guard) before
skipping generation, so the operator's env key actually wins. Addresses
the IMPORTANT finding both reviewers converged on.

Test: env-key + stale-empty-line combination now covered; strict
removal assertion gated on GNU sed (BSD sed on macOS dev hosts skips
the -i invocation, same caveat as the append test).

* fix(docker): warn at boot when a container-provided API_SERVER_KEY is too weak to start the api_server

The startup guard refuses keys under 16 chars. Now that a
container-provided key suppresses stage2 generation, a weak
`docker run -e API_SERVER_KEY=...` value means the api_server stays
down (cron fires unavailable) instead of clients getting 401s against
a generated key. Say so in the boot log, where the operator will look.

* fix(docker): create .env under umask 077 instead of touch+chmod

touch created the file with the inherited umask (typically 0644), then
a silenced chmod tightened it to 0600 — a brief group/world-readable
window, and no warning if the chmod failed. Creating under umask 077
makes the file owner-only from the first instant with no dependence on
a second command succeeding. Covered by the existing 0600 mode
assertion in test_keygen_creates_env_when_missing.

* fix(docker): guard the API_SERVER_KEY append so a read-only .env degrades to a warning, not a failed boot

stage2 runs under set -eu; the unguarded printf append meant a keyless
.env on a read-only volume (or full disk) aborted the whole cont-init
phase and the container boot. Guard it and emit the same loud warning
the create-failure path uses.

Test harness now runs the extracted block under set -eu to match
production (it ran set -u only, so it could not see this defect class);
new read-only regression test verified RED against the unguarded
append via mutation.

* fix(docker): only warn about a weak container API_SERVER_KEY when it is actually the effective key

The <16-chars warning fired before the .env inspection, so a weak
container key alongside a strong .env key produced a false boot-log
claim that the api_server 'will refuse to start' — immediately followed
by the both-keys warning saying the .env value wins, and the server in
fact starts. Move the check into the branch where the env key really is
the effective key on this boot (round-2 review finding, verified by
execution against python-dotenv last-wins semantics).

---------

Co-authored-by: Ben Barclay <ben@nousresearch.com>
2026-08-21 14:56:28 +10:00
Teknium 1acbeed146 fix(update): resolve code identity by reading .git directly — no subprocess
get_code_identity() shelled 'git rev-parse HEAD', which broke two tightly
mocked test suites (sequenced subprocess.run side effects in the
head-moved gate, call-count asserts in the Windows taskkill test) and
added process-spawn cost to gateway runtime-status writes.
_resolve_git_head_sha() now reads HEAD/refs/packed-refs directly,
handling regular checkouts and worktree/submodule pointer files.
Also: skip the 2s fleet settle wait when the restart phase touched no
gateways, and hoist killed_pids init outside the restart try-block.
2026-08-20 21:54:25 -07:00
Teknium 1d74833d8d feat(update): structured update receipts + post-update fleet version verification
Phase 1 of the fleet-update reliability plan (#91277): the updater now
proves its outcome instead of assuming it.

- hermes_cli/build_info.py: get_code_identity() — process-cached code
  identity (git sha for source installs, baked .hermes_build_sha for
  Docker images, pyproject version).
- gateway/status.py: every runtime-status write stamps the writer's
  code_sha/code_version into gateway_state.json, so a running gateway's
  actual code generation is observable from disk.
- hermes_cli/update_receipt.py (new): machine-readable receipt of each
  update run (steps, skips with reasons, gateway restart outcome, fleet
  snapshot) under ~/.hermes/logs/update_receipts/ with a latest.json
  pointer for the dashboard/desktop; plus collect_fleet_versions() /
  print_fleet_version_matrix() comparing every live profile gateway
  against the freshly updated checkout.
- hermes_cli/update_cmd.py: wires receipt begin/steps/finalize into the
  git, ZIP, and hard-failure paths; after the restart phase, prints the
  fleet version matrix and escalates provably-stale gateways into the
  existing gateway_fleet_restart_incomplete exit-1 contract. Pre-stamp
  gateways report 'unknown' and never fail the update (no false
  positives during rollout).

Silent-failure classes made visible: #88848, #74973, #85753, #81193.
Mixed-version fleet classes made loud: #88654, #69754, #77553, #56717.
2026-08-20 21:54:25 -07:00
Teknium 7af4b8ee92 test(bot-mode): drop unused stub param flagged by eslint 2026-08-20 21:54:18 -07:00
Teknium a976560e0e docs(bot-mode): group rooms sync across Desktops and gateways 2026-08-20 21:54:18 -07:00
Teknium c6745da72b feat(bot-mode): durable room identity + full gateway fan-out for group-room sync
Resolves the room-lifecycle class on top of the salvaged #89369 projection:

- v3 projection keys rooms by immutable roomId (id:<roomId>) with
  name:<name> fallback for legacy rooms; v1/v2 envelopes are normalized
  on read so mixed-version fleets share one merge path
- rename is now a same-key field update — no distributed delete+create,
  no old-name resurrection from lagging gateways
- id tombstones are FINAL (ids are never reused), so a gateway that was
  offline during a disband can never resurrect the room, regardless of
  the revision its stale copy carries; same-name recreation is unaffected
  because it mints a fresh roomId
- the projection fans out to EVERY reachable default-profile gateway
  (per-gateway job queues, CAS revision streams, backoff and retry caps),
  so rooms survive any single gateway dying and surface on gateway-only
  clients without waiting for a Desktop to foreground that gateway
- cold hydrate follows a remote rename via roomId instead of duplicating
  the room under both names

New tests: id-keyed rename continuity, final id-tombstones vs lagging
high-revision copies, rename-job shape (changed+deleted same key),
cold-hydrate re-keying, multi-gateway fan-out. Sabotage-verified: each
new test fails against the pre-class behavior.
2026-08-20 21:54:18 -07:00
Teknium 5d75a46645 fix(bot-mode): stabilize sync entry identity across gateway round-trips
Two follow-ups to the salvaged #89369 base against current main:
- compact projection entries no longer overwrite the local rich copy
  (attachments survive; watermark accounting stays stable)
- synthetic legacy-N thread ids collapse to one bucket in the entry key,
  so id-less entries don't duplicate after a pull and manufacture
  phantom member turns into busy sessions
2026-08-20 21:54:18 -07:00
TheTom 3b83b5e1fa fix(gateway): serialize shared bot room updates 2026-08-20 21:54:18 -07:00
TheTom 7656a2ad46 fix(desktop): hydrate shared bot room previews 2026-08-20 21:54:18 -07:00
TheTom 64d393cb7e fix(desktop): sync bot room previews through profiles 2026-08-20 21:54:18 -07:00
Ben Barclay 46e1a59d84 Merge pull request #69829 from NousResearch/docs/hermes-cloud-mcp
docs: guide for managing Hermes Cloud via the Portal MCP server
2026-08-21 14:51:00 +10:00
Ben Barclay efb6b40f94 Merge pull request #91237 from NousResearch/fix/relay-env-exclusive-messaging
fix(gateway): GATEWAY_RELAY_URL env stamp disables direct messaging platforms
2026-08-21 14:33:29 +10:00
Ben Barclay 6360113a8b fix(cli): read multiplex topology from the default root in enroll warning
sol-reviewer round-4 IMPORTANT (reproduced by execution): the enroll
warning read multiplex_profiles via load_gateway_config() under the
SECONDARY profile's HERMES_HOME, but the flag normally lives in the
DEFAULT root's config.yaml — so the warning never fired in the real
topology, preserving the round-3 defect it claimed to fix.

The topology decision now mirrors the multiplexer-conflict guard in
hermes_cli/gateway.py: secondary detection is the resolved-path
relationship to <default_root>/profiles/ (not a directory-name
heuristic — also fixes the round-4 MINOR false positive on unrelated
dirs named 'profiles'), and the multiplex flag comes from the
GATEWAY_MULTIPLEX_PROFILES env override or a raw read of the default
root's config.yaml. The raw read also avoids running the full
enablement pass (round-4 MINOR: load_gateway_config() emitted the
relay-exclusive sweep's own warnings into enroll output).

The warning now replaces the generic 'restart to pick up the new env'
line instead of following it (round-4 NIT: the two messages were
contradictory), and the helper returns whether it fired.

New test file pins all six topology cases, including the exact
false-negative reproduction (flag in default root only) and env
override in both directions.
2026-08-21 14:13:28 +10:00
Ben Barclay d97cb8f253 test(gateway): pin config/registration relay-URL agreement under multiplex scope
sol-reviewer round-3 findings: the regression tests stopped at the
config boundary, so config/registration agreement was only manually
verified. New TestConfigRegistrationAgreementUnderMultiplexScope
exercises both sides under an active profile scope: a process-env
stamp yields Platform.RELAY enabled AND relay_url()/
register_relay_adapter() agreement AND a constructed RelayAdapter with
a live transport; a profile-only .env stamp is inert on BOTH sides (no
half-enabled state). Also narrows the test_config docstring that
overclaimed profile .env stamps are unsupported — the launch profile's
.env still activates relay via load_hermes_dotenv's os.environ export;
only an isolated multiplex scope is never consulted.
2026-08-21 13:35:20 +10:00
Ben Barclay 3d25fd59b9 fix(cli): warn when gateway enroll writes relay URLs to a secondary multiplex profile
sol-reviewer round-3 IMPORTANT: gateway enroll --connector-url /
--wake-url persist GATEWAY_RELAY_URL / GATEWAY_RELAY_WAKE_URL into the
active profile's .env and tell the user a restart activates them. For
a SECONDARY profile of a multiplexed gateway that is silently untrue:
the routing stamps are process-global, and a secondary profile's .env
is loaded into an isolated secret scope, never exported to os.environ,
so the gateway can never read them from there. Enrollment (the
credential exchange) still succeeds and the creds are still valid, so
warn rather than refuse, pointing at the process environment or the
default profile. Single-profile gateways and the launch profile are
unaffected (load_hermes_dotenv exports their .env at startup).
2026-08-21 13:35:20 +10:00
Ben Barclay f8c3635d40 fix(gateway): classify relay routing stamps as process-global deployment config
sol-reviewer round-2 IMPORTANT: relay env vars had no scope
classification, so the two readers disagreed under a multiplexed
profile scope — gateway/config.py (scope-aware getenv) dropped a
process-env GATEWAY_RELAY_URL during the scoped runner reload while
gateway/relay's relay_url()/register_relay_adapter()/self-provision
(direct os.environ reads) still saw it. Result: adapter registered but
Platform.RELAY absent from config, so the connect loop never dialed
and direct adapters stayed up. The inverse split (profile-only stamp:
config enables RELAY, registration finds no URL) was equally dead.

GATEWAY_RELAY_* ROUTING stamps (URL, ENDPOINT, ALLOW_DIRECT_PLATFORMS,
PLATFORMS, BOT_IDS, ROUTE_KEYS, INSTANCE_ID, WAKE_URL, DISPLAY_NAME)
are now in _GLOBAL_ENV_EXACT: deployment config read from os.environ
under any scope, exactly like the API_SERVER listener settings
(#69379), so every reader resolves the same value. Relay AUTH material
(SECRET, ID, DELIVERY_KEY, IDP_*) is deliberately NOT global — it
stays profile-scoped with the fail-closed multiplex guard, mirroring
the non-secret/secret line the terminal env blocklist already draws
(tools/environments/local.py).

The round-1 multiplex regression test asserted the now-rejected
semantic (profile-scoped stamps win); it is inverted to pin the
global-stamp contract: a process-env stamp survives the profile scope
(sweep runs, matching registration), and a profile-only .env stamp
does NOT activate relay.
2026-08-21 13:21:46 +10:00
Teknium 533886c8b8 chore: map contributor email for Lesnak1 2026-08-20 20:21:12 -07:00
Teknium 49780391a2 fix(runtime): per-model api_mode + /v1 healing for custom OpenCode-family providers
The named-custom-provider runtime path returned a static api_mode, so a
providers: entry like opencode-go-bridge -> https://opencode.ai/zen/go/v1
sent responses-only models (grok-4.5, gpt-5.6-luna) to /chat/completions
and got HTTP 503 (#85589 repro). Now: when the provider name is in the
OpenCode family or the base_url is hosted on opencode.ai, derive api_mode
from the effective model and run the symmetric /v1 normalization — unless
the user declared an explicit transport, which stays authoritative.

5 new regression tests against a real temp HERMES_HOME config.
2026-08-20 20:21:12 -07:00
Teknium a83c3915a3 fix(opencode): family-wide provider predicate + reserved tool-name aliases for custom opencode-* providers
Builds on @Lesnak1's #85619 (issue #85589):

- New opencode_provider_family() single-owner predicate in
  hermes_cli/models.py — resolves built-in AND custom family providers
  (opencode-go-bridge, OpenCode-Zen-Custom, ...) case-insensitively.
  Migrated all 8 inlined family checks (models.py x3, runtime_provider.py
  x4 from the salvaged commits) plus 4 sibling sites the PR missed:
  cli.py api_mode sync, agent_runtime_helpers.py double-/v1 guard,
  model_normalize.py flat-namespace strip, model_switch.py base_url
  normalization.
- Responses transport: alias OpenCode-reserved function names
  (web_search, search_files -> hermes_*) on the wire and map them back on
  dispatch — same pattern as the xAI web_search collision fix. Matches
  family providers and any base_url on opencode.ai. Fixes the HTTP 400
  'custom function name X is reserved' half of #85589.
- Tests: custom-provider routing assertions + 5 new transport alias tests.
2026-08-20 20:21:12 -07:00
Lesnak1 12a3a35521 fix(providers): normalize OpenCode provider matching to case-insensitive and symmetric Zen/Go support 2026-08-20 20:21:12 -07:00
Lesnak1 f9cd51eb66 fix(providers): support custom opencode-go-* provider routing and grok models 2026-08-20 20:21:12 -07:00
Teknium 7c9285aa14 fix(memory): bound invalid-target error and restore recovery hint
Route the model-supplied target through _bound_error_text so a huge
bogus target can't bloat context, and restore the "Use 'memory' or
'user'" hint. Follow-up to HexLab98's review note on the salvage.
2026-08-20 20:20:23 -07:00
kshitijk4poor 2cf7b36e11 fix(memory): enforce independent built-in store permissions
Normalize malformed memory config during initialization and bind per-target write permissions to the session MemoryStore so direct and staged writes cannot update a disabled built-in store.
2026-08-20 20:20:23 -07:00
kshitijk4poor c809d964d4 fix(memory): parse boolean config values consistently
Use Hermes's shared truthy-value parser so quoted false memory flags disable both built-in stores as expected.
2026-08-20 20:20:23 -07:00
kshitijk4poor 5a5d6b966d fix(memory): unify store flags and bypass stale availability cache
Reuse the built-in store predicate during agent initialization and evaluate the config-backed memory tool check immediately after edits instead of applying the generic external-probe TTL.
2026-08-20 20:20:23 -07:00
Teknium 18a15a46d8 test(update): Windows progress self-test survives transient /progress socket stalls
A single urlopen(timeout=5) TimeoutError from the PS runspace listener
failed the test on a loaded runner (run 32440286339) even though the
listener recovered moments later — a transient stall is not the hang
this test guards. /progress sampling now retries until a deadline
(only a persistently unresponsive listener fails), the self-test hold
grows 10s -> 30s so retry time cannot push sampling past the held
stage, and the exit wait gets matching headroom.
2026-08-20 19:56:28 -07:00
Teknium 43c6dace56 fix(cron): restore reasoning_effort on cronjob() for the CLI lane — model dispatch still drops it
The CLI (hermes cron create/edit) routes through cronjob(); removing the
parameter outright broke that lane (CI slices 6/9). The parameter is back
on the function, but CRONJOB_SCHEMA and the registry handler still omit
it — same pattern as the intentional model/provider/base_url omission.
New test proves a hallucinated reasoning_effort arg through the model
dispatch is dropped.
2026-08-20 19:56:14 -07:00