Commit Graph

34412 Commits

Author SHA1 Message Date
teknium1
bc0edb8a5b test: main-alias aux pin is not stale in the backend switch report
Backend half of #97310 (the desktop banner has its own vitest case in the salvaged commit).
2026-09-13 14:46:38 -07:00
tutan0558
d2eb7e1c01 fix: don't flag auxiliary tasks using the 'main' provider alias as stale
Both stale-pin detections exempt only '' and 'auto':
- desktop persistentStaleAux banner (model-settings.tsx)
- switch-time stale_aux response (hermes_cli/web_server.py)

'main' is a backend-supported alias (auxiliary_client._normalize_aux_provider)
meaning "follow the active main provider", so aux slots pinned to it can
never be stale. The false positive fires for users following Moonshot's
official Hermes integration guide, which prescribes
auxiliary.vision.provider: main.

Exempt the alias in both places and add a regression test.
2026-09-13 14:46:38 -07:00
teknium1
ccf66b29ad fix: reload.mcp rediscovers under every live session's profile scope
`_do_full_reload` calls `shutdown_mcp_servers()` unscoped, which tears down
every profile's servers, but `discover_mcp_tools()` ran only under the launch
home. The all-sessions refresh then rebuilt a secondary-profile session's tool
snapshot under its own scope against a registry whose overlay was deregistered
and never rediscovered, so that session lost its MCP tools until its own
reload (main at least left its stale snapshot intact). After the pool rebuild,
rediscover once per distinct live `profile_home` under that profile's runtime
scope before refreshing the sessions.
2026-09-13 14:46:12 -07:00
teknium1
243392b196 fix(tui): reload.mcp refreshes every live session's tools, not just the requester's
The MCP pool is process-global but each agent snapshots `agent.tools` at build
time, so `/reload-mcp` from session A left session B's agent on the old tool
list until `/new` (losing its history); a request without a resolvable
`session_id` (desktop sends `activeSessionId ?? undefined`) refreshed zero agents
while still answering `reloaded`. After the pool rebuild, iterate every session
with a built agent under its own profile scope and push `session.info` to each.

Slim redo of PR #109383 by @nikkoxgonzales: the fan-out only, without the
mid-turn deferral, per-profile rediscovery loop and compute-host forwarding
changes that PR bundled.

Co-authored-by: nikkoxgonzales <nikkoxgonzales@gmail.com>
2026-09-13 14:46:12 -07:00
teknium1
bdb14f5f81 fix: status falls back to the live agent before the first host frame; recent route ties break deterministically
Under turn isolation `session.status` passed agent=None and only the metadata
mirror's model/provider, so until the compute host sent its first frame the
mirror was empty and the TUI rendered "Model: (unknown) (unknown)" where main
showed the in-process agent's route. Fall back to the live agent's model and
provider like `server._session_info` already does.

`get_recent_session_model_route` ordered by `last_seen DESC` alone; two rows
stamped in the same flush tie and SQLite's temp-sort order is unspecified, so
the retired route could be reported as current. Order by `rowid DESC` as the
secondary key so the route that appeared later wins.
2026-09-13 14:45:41 -07:00
teknium1
f26335752b fix(gateway): /usage billing route follows the most recent model too
`_persisted_billing_route` (idle `/usage` account-limits lookup) was the last
reader of the lifetime-dominant route, so it queried the retired provider's
account after a switch. Point it at `get_recent_session_model_route` and
delete the dominant query, which no longer has a caller.
2026-09-13 14:45:41 -07:00
KoNit-K
1387c64cac fix(tui): report live compute-host model in status 2026-09-13 14:45:41 -07:00
fangliquanflq
08a0cbe305 fix(gateway): prefer active status model override 2026-09-13 14:45:41 -07:00
fangliquanflq
280ede95a7 fix(gateway): report the most recent status model 2026-09-13 14:45:41 -07:00
teknium1
e30d0639bb fix(cli): sessions export accepts a directory for single-file formats
`hermes sessions export --session-id X <dir>/` crashed with IsADirectoryError
because jsonl/html/trace opened the positional as a file while --help called it
an "output path" and md/qmd really do take a directory. An existing directory
(or one spelled with a trailing separator) now receives a default-named file
(`hermes_session_<id>.<fmt>`), and the help text spells out per-format what
OUTPUT means.
2026-09-13 14:45:10 -07:00
teknium1
4003840378 fix(gateway): /save delivers the export document instead of crashing on get_adapter
`GatewayRunner` never had a `get_adapter` method, so every gateway `/save`
(Telegram, Discord, ...) rendered the file and then failed with
"'GatewayRunner' object has no attribute 'get_adapter'". Resolve the adapter
through `_adapter_for_source`, the profile-aware lookup the rest of the runner
uses, so multiplex secondaries deliver through their own bot rather than a
missing key on the default map.

Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: Baophan00 <109447498+Baophan00@users.noreply.github.com>
2026-09-13 14:45:10 -07:00
fangliquanflq
4e3165b75a fix(updater): finish Node phase after Windows handoff 2026-09-13 14:44:38 -07:00
liuhao1024
abdb31cd99 fix(cli): resolve .env-only key_env credentials for the /model probe
`/model` fed `validate_requested_model()` a key resolved through
`agent.secret_scope.get_secret`, which (multiplexing off) reads only
`os.environ`. Hermes does not export `$HERMES_HOME/.env` into the process
environment, so a `custom_providers` entry whose `key_env` lives only in `.env`
probed `/v1/models` unauthenticated, got 401 and printed a spurious "could not
reach this custom endpoint's model listing" note while chat worked fine.

Resolve through `get_env_prefer_dotenv` — the chain `client_lifecycle` uses for
the real request — when no profile scope is installed. With a scope installed or
multiplexing active the scope stays authoritative: a scoped miss still returns
"" and never borrows another profile's `.env`/process value.

Slimmed from the contributor's two commits (same mechanism, fewer branches,
tests trimmed to two invariants).

Fixes #109315
2026-09-13 14:44:06 -07:00
teknium1
138e426f62 fix: bound A2A orphan grace and clear _active_tasks on disconnect
`_orphan_timeout()` was `max(300, A2A_REPLY_TIMEOUT)` with no ceiling, so
an absurd value (1e18) meant the watchdog sweep could never fail an
orphan — the reply window is a floor for the grace, not a licence to
disable the sweep. Cap it at 86400s.

`disconnect()` failed and cleared `_pending`/`_pending_order` but left
`_active_tasks` populated, so a reconnected adapter would keep excluding
dead task ids from the orphan sweep forever. Clear it in the same locked
block.
2026-09-13 14:43:35 -07:00
teknium1
8abe6ab8ff docs(a2a): say the orphan sweep follows A2A_REPLY_TIMEOUT and live waiters
The troubleshooting entry told users to raise A2A_REPLY_TIMEOUT for long tasks,
which did nothing against the hardcoded 300s orphan sweep (#106972). Now that the
sweep derives its grace from the reply window and skips tasks with a live waiter,
state that contract next to the variable.
2026-09-13 14:43:35 -07:00
fangliquanflq
36f43c2706 fix(a2a): finalize tasks after stream disconnect 2026-09-13 14:43:35 -07:00
fangliquanflq
9c728ec30d fix(a2a): retain task ownership through completion 2026-09-13 14:43:35 -07:00
fangliquanflq
d441708886 fix(a2a): preserve live waiters during orphan cleanup 2026-09-13 14:43:35 -07:00
teknium1
05a40c51c2 chore(contributors): map EloquentBrush0x, benjamin-rousseau-shift, Mengchee118 emails 2026-09-13 14:43:19 -07:00
teknium1
24c136e8af fix(cron): block a job whose requested MCP server resolves to zero tools
Under a multiplexer MCP tools are registered per profile overlay while the
server toolset alias is process-global, so a cron job naming a server in
enabled_toolsets that is connected only for another profile validated as a
known toolset, resolved to zero tools, and ran tool-less with quiet_mode
hiding the only diagnostic; the run was booked success (#109050).

After cron MCP discovery, an explicitly requested enabled MCP server that
resolves empty in this profile scope now takes the existing blocked_config
path (incident, alert-once, visible last_status). The implicit merge of
all enabled servers is not judged; only servers the job asked for.
2026-09-13 14:43:19 -07:00
fangliquanflq
2710d85914 fix(kanban): gate gateway notifier polling 2026-09-13 14:43:19 -07:00
teknium1
9541317aee fix(kanban): trim auto-decompose scope shim and add an invariant test
Salvage follow-up to #107955 (Alex Tu) and #109494 (EloquentBrush0x):

- _default_profile_secret_scope: drop the import try/except, the
  current_secret_scope() short-circuit and the build-failure fallthrough.
  The tick always runs in a fresh Context (no scope can be present) and a
  failure to build the launch profile scope must surface, not silently
  degrade to an unscoped tick.
- Regression test proven red on origin/main: run the real
  auto_decompose_tick through _to_thread_process_service under multiplex
  and assert the decomposer reads the launch profile .env value; ports the
  contract from #57837 (srojk34) to the post-refactor dispatcher.
- Trim the #109494 test docstring to the invariant.
2026-09-13 14:43:19 -07:00
EloquentBrush0x
0c9aa10f41 fix(kanban): install the assignee's secret scope before scrubbing worker env
_default_spawn() called build_subprocess_env(scrub_secrets=is_multiplex_active())
with no profile secret scope installed. Under multiplex, any name registered via
terminal.env_passthrough makes _filter_secret_env's resolve_passthrough_value()
call get_secret() with no scope active, which fails closed with
UnscopedSecretError -- crashing every Kanban worker spawn, for every profile, as
soon as env_passthrough is configured anywhere.

Mirror _resolve_worker_cli_toolsets's existing scope-then-read ordering a few
functions up in the same file: resolve the assignee's HERMES_HOME first, install
build_profile_secret_scope() around the env build, then set env["HERMES_HOME"]
from the value already resolved instead of calling resolve_profile_env() twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:43:19 -07:00
Alex Tu
ae076bc465 fix(kanban): scope the auto-decompose tick to the default profile under multiplex
With gateway.multiplex_profiles on, agent.secret_scope.get_secret() fails closed
whenever no profile secret scope is installed. auto_decompose_tick runs through
_to_thread_process_service in a fresh context, so the decomposer's credential
read raised UnscopedSecretError on every tick before the aux LLM was called,
and every triage card stayed in triage forever (logged at INFO only).

Wrap the tick in _default_profile_secret_scope(): when multiplexing is active
and no scope is installed, build the gateway default profile's scope (the same
home load_gateway_config_for_runner uses) for the duration of the tick. No-op
for single-profile gateways and when a scope is already active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit c47e3ea6f8a5750182b7534160184210b8d5a110)
2026-09-13 14:43:19 -07:00
teknium1
1e76efbe28 fix: scan plugin test trees again, cap their criticals at caution
Skipping `tests/`, `spec/`, ... in EXCLUDED_DIRS made those trees
invisible to the guard, but `plugins_loader._load_directory_module`
sets `submodule_search_locations=[plugin_dir]`, so a plugin
`__init__.py` doing `from .tests import evil` imports and runs whatever
lives there: a `tests/evil.py` with a destructive root remove scanned
`dangerous` on main and `safe` on this branch. `_walk` also matched the
names at any depth, so `src/spec/handler.py` — plain runtime code — went
unscanned.

Keep scanning everything; instead cap a critical finding located under a
ROOT-level test dir at `high`, so the verdict is `caution` (confirmation
required, `--force` overridable) rather than the un-overridable
`dangerous`. Fixture strings still cannot brick an install, which was
the reported problem, while a critical in any runtime file (`setup.sh`,
`src/spec/...`) still yields `dangerous`. Trade-off stated in the PR
body: hostile code deliberately placed under `tests/` is now
force-installable rather than blocked outright.

Docs no longer claim test code never runs.
2026-09-13 14:43:04 -07:00
teknium1
713270d3d0 docs(plugins): document skipped test trees and the critical-finding block reason
User-visible scanner behaviour changed in this PR (test trees skipped, the block
reason names the critical rule ids), so the plugin docs say so in the same PR.
2026-09-13 14:43:04 -07:00
joaomarcos
fe97c84c73 fix(plugin): identify critical findings in install blocks 2026-09-13 14:43:04 -07:00
liuhao1024
07b60880cf fix(plugins): stop the security scanner from reading test trees
plugin_guard walks the whole plugin clone, and EXCLUDED_DIRS skipped
caches and vendored dirs but not tests/. A security-conscious plugin's
test suite SHOULD contain adversarial fixtures — a test asserting the
trust boundary holds round-trips the injection string verbatim — and
any single critical finding makes the verdict dangerous, which --force
explicitly cannot override. Scanning tests therefore made exactly the
plugins that test their security unconditionally uninstallable, and the
only workaround was obfuscating the payload strings, weakening the
tests and inverting the incentive. Fixtures are never loaded into an
agent's context at runtime the way README/plugin.yaml are.

Add the conventional test/spec/fixture directory names to
EXCLUDED_DIRS, alongside the existing cache/vendored skips.
2026-09-13 14:43:04 -07:00
teknium1
6241787662 fix(whatsapp): blank free_response_chats in YAML falls through to the env CSV
Same class as the Matrix readers: since 545e74d0 the WhatsApp YAML bridge seeds
`free_response_chats` into extra via the "csv" kind (any non-None value), so a
present-but-blank `free_response_chats: ''` reaches `_whatsapp_free_response_chats`
as '' and its `if raw is None` fallback never reads WHATSAPP_FREE_RESPONSE_CHATS.
Before 545e74d0 the hook returned None and the key never reached extra, so the
env CSV applied. Route it through the shared extra_or_secret reader (blank =
unset; an explicit empty list stays "no chats").

Sibling sweep of every "csv"-kind bridged key: dingtalk, mattermost and slack
readers already go through extra_or_secret and their bridges seeded extra before
0.21.2, so 008caa88 deliberately kept blank-means-clear there; whatsapp allow_from
uses key-presence semantics by design (_select_dm_allowlist); buzz reads env
first. Telegram allowed_chats: '' shadowing the env var is pre-existing (identical
on v2026.9.7, via the shared-key bridge) and left as is.
2026-09-13 14:42:33 -07:00
teknium1
3ad65cc892 fix(matrix): blank YAML values fall through to env at every extra-first reader
545e74d0 (post-0.21.2) made the Matrix YAML bridge seed its values into
PlatformConfig.extra so secondary multiplex profiles read their own config. The
"csv" bridge kind seeds any non-None value, so `free_response_rooms: ''` now
reaches extra as '' — and the readers' `if raw is None` fallback no longer fires,
so MATRIX_FREE_RESPONSE_ROOMS is ignored and require_mention drops every
un-mentioned message. Before that commit the bridge only wrote env and the key
was absent from extra, so the env value applied.

Route the three identity-check readers (_extra_csv_set, _extra_truthy,
_resolve_max_message_length — the last a three-tier chain where '' also
short-circuited the plugin-registry default) through the shared
gateway.platforms._shared.extra_or_secret, whose default already treats a blank
string as unset (the idiom mattermost/dingtalk/slack readers use). Explicit
scalars, bools and lists (including []) stay authoritative.

Two invariant tests replace the salvaged suite (moved to
tests/plugins/platforms/matrix/ to mirror the source path): blank falls through
for all three readers; explicit values still beat env.

Fixes #109358
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
2026-09-13 14:42:33 -07:00
KoNit-K
885ce3f493 fix(matrix): restore env fallback for blank room config 2026-09-13 14:42:33 -07:00
teknium1
3c0dcbbdc7 fix: keep same-session route during context switch
The contextSwitching early return in isRouteSessionMismatch sat above the
same-id short-circuit, so a profile or connection switch while the route
already pointed at the selected session reported a mismatch and blanked the
chat to the splash. On main that call returned false.

Move the selected-session check ahead of the contextSwitching guard: when the
selected view already owns the routed conversation there is no prior context
to leak, so nothing needs hiding. The guard still denies only the
transcript-retention fallback, which is the case it was added for.

Adds the exact regression to route-session-state.test.ts.
2026-09-13 14:42:01 -07:00
KoNit-K
3bad7e72db fix(desktop): preserve routed transcript during selection churn 2026-09-13 14:42:01 -07:00
teknium1
fedaad0cc4 fix: ignore star map playback hotkeys inside context menu
The node context menu now uses Radix, whose menu items are focusable
`div[role=menuitem]` elements. The window-level Space handler in
star-map.tsx only skipped INPUT/TEXTAREA/BUTTON/contentEditable, so
pressing Space on a focused menu item both activated the item and toggled
playback.

Extract the guard into `shouldIgnorePlaybackHotkey`, which additionally
bails when the event was already `defaultPrevented` or when the target or
active element sits inside a `[role=menu]`, and cover the menuitem case
with a small vitest.
2026-09-13 14:41:30 -07:00
teknium1
99979c3be2 fix: Star Map node menu stays inside the viewport near window edges
The Star Map right-click menu was a hand-rolled `position: fixed` card
placed at the raw `clientX/clientY`, so a star within ~75px of the bottom
(or ~144px of the right) edge clipped the `Delete memory` / `Archive skill`
row off-window while `Edit …` stayed visible — the destructive action
silently disappeared.

Reuse the shared Radix `DropdownMenu` anchored to a zero-size fixed span at
the click point — the exact pattern `AppContextMenu` already uses — so the
menu gets the same flip/shift collision handling (and `collisionPadding`,
keyboard navigation, Escape/outside-click dismissal) as every other menu in
the app, instead of adding a second bespoke measure-and-clamp path.

`Edit …` keeps the menu open while the node content loads (`onSelect`
`preventDefault`) exactly as before; `openEdit` closes it on success.

Refs #109288. Supersedes the measure+clamp approach of #109301 (credit
@KoNit-K for the diagnosis). #100894 routes the gesture to this menu and is
untouched.
2026-09-13 14:41:30 -07:00
teknium1
62a56f441c test(byterover): write the fixture .env with an explicit utf-8 encoding 2026-09-13 14:41:26 -07:00
teknium1
71cebc6348 fix(tools): browser_exec and computer_use caches are namespaced by the served profile
Both process-global caches were keyed by the caller's session/task id alone, so under
gateway.multiplex_profiles two profiles using the same id — a shared `browser_exec session=`
name, or two Hermes sessions whose screens report the same DISPLAY — resolved to the FIRST
profile's cloud browser / cua-driver, and a command issued in one bot's chat could act on
another bot's screen.

The key now carries the routed profile's home key whenever a served-profile scope is active
(`get_hermes_home_override()` set), the same shape `tools/approval.py::_baseline_key` and the
camofox/cloud caches already use; outside a scope every key is byte-identical to before. The
computer_use lookup, install and release paths all go through one `_scoped_sid`, so a release
under profile B never stops profile A's driver; approval-bypass state keeps the bare session id.

Fixes #110032 (report by @wolfyy970, from @vandaimer's manual test on #108914).
2026-09-13 14:41:26 -07:00
teknium1
444dcca650 test(hindsight): trim salvaged #108866 coverage to two invariants (secondary keeps own shaping; single-profile reads process env) 2026-09-13 14:41:26 -07:00
teknium1
6449a6c0e5 test(secrets): trim salvaged #108446 coverage to the two invariants (retry after failure; snapshot replaced on retry) 2026-09-13 14:41:26 -07:00
teknium1
732504c8d6 fix(memory/byterover): brv child carries the served profile's cloud key, never the launch profile's
Under gateway.multiplex_profiles os.environ holds the default profile's .env, so `_run_brv`
building the child env from raw os.environ curated a secondary profile's turns into the DEFAULT
profile's ByteRover cloud account (and prefetched the default's memories into the secondary's
context). The local half was already profile-scoped (`_get_brv_cwd`).

The child env now comes from `build_subprocess_env` and, under multiplex, strips the launch
profile's residue and sets BRV_API_KEY only from the served profile's secret scope — a miss means
no cloud key. Single-profile installs pass the process env through unchanged.

Closes #108993 (report and fix direction by @jonpol01).
2026-09-13 14:41:26 -07:00
webtecnica
9e6a8645ea fix(browser): resolve the Nous gateway from the picker selection, not only use_gateway
browser_exec with browser.cloud_provider: nous (the hermes tools picker row) fell into
the direct-API Browser Use branch and reported chrome-not-running, because
_resolve_backend_cdp gated on _use_gateway(), which only read the pre-picker
use_gateway: true flag. Recognize the picker selection too.
2026-09-13 14:41:26 -07:00
fangliquanflq
4a927e9a6e fix(gateway): revoke stale profile secret snapshots 2026-09-13 14:41:26 -07:00
fangliquanflq
d461b27dcd fix(gateway): clear stale profile secret snapshots 2026-09-13 14:41:26 -07:00
fangliquanflq
917cfbd740 fix(gateway): retry failed profile secret hydration 2026-09-13 14:41:26 -07:00
John Paul Soliva
2bade5daa7 fix(memory/hindsight): pin the isolation-vs-shaping split for scoped reads
`langfuse._secret` and `azure_identity_adapter._scoped_env` were changed to
raise rather than fall back, because swallowing `UnscopedSecretError` hides the
spawn-site bug the exception exists to surface. `_scoped_setting` looked like it
contradicted that, so make the split explicit and pin it.

Hindsight already follows the contract for everything that decides WHERE data
goes: `mode`, `apiKey` and the `bankId` partition read through bare
`get_secret`, so a scopeless multiplexed read raises. In `_load_config` that
raise happens on `HINDSIGHT_MODE` before any shaping value is reached, so the
swallow below cannot mask an isolation failure.

Presentation shaping is deliberately not in that class. `MemoryManager._each_provider`
logs an `initialize` failure at WARNING and drops the provider for the session,
so raising there would cost the whole memory provider because a speaker prefix
could not be resolved. It degrades to the provider's own default instead —
never to `os.environ`, which under multiplex is the default profile's.

The test names the offending key rather than asserting that something raised:
routing `mode` through the shaping helper shifts the failure to
`HINDSIGHT_API_KEY`, which a bare `pytest.raises` would still accept.
2026-09-13 14:41:26 -07:00
John Paul Soliva
a48dde5316 fix(memory/hindsight): resolve retain shaping through the profile scope, never os.environ
_load_config() reads the Hindsight bank, mode and retain tags through the profile
secret scope, but _apply_retain_settings() then discarded that answer and re-read
os.environ whenever the config value was falsy:

    return cfg.get(key) or os.environ.get(env_var, default)

Under gateway.multiplex_profiles os.environ holds the DEFAULT profile's .env, so a
secondary profile's scoped miss came back as the default profile's retain tags,
observation scopes, source and speaker prefixes — the fallback-after-miss shape
gateway/AGENTS.md forbids. Tags are Hindsight's retrieval partition and
metadata.source is opt-in by design, so the secondary's memories were both
mislabelled and selectable by the default profile's tag filters.

Both halves now go through _scoped_setting(), which resolves the value with
get_secret() and falls back to the provider's OWN default — a miss is a miss, the
same rule embedded.py already applies to the daemon's key and base URL. The three
raw reads left inside _load_config() (retain_source, retain_user_prefix,
retain_assistant_prefix), directly under the comment declaring them per-profile,
go through it too.

Single-profile deployments are unchanged: with no scope installed get_secret()
still reads the process env, where the value IS this profile's own.

Fixes #108865
2026-09-13 14:41:26 -07:00
teknium1
122ad719b5 fix(telegram): runner-side allowlist gate decodes JSON list strings too
The adapter fix decoded `'["-100","-200"]'` before comma-splitting, but
the runner's central gate in gateway/authz_mixin.py::_coerce_allow_set
reads the same YAML-bridged env chain (TELEGRAM_GROUP_ALLOWED_CHATS,
TELEGRAM_ALLOWED_USERS via _auth_env) and still produced
{'["1"', '"2"]'}, so a group message admitted by the adapter could
still be rejected upstream.

Move the decoder to gateway/platforms/_shared.py, which both the adapter
and authz_mixin already import from (no plugin -> gateway cycle), and
route _coerce_allow_set through it. One invariant test on the runner
side, red before this change.
2026-09-13 14:41:01 -07:00
teknium1
a3b4d70ea2 test(telegram): two invariant tests for JSON-string allowlists, under the plugin's test mirror
Trim the salvaged suite to the two invariants the fix guarantees: every Telegram allowlist
key (the five `_extra_str_set` readers plus `ignored_threads`) decodes a JSON-encoded string
and the group gate then admits the listed chat; comma strings, native lists and malformed
JSON keep the legacy split. Moved from tests/gateway/ to tests/plugins/platforms/telegram/ to
mirror the source path.
2026-09-13 14:41:01 -07:00
Konstantin Khlopkov
5cc9b31b7a fix(telegram): decode JSON-encoded allowlist strings before comma-split 2026-09-13 14:41:01 -07:00
liuzikaii
a15b8982d4 fix(gateway): preserve profile config changes during connection 2026-09-13 14:40:31 -07:00