Discord was the hardest gateway to set up: the .env template had no Discord
section at all, the wizard saved whatever was pasted, and users had to find
the Message Content Intent toggle, build an OAuth2 invite URL by hand and turn
on Developer Mode just to learn their own user ID.
The wizard now reads GET /applications/@me with the pasted token: a rejected
token is re-prompted instead of saved, an intent that is off gets a direct
link to the toggle and a re-check, the invite link is printed with the
permissions the adapter uses, and the allowlist defaults to the application's
owner (or accepted team members). Offline, the token is still saved with the
manual intent reminder. The wizard moves from the 7k-line adapter into
plugins/platforms/discord/onboarding.py.
Add the clarify text-fallback invariant. It drives the real
TelegramAdapter send through text_fallback_coro and fails on base, where
the send carries disable_notification=True. Drop the runner metadata
test: it only pins that the metadata did not change from base. The
_notification_kwargs parametrize already fails on base for
is_approval_prompt, which keeps the stack at two invariant tests.
The salvaged change also put notify=True on both approval sends.
On A2A, `notify` marks the turn-final reply, and A2A has no native
approval renderer. A prompt that needs a human decision would therefore
complete the caller's task with the prompt text while the turn is still
waiting (@ehz0ah's review on #132527). Telegram already pushes the
prompt through the approval-aware _notification_kwargs, so only the
is_approval_prompt marker is needed. The card send goes back to the
verbatim thread metadata: Telegram's control-message path never silences
cards.
Tests: the runner test pins that the fallback metadata stays interim and
does not carry notify. The adapter tests become a single parametrize.
Fold the _notification_kwargs change back into the original one-line
shape, so adapter.py grows by one line instead of six.
In the default Telegram notification mode ("important") the plain-text
approval fallback marked only is_approval_prompt, which no notification
decision consults, so _notification_kwargs sent the prompt with
disable_notification=True: the phone got no push, the user never learned
a dangerous command was waiting, and the tool sat the full approvals.timeout
(default 300s) before failing closed (#132516).
Mirror the cron-delivery convention on both approval sends in
_approval_notify_sync: notify=True on the button card and the text
fallback, and make _notification_kwargs itself approval-aware
(is_approval_prompt defeats disable_notification) so every surface that
marks the prompt is covered in one place. Loosens no guard and
auto-approves nothing.
(cherry picked from commit 576fec2ab6a6cdca08f96968574ba741c47568c0)
Gate BR3 r1 reuse (Low): build_update_processor hand-rolled the int-or-
default parse that gateway.platforms._shared.coerce_port already does.
Neither caught OverflowError, so `max_concurrent_updates: .inf` in YAML
aborted connect(). coerce_port now treats non-finite values as unparseable
(as gateway.config._coerce_num already does), which also hardens its other
port/int callers; the telegram setting warns and falls back to 32.
Gate BR3 r1 quality (Low): PTB's SimpleUpdateProcessor already provides the
no-op initialize/shutdown the class copied, and config.extra is always a
dict, so the `(extra or {})` guard was dead. The wiring test only compared
PTB's concurrent_updates property with itself (true for any processor);
assert the PerChatUpdateProcessor type and DEFAULT_MAX_CONCURRENT_UPDATES
instead - now red when a plain SimpleUpdateProcessor(32) is wired in.
PerChatUpdateProcessor awaited the predecessor's tail future directly, so
cancelling a waiting same-chat update also cancelled that future (gate
BR3 r1 2c + quality, Medium). The running predecessor then raised
InvalidStateError from done.set_result() after its handler succeeded, and
the next waiter started while the predecessor still ran (probe order:
start A, start C). Shield the await and, for a cancelled waiter, release
its turn only once its predecessor finishes.
Plain PTB concurrency (concurrent_updates=32) unblocks other chats while one
chat's handler is slow, but also lets that chat's NEXT update dispatch and
deliver before the first one finishes (download/vision/callback work happens
inline, before text batching can merge them).
PerChatUpdateProcessor (update_admission.py, not the 7k-line adapter) chains
each update behind the previous update of the same chat while keeping the
bounded cross-chat pool. The builder keeps the instance, so the connect-retry
rebuild in _initialize_app_with_retries reuses it. An invalid
max_concurrent_updates now logs a warning instead of silently falling back
(as #74652 did), and the dispatch-stall watchdog comment no longer claims PTB
dispatches sequentially.
Co-authored-by: fortona-space <299084105+fortona-space@users.noreply.github.com>
Fold of gate finding (2c Medium regression): raising _INGRESS_DISPATCH_STALL_HEARTBEATS to 4
left the `ingress` row of test_only_pre_handoff_failure_reopens_admission looping 3 heartbeats,
which can never reach the threshold, so its "no healthy-but-deaf report" assertion was disarmed.
Loop the threshold itself so the leg tracks future bumps. Verified: with update_admission's
dispatch counting removed, the heartbeat leg now fails (it passed with range(3)).
An edit carries m.mentions twice: once inside m.new_content and once at the
top level. When a budget-sized edit names many distinct Matrix IDs, that
fixed part alone could pass the content budget. Shortening the fallback body
then had no room to work with, and the edit still went out at about 54 KB,
which an encrypted room would reject with M_TOO_LARGE.
When the edit is still over budget with an empty fallback, it now drops the
top-level mentions copy. That copy only drives the edit's own notifications,
and m.new_content keeps the full list.
The event-size tests move to tests/plugins/platforms/matrix/test_event_size.py,
mirroring the plugin source tree, and gain a mention-heavy text. The
tests/gateway/test_matrix_message_length.py file is back to main apart from the
env-override value that the new ceiling requires.
(cherry picked from commit 4b06bcb7dbffb732bef6e877cb66dea2fbff8c73)
The Firecrawl scrape() call was missing the `timeout` parameter, so the
server-side used its 30s default. The client-side asyncio.wait_for
expects 60s, causing SCRAPE_TIMEOUT on slow pages before the Python
deadline expires.
Fix: pass timeout=60000 (ms) to scrape() so both deadlines align.
Fixes#43272
The provider's own suite and the supermemory cases in the shared memory
suites (BOM-tolerant config reads, PM SDK admission, multiplex identity
scope, provider threads inheriting the profile, plugin-family secret scope,
cron agent_context) imported plugins.memory.supermemory, which is gone.
The other providers' cases in those suites are unchanged; the SDK-admission
test keeps its mem0 case.
Generated image/video output now writes under cache/generated/images|videos
instead of the swept inbound caches (cache/images, cache/videos). The hourly
gateway housekeeping deletes 24h-old files in those dirs; for base64-returning
providers the swept file was the only copy, breaking transcript references,
the Artifacts panel and later edits.
Rebased onto current main, where the writers delegate to agent/provider_media,
so the media kind is now the generated/ subpath. cache/generated is added to
tools/credential_files._CACHE_DIRS: without a mount/sync entry the Docker,
SSH and Modal backends never receive the bytes and the provider result loses
agent_visible_image. All callers consume the returned absolute paths, so no
call-site changes were needed; inbound download paths (get_image_cache_dir /
get_video_cache_dir) and the sweeper are untouched.
RED: tests/agent/test_generated_media_durable.py (9 failed on main) — 25h-backdated
generated files deleted by the real cleanup_image_cache / cleanup_video_cache, and
no mount mapping for cache/generated. tests/tools/test_image_generation_artifacts.py
::test_real_generated_image_is_agent_visible_under_ssh_env covers the sandbox
regression with the real writer. Green: 533 passed, 19 skipped.
Co-authored-by: Froraut <79215537+Froraut@users.noreply.github.com>
(cherry picked from commit fd23181f22232f66aec7076d6bf0868ed45661f2)
The kanban test's hook leg used to rewrite the pre-existing attachment, which
the created-paths snapshot already ignores, so the kanban exclusion was never
reached on that path. It now creates a NEW kanban file via the call and
asserts it survives session end. Without the kanban entry in
_NEVER_TRACK_TOP_LEVEL that file gets tracked and deleted.
Also moves the function-local `import os` to module level and replaces
iter(range(1, 10**6)) with itertools.count(1) for call IDs.
Keep the headline invariant (a call never tracks paths it did not
create) and the cross-session snapshot isolation case; the rewritten-args
case exercises the same fail-closed branch as the headline test.
The pre/post snapshot handshake had two ways to mark a user's existing
file as created, which queues it for deletion when the turn ends:
- Snapshots were keyed by tool_call_id alone. llama.cpp sends one
constant id for every call. When session B's pre hook ran between
session A's pre and post, A consumed B's snapshot. A's
already-existing test_user.py then looked "not in existing", and A
tracked it.
- Every pre_tool_call hook sees the original args. Another plugin's
`modify` directive is merged afterwards, and post_tool_call sees the
final args. A path swapped in by that hook was never snapshotted, so
"not in existing" held for it too. The file was tracked even when the
write was refused.
Snapshots are now keyed by (task_id or session_id, tool_call_id), the
identity both hooks receive from the dispatcher. They store the paths the
call named that did not exist yet, instead of the ones that did. The post
hook tracks a path only if its own snapshot saw it absent and it exists
now. Any path the snapshot did not cover fails closed.
I reproduced both end to end through model_tools.handle_function_call
with a second plugin's modify hook and two interleaved sessions. The
user's file was deleted before this fix and survives after it, and a
file the call really created is still cleaned up. The README hook
contract is updated to match.
(cherry picked from commit 8d0b853981f1ab0775549f4831a3d4f733a46106)
post_tool_call sent write_file/patch paths, every absolute path in a
terminal command AND every path in its output to dg.track(), gated only
on `p.exists()` plus the test_*/tmp_* name rule. Category 'test' is
deleted at age 0 by quick(), which on_session_end runs after every turn,
and directories are rmtree'd. So a user's cron script
$HERMES_HOME/scripts/test_uptime.py the agent merely patched, or a hook
dir $HERMES_HOME/hooks/test_notify it listed (`ls -la`, `find ... -name
'test_*'`), was irreversibly deleted when the turn ended — then the
emptied scripts/ and hooks/ dirs too. The plugin's contract is files
"created during Hermes sessions".
A pre_tool_call hook now snapshots which of the call's argument paths
already exist (keyed by tool_call_id; it never raises, since pre hooks
fail closed; entries expire after an hour if the post hook never
fires). post_tool_call tracks a path only when the call named it, it
did not exist before and exists now. Paths seen only in terminal output
carry no such proof and are no longer tracked; no snapshot means
nothing is tracked. Existing tests now drive the real hook order.
(cherry picked from commit e0e0e1f7450df0c75a8499fe57b63dc46c6d2af2)
The fail-closed change dropped the localhost_only() short-circuit from
is_trusted_peer. In no-token mode authenticate() always yields ip:<addr>,
so any install with A2A_TRUSTED_PEERS / a2a.trusted_peers configured
started 403-ing every local dispatch. Restore the short-circuit and pin it
in test_localhost_trusts_all.
Also:
- log the non-loopback/no-allow-list misconfiguration once in
A2AAdapter.connect() instead of an ERROR on every refused request;
- is_loopback_bind reuses agent.proxy_bypass.is_loopback_host instead of a
hand-rolled loopback check;
- fold the loopback backward-compat test into
test_no_allowlist_trusts_authenticated (now explicit A2A_HOST=127.0.0.1)
and drop implementation asserts on is_loopback_bind().
Keep non-loopback+empty allowlist -> closed (the #126756 headline) and
loopback+token+empty allowlist -> open (backward compat). Allowlist
membership and allow-all are already covered by existing TestTrustedPeers
cases.
The inlining step called ref.get() on every reference, so a legacy
image_style_references entry that is neither a string nor a dict raised
AttributeError out of generate() (main forwarded it to Krea). It now passes
through untouched.
The 3 MB budget was checked against stat() and the file read separately,
so a file that grew in between was sent in full. Each file is now read once,
at most one byte past the remaining budget, and the cap applies to those
bytes. The refusal test states each expected error_type and message
directly.
The salvaged inlining checked is_file() and summed st_size before
raise_if_read_blocked(), so a denied path (e.g. HERMES_HOME/.env) answered
"not found" or "over 3 MB" and revealed whether it exists and how big it
is. The guard now runs first, and its denial comes back as the plugin's
normal error dict instead of an exception escaping generate().
Local files are inlined after the managed-gateway LoRA/moodboard fail-fast,
so a request about to be refused no longer reads and base64s up to 3 MB.
The size message is built from the constant. The four salvaged tests
become one embed test and one parametrized refusal test (missing file,
total over the cap, guard-denied path).
- relay-deliver-budget: the ceiling/timeout composition is checked
against the imported constants (which the mirror tests already tie to
the Python defaults) instead of re-reading config_defaults.py.
- terminal env sync: the key recorder also traps get()/[] and is an
instance, the probe fails loudly if the bridge looks up no keys (so the
gateway-only-key check cannot go vacuous), one _cli_env_map() helper
serves both tests, and the docstring says what is actually driven.
- live_system_guard bypass reuses _live_system_guard_is_active().
- retaindb no-client prefetch asserts no thread is spawned through the
module's spawn_context_thread seam, not a private list.
Each changed assertion re-proven red by its production mutation.
Each test passed with the behaviour it names removed (proven by mutating
the production owner on the old and new test):
- s6 hooks test_register_noop_on_host: the hook swallows the
AssertionError into a printed warning, so reaching the manager never
failed the test. Assert nothing was printed, as the sibling test does.
- live_system_guard bypass: signal 0 to our own PID passes the guard too.
Assert os.kill is the unpatched builtin.
- retaindb queue_prefetch without a client: a missing guard fails only in
background threads. Assert no prefetch thread was started.
- test_config golden migration parity: drop the `_config_version == 33`
exact-match branch, which cannot run again now the version is 49; the
subset check it fell through to is unchanged.
test_terminal_config_env_sync now drives gateway.run's
_bridge_terminal_config_to_env with every known terminal key instead of
parsing gateway/run.py's source for a dict literal. It asserts each key
reaches the same TERMINAL_* var as the CLI, which the key-set comparison
could not check, and removes the last inspect.getsource in tests/.
Same race as the WhatsApp bridge (#127047): a container/supervisor stop
signals the whole process tree, so the Photon sidecar (its own session)
can die before the gateway's stop flow reaches disconnect(), while
_inbound_running is still True. _supervise_sidecar() then reported
SIDECAR_CRASHED and queued a reconnect during shutdown.
Consult the runner's _stop_requested_by_signal, which the signal handler
sets before any stop work runs. A sidecar exit while the gateway keeps
running is unchanged: still a retryable fatal error.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
WHAT: regression test mounting the kanban plugin router exactly as
`_mount_plugin_api_routes` does (behind `_plugin_route_secret_scope`) and driving
Specify under `set_multiplex_active(True)` for launch profile A -> `?profile=workerb`
-> A, asserting the aux call's `get_secret` sees each profile's own key.
WHY: `_run_aux` / `_run_estimate` only pinned the board, never a profile scope, so the
aux client's provider-key read failed closed under multi-profile hosting. The scope is
now bound once at the plugin mount (previous commit) rather than per aux call inside the
plugin: one mechanism for every plugin router, and the `?profile=` request convention
works for the kanban routes too.
Fixes#123372
(cherry picked from commit 6f01ddc9e455b3eee7046d5b1ac969df3499cbeb)
Follow-up trim to @JE4NVRG's salvaged commit (is_connected → env_is_connected("A2A_PORT")):
- plugins/platforms/a2a/tools.py::_a2a_tools_available read os.getenv("A2A_PORT") too — the
same unscoped gate in the same plugin, so every secondary profile also paid for the a2a
toolset whenever the launch profile set A2A_PORT. Now get_scoped_secret, same policy as
the adapter's port read (scoped miss fails closed; default/T1 reads its own environ).
- Tests trimmed to two invariants: A→B→A over two temp profile homes under
set_multiplex_active(True) with the launch value in os.environ (profile B with no
A2A_PORT of its own is NOT connected and gets no tools; red on origin/main at index 1),
and the standalone T1 case where os.environ IS the profile.
Row L1 (boot-time platform enablement probe), topologies T2/T3 (multiplexed host serving
secondary profiles). No other plugins/platforms/*/__init__.py is_connected/check_requirements
does an unscoped env read (buzz #125985 is handled separately).
Co-authored-by: JE4NVRG <jean.v1803@gmail.com>
is_connected() gated on a raw os.getenv("A2A_PORT"), which under multiplexing
holds the launch (default) profile's value. Every secondary profile therefore
instantiated the inbound A2A server, all of them fell back to the module
default port 9900, and all but one died with "could not bind ... Address
already in use" (fatal / bind_failed in gateway_state.json) on every boot.
Resolve the gate through gateway.platforms._shared.env_is_connected, the same
scope-aware idiom the sibling adapters use -- the adapter's own port read
already goes through _get_scoped_secret for exactly this reason.
Tests: tests/plugins/test_a2a_plugin.py pins the behavior. A profile with no
A2A_PORT in its own scope is not connected and never reads os.environ (a raw
os.getenv fails the test), a scoped port connects, and extra.enabled still
short-circuits.
Fixes#122126
Maintainer ruling: only Hermes and only its packaged package managers
(uv/pip/node/npm) are ever used; no PATH fallback when the managed tool is
missing, no "prefer the user's if new enough".
- hermes_constants.find_node_executable: node/npm/npx resolve to PM's
installed copy or None. Every caller already pm.ensure()s on None, so a
missing runtime is now provisioned instead of silently borrowing the
user's Node (native-addon ABI / npm cache mismatches).
- agent/lsp/install._install_npm: pm.ensure('npm') when PM npm is absent,
instead of failing over to whatever npm is on PATH.
- gateway._append_node_dir_for_service: stop baking the invoker's PATH node
dir into generated systemd/launchd units.
- main_install_repair._resolve_node_runtime_npm: drop the PATH re-scan for
another npm.
- source_build.source_product_current: run the freshness reader only with
PM's node.
- doctor: Node/npm rows and npm audit use PM's copies (Termux APT distro
keeps its system Node).
- install.sh ensure_uv / install.ps1 Get-Uv: always stage the pinned uv
artifact; delete the "uv on PATH if new enough" developer shortcut.
RunClock ticked from tasks.started_at (the task's first-ever start), so after
a review timeout + retry a healthy current run showed cumulative card age
(e.g. working · 2h for a minutes-old run).
_task_dict() now emits current_run_started_at from the run row
tasks.current_run_id points at (batched single query, same as latest
summaries), and RunClock prefers it, falling back to started_at for older
backends. Fixes#99819.
The control test only added one thing over the first new test: a quick()
deletion check for untracked scratch in a git-init'ed HERMES_HOME. It also
passed on base, so it pinned no new behaviour. Save both entries in the
first test's quick() call and assert the tracked file survives while the
scratch beside it is deleted, then drop the control. The stack now adds
one test, and that test is still red on base.
Update test_scratch_outside_git_trees_still_cleaned's docstring. It said
only a .git below HERMES_HOME marks a file git-owned, which stopped being
true once an enclosing repo that tracks the file counts too.
Keep the stack at two invariant tests: the tracked-file test now also
covers a file committed after first classification in the same process
and a HERMES_HOME nested in an enclosing repo; the separate stale-cache
test is redundant now that there is no cache.
Co-authored-by: David Crandall <david@convergentdesign.dev>
`_git_tracked_index()` cached one `git ls-files` set per process, so a
long-lived process could keep classifying a file as untracked after git had
taken ownership of it. `guess_category()` runs on every post-tool-call, so the
gateway can cache the index while `test_scratch.py` is still untracked; an
auto-snapshot then stages and commits it, `quick()` re-validates the stored
"test" entry through `guess_category()`, the stale cache still reports the file
as untracked, and `_delete_item()` unlinks a file git now owns (`git status`
shows `D test_scratch.py`).
Key the cached set on the git index's stat signature as well as the home, so a
staged, committed or unstaged change is a new cache key and no invalidation
hook is needed anywhere. The index path comes from `git rev-parse --git-path
index`, cached per home — so the per-call cost stays a single `os.stat`, which
also covers a linked worktree (where `.git` is a pointer file) and an explicit
`GIT_INDEX_FILE`. Fail-open behaviour is unchanged: no index and no git mean the
empty set, and the guard never raises.
Regression test: classify an untracked `test_scratch.py`, then `git add` and
`git commit` it in the same process with no `cache_clear()`, and assert
`quick()` leaves it on disk while an untracked control beside it is still
cleaned.
(cherry picked from commit a3271750608ca4e02e5205311a09b906e16d33d5)
The `_inside_git_worktree()` guard sliced its parent chain at HERMES_HOME, so
`.git` entries strictly below the home were the only ones consulted. That misses
the case where HERMES_HOME IS a git checkout: on this install `~/.hermes` is the
userfiles repo, so `~/.hermes/scripts/` and any top-level `tmp_*` file sit inside
a worktree yet resolve to no `.git` below the home.
Observed live: the bundled disk-cleanup plugin classified two COMMITTED
regression tests in `~/.hermes/scripts/` as disposable session scratch and
unlinked them — `scripts/test_analyze_upstream_opportunities.py` and
`scripts/test_customization_protocol_v2.py`; the deletion was then committed by
the next auto-snapshot (ce8dc99), so the suite that would have caught a scanner
regression was silently gone. 13 tracked `test_*` files under `scripts/` plus 17
tracked top-level `tmp_*` files were in the same blast radius.
Fix: when HERMES_HOME itself carries `.git`, ask git whether it TRACKS the exact
path (`git ls-files`, one cached subprocess per process keyed on the home) rather
than treating the whole home as protected — a scratch file merely living beside
tracked ones must still be cleanable, or cleanup would be disabled entirely.
Tests: two regression tests — a git-tracked `test_*` file is never classified
disposable and a stale pre-fix tracked.json entry is dropped by quick()'s
re-validation instead of deleted; plus the control that an UNTRACKED `test_*`
file in the same repo is still cleaned. Existing suite 30 -> 32 passed.
(cherry picked from commit c5555940108286c4c1070e84d6d694ad9a35bb76)
(cherry picked from commit c7c39e60d4dc467bf9681c3dcb5781ac96557724)
Follow-up to the #103857 salvage. The Copilot provider profile and the
main-agent GitHub reasoning path each carried the same clamp-then-medium
fallback. Both now call hermes_cli.models.clamp_github_reasoning_effort.
The offline Astra tests move next to the other Copilot effort tests, along
with a check that a structured catalog entry still wins. The main-agent
clamp now has a test pinning max/ultra -> high on the GPT-5 ladder. The
transport test no longer writes config.yaml. The two effort comments now
say the same thing.
Desktop opened /events with no since, and a missing cursor was read as
0, so every open replayed task_events history. Seed the socket from the
snapshot or this connection's last frame, and start a cursorless stream
at MAX(id). An explicit since still replays from there.
Fixes#81537
Gate round-1 follow-ups on the #121486 fix:
- auxiliary_client: inline the pool route lookup (no dead try/except or
fallbacks; HERMES_CODEX_BASE_URL short-circuits once) and read auth.json
directly when the pool yields no token (no second uncached pool load,
no re-select race pairing a new pool key with chatgpt.com).
- image plugin: _read_codex_credential() is the single source for both
is_available() and generate(); _post_image_request requires base_url.
- auth_codex: drop the unused _pool_codex_access_token wrapper; the route
helper's error fallback reads the profile-scoped override, not the raw
process env.
- model setup flow: the confirm guards get the resolved Codex base, not
the chatgpt.com constant.
- cli_model_switch_mixin: self.base_url is always set.
Follow-up to the two contributor commits for #121486. The picker, the
image plugin and the auxiliary Codex client still composed a pooled
gateway key with a base re-read from ambient state (HERMES_CODEX_BASE_URL
or the chatgpt.com default), so a model.base_url-only gateway (env unset)
still sent its key to chatgpt.com.
- auth_codex: resolve_codex_runtime_credentials reports the host a pooled
credential actually routes to (runtime_provider._pool_entry_mode_and_url:
env > model.base_url while the row is canonical > row URL) instead of the
ambient default; get_codex_auth_status carries the same bound base_url.
- picker: get_codex_model_ids(access_token, base_url=) now receives the base
resolved with the token from hermes_cli/models.py, the CLI default-model
swap (self.base_url) and the `hermes model` Codex flow.
- aux/image: _resolve_codex_credential_and_base() returns (token, base) from
one pool selection; the image plugin, _build_codex_client and the raw
Codex client use it (profile-scoped override from #121497 still wins).
- model_metadata: the non-JWT refusal now applies only when the target is
chatgpt.com; a gateway key may probe its own gateway's /models.
Adversarial regressions: model.base_url with env unset, env/route mismatch,
opaque + JWT gateway keys, pool-selected credential, pool row with its own
gateway URL, direct-ChatGPT positive control.
Addresses @andrexibiza's review on #121508.
Behind a custom Codex base URL (HERMES_CODEX_BASE_URL / model.base_url
gateway) three paths still hit the hard-coded chatgpt.com host with the
gateway's credential-pool key (#121486):
- the OAuth context-length probe (agent/model_metadata.py) and the
/model picker's live discovery (hermes_cli/codex_models.py) both GET
https://chatgpt.com/backend-api/codex/models with
Authorization: Bearer <gateway key> whenever model.context_length is
not pinned — the key is sent to a service it does not belong to and
cannot answer for;
- the openai-codex image_gen plugin posts to the same hard-coded base.
Fix, mirroring the quota probe's existing gate in auth_codex:
- both catalog sites now decline to probe non-JWT credentials (real
Codex access tokens are JWTs; a gateway key is not one) and fall
back to the static table / offline sources — same outcome as the
doomed request today, minus the credential leak;
- a JWT reached through a custom base now probes that base's own
/models instead of chatgpt.com (catalog URLs are built from the
resolved base; the per-token cache key includes the base);
- the image plugin resolves its base from HERMES_CODEX_BASE_URL the
same way the text client does.
Fast-mode host gating in the /fast picker is intentionally left
untouched: lifting it needs an explicit opt-in design decision, not a
bug fix.
(cherry picked from commit 5d76ec525674d7b103ab53955ba5279605457ca1)
[salvage: plugins/image_gen/openai-codex/__init__.py hunk dropped in favour of #121497 (first submitter, profile-scoped override + base-aware Cloudflare headers)]