Commit Graph

34352 Commits

Author SHA1 Message Date
kshitijk4poor b72055ece0 refactor(mcp): read the key's scope and the identity's auth type instead of re-deriving them
_key_scope(key) already answers 'owned by another scope'; rebuilding the
tuple via _server_key said the same thing less directly. The OAuth check
re-normalised both configs' auth strings although the normalised value is
the last element of _connection_identity, which the route test compares
anyway — one side suffices once identities match.
2026-09-13 14:38:20 -07:00
kshitijk4poor 527bfe3cc0 test(mcp): drive OAuth profile isolation through register_mcp_servers()
The regression only exercised register_connected_into_current_scope() and
_select_new_servers() directly. Closed #109430 covered the user-visible
path: profile B, driven through the public register_mcp_servers() entry
point, must open its own connection (its own OAuth token) instead of
adopting A's session. Fold that drive into the existing test rather than
adding a second one.

The direct _select_new_servers() assertion is dropped because it marks
B's key as connecting as a side effect, which would make the subsequent
entry-point drive skip the server; the end-to-end drive subsumes it.
2026-09-13 14:38:20 -07:00
KoNit-K bbe4089405 fix(mcp): isolate mTLS connection identities 2026-09-13 14:38:20 -07:00
Amandeep Khurana 399238f2c2 fix(mcp): isolate OAuth connections by profile 2026-09-13 14:38:20 -07:00
teknium1 157aa716b7 fix(cron): external worker ack deadline equals the handoff adoption grace
The dispatch path abandoned a handoff after a fixed 5s while the dead-owner
recovery ledger already tolerates HANDOFF_ADOPTION_GRACE_SECONDS (30s) for the
same pending handoff. Field measurements (issue #109243: p90 claimed->started
10.7s, cold worker starts 9-12s dominated by imports plus secret hydration)
put the cold mode squarely inside the old deadline, so healthy handoffs were
logged as ownership-uncertain and never had their worker pid recorded. Use the
one constant the ledger already defines instead of a second, separately tuned
number, so the two guards around one event cannot disagree again.

Reshapes the salvaged test from #109252 to the current
restart_safe_gateway_child_argv signature and asserts the acknowledged-path
side effect (worker pid recorded) rather than clock progress.

Fixes #109243
2026-09-13 14:37:49 -07:00
KoNit-K a7810a7a6e fix(cron): allow cold external worker startup 2026-09-13 14:37:49 -07:00
teknium1 ba65ce668a fix(kanban): review handoff rollback discards staged copies; no double upload
Two review follow-ups on request_review's artifact staging.

Staging copies files into attachments/<tid>/ inside the write txn, but
the copy is a filesystem side effect the rollback cannot undo. When a
later step in the same txn raised (anything other than
ArtifactPreservationError, e.g. run bookkeeping), the task correctly
stayed `running` but the copy leaked, so the retry staged `a_1.txt`
beside an orphan `a.txt`. _stage_completion_artifacts now returns the
copies and request_review discards them on any exception around the
txn, reusing the same unlink/rmdir logic the staging helper already
had. complete_task is left alone: its txn has a different shape (the
early-return paths and acceptance recording) and its cleanup runs the
scratch workspace anyway, so it was not the identical one-line change.

The notifier unions payload['artifacts'] with paths parsed from the
summary prose and dedupes by full path only. For `review_requested` the
scratch original still exists (the reviewer's completion is what
deletes it), so a summary naming the original uploaded the file twice:
staged copy and original. Prose-parsed paths whose basename matches a
staged artifact are now skipped; `completed` delivery is unaffected in
practice because there the original is already gone by delivery time.
2026-09-13 14:37:17 -07:00
teknium1 e10d30f8a8 docs(kanban): review handoffs also stage declared artifacts
`kanban_request_review(artifacts=[...])` now preserves scratch deliverables
the same way `kanban_complete` does; say so where the scratch-workspace
lifecycle is documented.
2026-09-13 14:37:17 -07:00
teknium1 d1cf019561 refactor(kanban): trim review-artifact salvage to metadata path, 2 invariant tests
Drop the new `artifacts=` keyword on `request_review()` and its
`_declare_handoff_artifacts` helper: the tool layer already folds the
model-facing `artifacts` list into `metadata["artifacts"]` via the
existing `_merge_artifacts`, so the DB layer needs only the one input it
already honours. Keep two invariant tests (declared artifact survives the
reviewer's completion; notifier uploads the staged copy on
`review_requested`); the prose-reference and rollback variants are
covered by the same helpers `complete_task` already exercises.

Salvage of #109276 by @yoyodine-industries.
2026-09-13 14:37:17 -07:00
yoyodine-industries f3357b5031 fix(kanban): stage review-bound handoff artifacts in request_review
request_review ignored artifacts entirely, so a review-bound card lost every
file its handoff named: the reviewer's complete_task is what runs
_cleanup_workspace over the managed scratch workspace.

Stage declared (explicit artifacts argument or metadata["artifacts"]) and
prose-referenced files into the task's durable attachments dir at the review
handoff, exactly as complete_task already does, carry the staged paths in the
review_requested event payload, and let the gateway notifier upload them (its
guard widens from completed to review_requested). ArtifactPreservationError
still rolls the whole transition back: the task stays running and retryable
with no attachments and no event.
2026-09-13 14:37:17 -07:00
teknium1 2292c64738 fix(desktop): a tab promoted into MAIN keeps its owner for session.control.read
KoNit-K's two commits stamp an owner on tab-strip `+` drafts at create
time, which clears the banner ON the draft tile. Promoting that draft into
main (⌘W on the workspace tab, or a tab dragged out of main) still raised
"Session controls unavailable": closeSessionTile drops the tile AND evicts
its $sessionStates mirror in one tick, resumeSession then makes the
runtime active before the view republishes, and the composer's control
read lands in that gap — storedSessionIdForRuntimeId had no tile, no
mirror, and so never reached the stored-id hint that was there all along.

Give the translation one more rung: the active runtime maps to the
selected stored id. Only main's own binding qualifies; unrelated runtime
ids stay unknown and keep failing closed.

Live (Electron + mock gateway, bot chat in main, `+` draft, close main's
tab so the draft promotes): main → banner; KoNit-K alone → banner on
promote; with this rung → no banner at any step, send works.

Invariant test red on main, green here.
2026-09-13 14:36:16 -07:00
KoNit-K 88a7e6f96a fix(desktop): own tab-strip drafts from the draft profile
Tab-strip new tabs omit options.profile; record the draft or active
profile as the tile owner so session.control.read can resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 14:36:16 -07:00
KoNit-K 771b947a43 fix(desktop): preserve owner for unlisted profile tabs 2026-09-13 14:36:16 -07:00
teknium1 f1d5c99fe5 feat: background-process completions paint a compact title, not the raw notification wall
Subagent completions already got this: the model receives the full
`[ASYNC DELEGATION …]` text while the CLI/TUI/Desktop paint a one-line
"Subagent Task Completed: <goal>" event. Background-process completions
(`terminal(background=True, notify=True)`) still echoed the entire
`[IMPORTANT: Background process proc_… completed normally (exit code 0).
Command: … Output: …]` block as if the user had typed it.

Generalise the delegation mechanism: `TimelineNotification` (formerly
`SubagentNotification`) carries `display_kind` + `display_text`;
`ProcessNotificationBatch` renders a `process_complete` one with a
`process_completion_display_text` title ("Background Process Finished:
<cmd>", "Background Process Failed (exit 1): <cmd>", "N Background
Processes Finished"). The TUI gateway stamps the same kind/metadata on
the synthesized turn and emits the title on `status.update`; Ink and
Desktop project `process_complete` rows as timeline events (Desktop keeps
the raw output behind the existing expandable async-result row). Model
content is byte-identical to before.
2026-09-13 14:35:32 -07:00
teknium1 271515b18c fix(auth): a configured custom endpoint counts as a provider in auto resolution (#108383)
`resolve_provider("auto")` only honoured `model.provider` when it named a
registry provider, so `provider: custom` (llama.cpp / vLLM / ollama, or a
loopback `base_url` alone) fell straight through to "No inference provider
configured". The boot inventory in free_tier_bootstrap asks exactly that
question, records provider_configured=False, and every `setup.status` in
`hermes serve` answers from that record — the dashboard's Ink chat then
parked each new session on "Setup Required" while `hermes chat` (which
builds the runtime through resolve_runtime_provider) worked on the same
config. Merged in v0.21.2 (#107697 4bdd64b334); reporters on custom and
on named registry providers were hit by the same record path.

Recognise `custom` / `custom:<name>` / local-server aliases, and a
base_url the bare-custom runtime rung already trusts
(_config_base_url_trustworthy_for_bare_custom), as explicit intent in
_config_model_provider.

Probe (temp HERMES_HOME, provider: custom, base_url 127.0.0.1:8000):
  before  record.provider_configured=False  setup.status.provider_configured=False
  after   record.provider_configured=True   setup.status.provider_configured=True
2026-09-13 14:35:02 -07:00
teknium1 3f4d29d91f test(desktop): trim #102840 salvage to two invariant tests
Keep the null-route stub test (the #108369 / #102792 trigger) and the
runtime-id session.control.read parity test; drop the routed-connection
and stub-eviction cases (the routed path already had owner hints on main,
and eviction is an implementation detail of the stub atom).
2026-09-13 14:35:02 -07:00
Halldrix a92c8749f3 test(desktop): pin runtime-id control.read parity for unlisted owner stub (#102792) 2026-09-13 14:35:02 -07:00
Halldrix 5b9d387cee fix(desktop): satisfy perfectionist import order on #102792 files 2026-09-13 14:35:02 -07:00
Halldrix ecdd72d17b fix(desktop): record owner stub for unlisted tile drafts so session.resume routes (#102792)
Tab-strip/project-sidebar '+' with listed:false minted a backend session
but recorded its owner nowhere when the owner route was null (local source
+ named profile): no tile route, no hint, no row. The tile's immediate
session.resume then failed closed on multi-profile installs.

Record an ownership stub in a dedicated off-list atom that rides only the
owner-lookup path: same stamps as an optimistic row, shadowed by real rows,
evicted on list/drop.
2026-09-13 14:35:02 -07:00
teknium1 b47fb2eba3 fix(profiles): clone builds in a hidden staging dir, never writes through symlinks, refuses --clone-channels in core; channel inventory is ownership-based
Post-merge review of #109502 (gaoanze888) on current main, findings 1-3 and 5-11
(finding 4, the migrate manifest ordering, was already fixed by f9e47aa6fe).

Why:
- `--clone-all` used copytree(symlinks=True); a symlinked source `.env` was then
  edited THROUGH the link by the channel strip, deleting the SOURCE's bot token.
  Root files the clone edits (.env, config.yaml, auth.json, SOUL.md) are now
  materialized as private copies before any write.
- The final profiles/<name> existed during the copy; the multiplexer rescans
  profiles/ on create (#109239) and could adopt the half-copied tree and start
  adapters on credentials not yet stripped. Clones are built in
  profiles/.<name>.staging-<pid> (a leading dot never matches _PROFILE_ID_RE, so
  profiles_to_serve never lists it) and published with one os.rename after the
  strip; a failed create removes the staging tree.
- The live-multiplexer refusal for --clone-channels lived only in the CLI; REST
  (POST /api/profiles) and the TUI (profiles.create) bypassed it. It now lives in
  create_profile as profile_channels.clone_channels_refusal, raising ValueError
  which every surface already maps to a 400 / 4062. --clone-channels without a
  clone flag is an error instead of a silent no-op.
- Channel inventory is ownership-based, evaluated in the SOURCE profile's plugin
  scope: private platform plugins under <source>/plugins/ contribute their keys
  (previously discovered under the ambient HERMES_HOME); GATEWAY_ALLOW_ALL_USERS /
  GATEWAY_ALLOWED_USERS and GATEWAY_RELAY_ID/SECRET/DELIVERY_KEY are channel
  settings; alias prefixes SUPPLEMENT the canonical <PLATFORM>_ prefix
  (WECOM_DM_POLICY, SMS_WEBHOOK_PORT now stripped). Prefixes shared with tools
  (HASS_, TWILIO_, EMAIL_) are stripped only when the source's gateway would run
  that adapter (enabled in config, or complete credentials and not explicitly
  disabled); their allowlist/port keys are always channel-only.
- --clone-all state removal handled files only; Google Chat's directory-shaped
  google_chat_user_tokens/ survived. Directories are removed too, without
  following a copied symlink.
2026-09-13 14:33:11 -07:00
teknium1 ce52c238b7 test(tui-gateway): invariants for secondary side-worker/build terminal scope and launch env-only policy
Two per finding, proven red on origin/main b9271bcb34: secondary side
worker and branch build run their own docker backend (scope reset on success
and exception, os.environ untouched); launch turns keep env-only SSH policy
after activation and ignore ambient TERMINAL_* written afterwards.
2026-09-13 14:31:21 -07:00
teknium1 06bfcae47f fix(tui-gateway): launch-profile turns keep their env-only terminal policy once multiplexing is active
606903badc made launch-profile turns bind a file-backed terminal scope as
soon as any secondary home is served, so a poisoned ambient bridge can never
be the launch turn's authority. That scope is rebuilt from defaults +
<home>/.env + config.yaml only, which drops the launch process's legitimate
env-only policy: TERMINAL_ENV=ssh TERMINAL_SSH_HOST=example.test with a `{}`
config.yaml became backend=local, ssh_host='' the moment a second profile
was served.

tui_gateway/launch_terminal_policy.py freezes the process TERMINAL_* once,
in _profile_home right before the first secondary home is registered as
served — the last moment ambient env is provably the launch profile's own.
build_profile_terminal_scope takes that snapshot as a trusted env_overlay
sitting where the process env sits in the standalone bridge (explicit YAML
keys still win). Launch turns overlay the snapshot; ambient os.environ is
never re-read after activation, so a later secondary write is still
rejected, and the scope is reset after the turn as before.

Refs #108440 review (andrexibiza), #107442 (ehz0ah).
2026-09-13 14:31:21 -07:00
teknium1 484b8e96e8 fix(tui-gateway): secondary side workers and agent builds bind their own terminal scope
Under multiplexing tools/terminal_tool.py no longer bridges a profile's
terminal.* into os.environ while a home override is bound (#108440), so any
secondary-profile entrypoint that binds only the home (+ secrets) leaves
terminal_tool on the launch process's ambient TERMINAL_*: a secondary with
`terminal.backend: docker` ran its prompt.background / prompt.btw /
preview.restart side agents, its eager resume and its session.branch build
on the launch `local` backend.

_spawn_side_agent and _profile_build_scope now enter
_session_profile_runtime_scope, the same home -> secrets -> terminal
composition a prompt turn binds (and gateway/run.py::_profile_runtime_scope
mirrors). The terminal scope is installed for the whole worker/build
lifetime and reset on success and on exception; a malformed secondary
config still yields the fail-closed refusal scope. No ambient os.environ
write is restored.

Refs #108440 review (andrexibiza), #107442 (ehz0ah).
2026-09-13 14:31:21 -07:00
teknium1 3f86ed75da chore: map juliancruzet@users.noreply.github.com to @JulianCruzet for contributor attribution 2026-09-13 12:52:43 -07:00
teknium1 130284fdcd fix(providers): declare catalog-only aliases on their plugin profiles
Same class as minimax-portal: aliyun, deep-seek, nim/build-nvidia/nemotron
and vertexai resolve in hermes_cli's alias tables but not in
providers.get_provider_profile(), so a profile lookup keyed on the alias
returned None and lost the profile's wire mode/extra_body/headers. qwen is
left out: the catalog maps it to alibaba while the qwen-oauth plugin already
claims it, a pre-existing disagreement outside this fix.
2026-09-13 12:52:43 -07:00
teknium1 117b01afa7 test: collapse minimax alias tests to one registry invariant
The two salvaged tests overlapped (resolution subsumes membership); one
contract test that every documented alias resolves to minimax-oauth is
the salvage bar.
2026-09-13 12:52:43 -07:00
Julian Cruzet d55c3ad70b fix(minimax): register minimax-portal and minimax-global aliases for minimax-oauth (#107928) 2026-09-13 12:52:43 -07:00
teknium1 643b3f450d fix(tools): stop check_fns swallowing resolver crashes into "returned False"
check_vision_requirements (and five siblings: browser_vision, image/video
generation, x_search, browser_vault) wrapped their whole probe in
`except Exception: return False`. The registry then logged "returned False",
indistinguishable from an unconfigured backend, and the only diagnostic for a
crashed resolver was gone (#87950: named custom provider lookup failing in a
long-lived multi-profile process, reported as vision tools silently vanishing).

The registry owns the verdict: _run_check_fn_uncached and _check_fn_cached both
catch, log with traceback, and return False. Let the exception reach them.
Behaviour for the model is unchanged (tool hidden either way); agent.log now
says why.
2026-09-13 12:35:36 -07:00
teknium1 aa7980d777 fix(tools): log the traceback when a check_fn raises on the cached path
The TTL-cached availability path collapsed a raising check_fn into the bare
verdict "check_fn X raised; dependent tools will be unavailable this turn" with
no exception attached, while the uncached path already logged exc_info. A probe
that crashes is a bug in the probe or its resolver, and without the traceback
the log reads exactly like "nothing configured" — that gap turned #87950 from a
ten-minute diagnosis into a multi-day one. Carry the exception out of the
try block and attach it to the verdict.
2026-09-13 12:35:36 -07:00
teknium1 b9271bcb34 chore: map steveahlstrom@gmail.com to @sjahlstrom for contributor attribution 2026-09-13 11:22:08 -07:00
teknium1 093c58ca77 test: cover adapter-wrapped probe stubs and repeat-probe stability
Trim the salvaged test to the ≤2-invariant bar: one parametrized test asserting
repeat probes stay resolvable (the user-visible contract from #87654) and that
neither a bare stub nor a Codex-adapter-wrapped stub lands under the runtime
cache key. The guard now keys on aux_probe_mode being active rather than on the
client's type, which is what covers the wrapped case.
2026-09-13 11:22:08 -07:00
Steve Ahlstrom a714ff9823 fix(aux): stop probe stubs poisoning the client cache
`_store_cached_client()` refuses an `_AuxProbeClientStub`, but
`_get_cached_client()` assigns to `_client_cache` directly and so never
reaches that guard. check_fns resolve through this path inside
`aux_probe_mode()` during tool-schema assembly, and the cache key carries no
probe/runtime distinction — so the stored stub is returned to the next real
caller sharing that key, which dies on attribute access with
`_AuxProbeClientStub used as a real client (attribute 'chat')`.

The `async_mode` field in the cache key is what kept this latent: the probe
caches the sync variant, so async consumers (`analyze_image`) miss the entry
and build a real client, while sync consumers (`browser_vision`) hit the
poisoned one and fail on every call.

Observed against a local OpenAI-compatible vision endpoint, where
`browser_vision` failed every call with that RuntimeError while
`analyze_image` against the same provider worked.

Guard the inline store the same way `_store_cached_client()` does, and
return the stub to the probe caller without caching it.

The existing `test_probe_stub_never_cached` pins the invariant only on
`_store_cached_client()`, which is why the unguarded door went unnoticed;
the new test exercises `_get_cached_client()` and fails without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:22:08 -07:00
hermes-seaeye[bot] 61304d5923 fmt(js): npm run fix on merge (#110132)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-13 17:58:46 +00:00
teknium1 68eb059863 feat(desktop): daily re-auth nudge for MCP servers with a Disable action
An MCP server whose OAuth refresh token died used to get one warning toast
per app session (on the transition into needs-auth) and then nothing: the
server sat parked, silently, until the user happened to open Capabilities →
MCP. A dead token is a standing problem that needs an action, so the health
checker now re-nudges once a day while the server stays broken (persisted
24h snooze per profile+server, same pattern as the update/skew toasts) and
the toast offers the second way out: Disable, which writes enabled: false
through PUT /api/mcp/servers/{name}/enabled. The gateway's config reconcile
(#109906) and the serve backend's reload both follow that edit.

Toasts gain an optional secondaryAction (outline button beside the primary).
2026-09-13 10:53:29 -07:00
teknium1 d8668e25ef test(web): type the shared preset lookup as ThemePresetPalette so tsc -b (the Docker build) accepts darkColors
vitest and tsc -p tsconfig.app.json were green because the app config excludes tests; the image build runs tsc -b, which type-checks them and widened the indexed lookup to a union missing the optional slot.
2026-09-13 10:52:11 -07:00
teknium1 e7657792df refactor(themes): web dashboard presets derive from the desktop palette table
The desktop and the web dashboard each carried a private copy of the
cyberpunk / ember / midnight / mono palettes and they had drifted: the
dashboard's cyberpunk canvas was #040608 with a mint #9bffcf accent
while the desktop's was #000a00 with #00ff41, ember and midnight
disagreed on both canvas and accent, mono agreed only by luck.

Move the raw palette table for every built-in preset into
@hermes/shared (`THEME_PRESET_PALETTES`, apps/shared/src/theme-presets.ts)
and make it the single source of truth:

- apps/desktop/src/themes/presets.ts spreads its `colors` / `darkColors`
  from the shared table; the OKLCH synthesis, terminal palettes and
  typography stay in the desktop. Serialised BUILTIN_THEMES are
  byte-identical to before, so the existing `--dt-primary-solid`
  parity pins stay green untouched.
- web/src/themes/presets.ts projects each shared preset onto its
  3-slot model through one pure function, `webPresetFromShared`
  (background <- background, midground <- primary, warmGlow <- the
  midground/ring accent), so cyberpunk / ember / midnight / mono now
  render the desktop's palette. Web-only presets (default,
  default-large, nous-blue, rose) are untouched.
- Invariant test (web): for every preset shared by both surfaces the
  dashboard canvas equals the shared background and the projected text
  colour keeps >= 3:1 contrast against it. Red on the previous hexes,
  green now.

Why: one edit in one place should recolour a preset on every surface;
two hand-maintained tables guarantee the drift the audit found.
2026-09-13 10:52:11 -07:00
teknium1 7d2bb463c4 test(desktop): load ConfigSettings once in beforeAll so the autosave test's 15s budget is not spent on the import
The autosave test did `await import('./config-settings')` inside the test
body. That module graph is large: 1.5s cold on an idle machine, and on a
saturated CI runner (import 1682s aggregate across the run) it alone
crossed the 15s testTimeout twice today on PRs that never touched the file.
The assertions themselves take ~100ms. Hoisting the import into a
beforeAll with its own 60s hookTimeout keeps the test's budget for the
behaviour under test; the fault-injection mocks are hoisted vi.mock calls
and still apply.
2026-09-13 10:51:50 -07:00
teknium1 5dea46d13d fix(dashboard-auth): one refresh single-flight for the cookie gate and the native route, off the event loop
The cookie gate (middleware._attempt_refresh) never coalesced concurrent requests
carrying the same stale refresh token, so a browser/desktop burst after access-token
expiry replayed a just-rotated RT into the provider's reuse detection and the whole
session was revoked (#55712). Both refresh paths also ran the synchronous provider
HTTP call on the ASGI event loop, which wedged /api/status behind a slow IdP.

Generalise Doud-FR's native-route single-flight (#71548) into
refresh_singleflight.refresh_session_coalesced and use it from both paths, each in
run_in_threadpool. The replay key is the RT alone: a burst that straddles a network
change must still coalesce, and whoever presents the RT already owns the session.
Middleware keeps its refresh_expired / provider_unreachable audit events via callbacks.

Live E2E (evals/dashboard_auth/refresh_singleflight_live_e2e.py, real uvicorn, stub
rotating IdP with reuse detection): base 1/4 requests survive each burst, 4 provider
calls, /api/status 2.7 s behind one refresh; fixed 4/4, 1 call, 10 ms.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
2026-09-13 09:40:44 -07:00
Doud-FR f561155a70 fix(gateway): coalesce native refresh per concrete provider across hints
(cherry picked from commit 18ce843daba52c427ab5d4b5f21990efc1202486)
2026-09-13 09:40:44 -07:00
teknium1 6c27a289c3 chore: map contributor email for samfoy 2026-09-13 09:18:54 -07:00
Teknium e016e87d20 chore(deps): regenerate uv.lock for pillow-heif on current main 2026-09-13 09:18:54 -07:00
Teknium adaa6643b9 test(vision): import HEIC helpers from the defining sibling module
The Sep-2026 facade decomposition moved _detect_image_mime_type_from_bytes and
_normalize_to_supported_image into tools/vision_tools_image_prep.py; import from
the defining module per repo convention (compat pointers are off limits in-tree).
2026-09-13 09:18:54 -07:00
Sam Painter c83c413214 fix(vision): fail closed on a malformed ftyp box size
Self-review finding (hostile redteam pass), and it defeats the exact guarantee
the previous commit advertised.

The brand scan is bounded by the declared ftyp box size so a token in a FOLLOWING
box cannot upgrade a HEIC to AVIF. But the bound fell back to the whole 64-byte
sniff window whenever the declared size was outside `16 <= size <= len(header)`.
That failed OPEN in precisely the attacker-controlled case:

    declared size  0 -> image/avif   (genuine HEIC, 'avif' in a later box)
    declared size  4 -> image/avif
    declared size  8 -> image/avif
    declared size 12 -> image/avif
    declared size 15 -> image/avif
    declared size 24 -> image/heic   (truthful size, correct)

A size too small to hold any compatible brand now scans none, rather than
scanning everything. A size >= 16 that overruns the sniffed window is a truncated
read rather than an attack, so that case clamps to the bytes actually available
and a genuine oversized-ftyp AVIF is still detected.

The prior test only exercised the truthful size, so it proved the happy path
rather than the invariant it was named for. Added parameterized malformed-size
cases (0/1/4/8/12/15), a misaligned-size case (17-23, non-multiples of 4), and an
honest-oversized case. All six malformed-size assertions fail if the fail-open
bound is restored; verified by mutation, not assumed.
2026-09-13 09:18:54 -07:00
Sam Painter f5a7dc14c7 fix(vision): parse the ftyp compatible-brand list; don't gate AVIF on pillow-heif
Two defects in the HEIF/AVIF sniffing added by this PR.

1. Major-brand-only detection mislabeled AVIF as HEIC.
   AVIF encoders routinely stamp the generic still-image brand 'mif1' as the
   MAJOR brand and declare the AV1 codec only in the compatible-brand list, so
   every mif1-major file was reported as HEVC-coded HEIC. Parse the
   compatible-brand list (bounded by the declared ftyp box size, so an 'avif'
   token in a following box can't upgrade a real HEIC) and let AV1 brands win
   when both families appear.

2. AVIF was rejected outright when pillow-heif was missing.
   Pillow >= 11.3 bundles a native AvifImagePlugin, while pillow-heif wheels
   are commonly built with no AV1 codec at all (libheif_info() reports
   AVIF: ''). Gating AVIF on a pillow-heif import therefore refused files
   Pillow could already decode, and pointed the user at a library that cannot
   decode them. Registration is now best-effort: the decode attempt is the
   arbiter, and failures emit codec-specific guidance naming the backend that
   actually serves that format.

Tests: mif1+avif and mif1+av01 regressions, a box-size bound guard, a
truncated/bogus-size guard, AVIF-converts-without-pillow-heif, and AVIF error
text. Verified against real pillow-heif-encoded HEIC and Pillow-encoded AVIF
files, not just synthetic headers; both new brand tests fail if the
compatible-brand scan is reverted.
2026-09-13 09:18:54 -07:00
Sam Painter 352d8b0929 chore(deps): use bounded pillow-heif constraint and regenerate uv.lock
AGENTS.md:559-576 requires every PyPI dependency to carry an upper bound
rather than an exact runtime pin. Replace "pillow-heif==1.4.0" with
"pillow-heif>=1.4.0,<2" and regenerate uv.lock (resolves 1.5.0, with
hashes), confirming the range admits later 1.x releases.
2026-09-13 09:18:54 -07:00
Samuel Painter 0a545f7fa5 feat(vision): decode HEIF/HEIC/AVIF images (iPhone photos)
vision_analyze rejected iPhone photos with 'source is not a recognized
image'. iPhones capture HEIC (ISO-BMFF/HEVC) and upload pipelines often
mislabel it .jpg; the magic-byte sniff had no HEIF branch, so the bytes
were rejected before reaching normalization.

- _detect_image_mime_type_from_bytes: sniff the ISO-BMFF 'ftyp' box and
  recognize HEIF/HEIC (heic/heix/heim/heis/hevc/hevx/mif1/msf1) and AVIF
  (avif/avis) brands, returning image/heic or image/avif. Non-image ftyp
  brands (mp4, ...) stay unrecognized.
- _normalize_to_supported_image: register the pillow-heif Pillow opener,
  then re-encode HEIF/AVIF to PNG before embed (same soft-dependency
  pattern as SVG rasterization). Missing decoder yields an actionable
  'pip install pillow-heif' error, not a generic failure.
- Add pillow-heif==1.4.0 as a core dep alongside Pillow (prebuilt wheels
  bundle libheif; no system libs needed).
- Tests: brand detection (incl. AVIF + mp4-not-misdetected), end-to-end
  HEIC->PNG normalization, and the decoder-missing error path.

The vision resolver's magic-byte sniff stays authoritative (no extension
trust); HEIF now reaches the existing normalize step instead of 400ing.
2026-09-13 09:18:54 -07:00
teknium1 42e7471c64 test: route two honcho tests that landed at tests/ root during the rebase 2026-09-13 09:18:02 -07:00
teknium1 76e88cae2a test: repoint cross-test imports at the moved modules; opt one backoff test out of the merged agent fixture
Twenty-three tests imported helpers from sibling test modules by dotted
path (tests.run_agent.test_run_agent, tests.cli.test_cli_init,
tests.gateway.test_42039_duplicate_user_message); those follow the moves
and renames. The run_agent autouse fixture that zeroes jittered_backoff
now covers all of tests/agent, where one pre-existing test asserts the
real backoff is positive — it opts out via a real_retry_backoff marker,
the same pattern real_concurrent_gate and real_agent_prewarm use.
2026-09-13 09:18:02 -07:00
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00
kshitijk4poor 422bc9bde9 fix(migrate): rollback refuses a malformed manifest before mutating; plan and run agree
Follow-ups from the #109962 review that landed after the merge:

- A hand-edited manifest (`"secondaries": null`, a record without profile/home,
  `"default": []`) crashed with a traceback AFTER the flag had already been
  flipped. It is now refused up front, with the same message from the dry-run
  plan and the real run.
- The dry-run plan said "restore its recorded standalone gateway" for a record
  with neither service nor pid, where the run did nothing; both now say so.
- `--standalone --dry-run` with no manifest exits 1 like the real path and
  prints the same hint.
- One "Rollback incomplete" message; a failed default restart now tells the
  user the multiplexer is still running and how to stop it.
- Drop the dead `prefixes and` guard; an empty profile name can no longer
  produce a bare ":" platform prefix.
2026-09-13 20:06:00 +05:30