Closes the last two emission gaps from #77484:
- tools/terminal_tool.py: both exception paths (generic except and
TERMINAL_DEGRADED_MODE=fail) returned raw str(e) + traceback.format_exc()
to the model — only the logger copy was redacted. Exception text can
embed the failing command line and any secrets inline in it; both fields
now pass through redact_sensitive_text.
- acp_adapter/entry.py: _setup_logging cleared root handlers and installed
a plain logging.Formatter, bypassing redaction entirely on ACP stderr.
Now uses RedactingFormatter like every other logging surface.
The other three gaps from #77484 (process(list), *_KEY regex variants,
control-char splits) were fixed in #80964/#80965.
image_generate and video_generate forwarded model-supplied local paths to
provider plugins, which read them off the HOST filesystem regardless of
terminal backend — inconsistent with the confinement boundary vision/video
analysis enforce (GHSA-gpxw-6wxv-w3qq), and broken for sandbox-only files.
New dispatch-layer chokepoint (_confine_source_images): under a non-local
backend, path-like image_url / reference_image_urls resolve through
tools.image_source (media-cache host reads, bounded in-sandbox exec-read,
lazy env bring-up, credential guard, 50MB cap) and reach every provider as
data: URLs — which all backends already accept. URLs/data: pass through;
local backend is a no-op. xai_video_edit/extend already require public
HTTPS URLs, so no change needed there.
- description 219 -> 58 chars
- author credits Ben Barclay (benbarclay) first
- modern section order; trimmed template safety boilerplate into
step-local rules and a skill-specific verification checklist
- tests at tests/skills/test_email_inbox_triage_skill.py (9 passing)
- docs regen scoped: per-skill page + one catalog row + one sidebar line
--resume latest resolves the most recent session through the same
workspace-scoped MRU lookup as -c (TUI source first under --tui, with
classic-CLI fallback). --in DIR chdirs before session resolution so the
lookup keys off DIR's workspace, and pins the session there by skipping
the recorded-cwd restore.
Requested by @Jeff9James: hermes --tui --resume latest --in ./dir
Under the Docker terminal backend, vision_analyze's first exec-read
sometimes returned empty / non-zero against a freshly started container,
producing 'could not read <path> inside the sandbox' on a file the agent
could cat seconds later. Cold pipe setup on the first exec against a
new container, not a permissions or mount problem.
Retry once after a short delay (150 ms covers Docker exec warm-up
without making a real failure feel sluggish). When every attempt still
fails, fold the container's first stderr line into the raised error so
the user can tell 'no such file' from 'permission denied' instead of
staring at one opaque message.
Tests cover the retry-then-succeed path, the diagnostic-on-exhausted
path, and confirm the existing single-attempt raise is preserved.
Follow-up on the salvaged commit: replace the hand-rolled file_ops python3
exec-read with tools.image_source.resolve_image_source(permitted=('video',)),
so video_analyze gets the same pipeline as vision_analyze — media-cache host
reads, bounded head -c sandbox exec (no python3 dependency in the sandbox
image, no unbounded base64 stream), lazy env bring-up (#62825), the
credential-read guard, and the 50MB ingest cap.
The Docusaurus build step (198s) is 79% of the docs-site-checks job
wall time and is the CI critical path. The site has two locales (en +
zh-Hans, ~700 pages total); building only the default locale in PR
checks halves the build time.
Follows the same pattern Docusaurus uses internally: a build:fast
script that runs `docusaurus build --locale en` for CI/preview builds,
while the full bilingual build runs only in deploy-site.yml for
production deploys.
deploy-site.yml is unchanged — it still runs `npm run build` (all
locales) on push-to-main and release.
- Migrate the missed 6th inline formatter (update_cmd.py backup-size
display) to the shared helper.
- checkpoints._fmt_bytes: plain alias instead of a None-guard wrapper —
every caller feeds ints from checkpoint_manager (all size fields
initialize to 0), so the None path was dead defensive code.
- Drop the fallback= kwarg (zero production callers; '?' default is the
real inherited contract and stays).
- curator_backup + context_references: call format_bytes directly (single
internal call site each, zero external importers — alias was churn
avoidance with nothing to avoid). backup/_format_size and
doctor/_human_bytes keep their aliases (claw.py + tests pin the former;
three call sites use the latter).
- Reshape the loop so the trailing TB return is reachable (no dead line).
- Tests: replace alias-identity assertions (ossified the delegation
mechanism) with behavior-contract equality over a value sweep;
mutation-checked red-green.
- update_cmd parity: byte-identical B-GB vs the old inline loop; gains
the TB tier.
Five modules each carried a private near-identical human-readable byte
formatter (backup._format_size, checkpoints._fmt_bytes,
doctor._human_bytes, context_references._human_bytes,
curator_backup.format_size). Three of them silently topped out at GB and
rendered a 1 TiB value as '1024.0 GB'. All five now alias one shared
format_bytes in hermes_cli/sizefmt.py (sibling of timefmt.py, same
zero-dependency rationale), keeping each module's established local name
so no caller churns.
Deliberately NOT migrated (behavior differs on purpose):
- session_recovery._format_bytes: binary suffixes (KiB/MiB/GiB)
- qqbot chunked_upload.format_size: '100.0 B' one-decimal style, pinned
by its protocol tests
Net -33 production LOC before the new module; parity verified over a
16-value corpus against all five verbatim originals (only divergence:
the TB tier fix). Contract tests mutation-checked red-green.
On Windows, truststore.inject_into_ssl() replaces ssl.SSLContext with an
OS-trust-store-backed context whose get_ca_certs() raises NotImplementedError
(empty message). The ssl_guard's _validate_bundle_path() called get_ca_certs()
unguarded, crashing every fresh agent init with an opaque
'Failed to initialize OpenAI client:' error.
create_default_context(cafile=...) already validates that the bundle is
parseable, so we skip the post-load introspection rather than treat the
NotImplementedError as a failure.
Cherry-picked from PR #49945 with comment trimmed.
Co-authored-by: WolftacDigital <jonathan@wolftacdigital.com>
The _bash_exec_payload delegation rejected short-option bundles with
letters outside bash's alphabet, so 'zsh -yc', 'dash -Vc' and 'ksh -Gc'
scripts stopped being scanned — a fail-open regression for shells the
guard's _SHELL_EXECUTABLES explicitly covers. Try the bash grammar
first (catches operand-hidden -c), then fall back to the permissive
positional scan; a block-guard fails closed.
_format_db_size reimplemented human-readable size formatting two
imports away from backup._format_size, which doctor already leans on
for _QUICK_STATE_FILES. Delegate and keep only the stat-failure wrap.
Sizes now scale units (KB/GB) instead of pinning everything to MB.
model_tools._TOOL_ERROR_MAX_LEN (2000, '...' marker) and the new
registry._MAX_TOOL_ERROR_CHARS (2048, '… [truncated]' marker) were two
caps for the same budget; text on the dispatch exception path passed
both. Alias the sanitizer's cap to the registry constant so the
tool-error budget lives in one home. model_tools already imports from
tools.registry at module level, so no import-cycle risk.
bisect sat in _KNOWN_GIT_BUILTINS and was allowed in the running source
root, yet it repeatedly checks out commits — the exact module-version
skew this guard exists to prevent. Move it to _WORKTREE_MUTATIONS.
_KNOWN_GIT_BUILTINS omitted reset/stash/clean/restore (whose dangerous
forms _mutates_worktree already classifies first) and common read-only
porcelain (reflog, ls-files, cat-file, shortlog, show-ref, ls-tree,
ls-remote, merge-base). Every safe use inside the source repo — 'git
stash list', 'git reset --soft', 'git clean -n', 'git restore --staged'
— fell through to _read_git_alias and spawned a 'git config --get
alias.X' subprocess per terminal command (~10ms, 1s worst case on a
locked config). Complete the builtin set; the mutation classification
is unchanged and runs first.
The guard's _shell_script_arg treated any leading option containing 'c'
as -c and looked no further, so 'bash -o pipefail -c "git checkout
main"' returned None and the script was never scanned (fail-open).
approval.py's _bash_exec_payload already parses bash's real option
grammar (-O/-o consume operands, short-option bundles, --init-file);
delegate to it instead of keeping a second, weaker parser.
truncated_response_parts were joined with no separator at both the
ceiling exit and the success path, so a fragment ending mid-word ran
straight into the next one (#78577). insert a newline only when the
previous fragment ends non-whitespace and the next starts
non-whitespace, so existing separators are not doubled.
the scaffolding marks are hermes bookkeeping. only the chat-completions
transport strips underscore keys, so anthropic and bedrock requests on
continuation attempts 2+ would send the marks to strict providers. pop
them in the central api_messages sanitization next to _thinking_prefill.
also pin that a mark reloaded from a mid-crash persist on a prior turn's
message is never deleted by a later turn's ceiling cleanup.
a turn that exhausts all 4 length-continuation attempts used to persist
its interim fragments and '[System: ... continue ...]' user nudges into
the session transcript. every later user turn replayed the unanswered
nudges, so the model resumed the oversized response, truncated again,
and re-exhausted the ceiling - wedging the session regardless of input.
at the ceiling exit, drop the fragment/nudge scaffolding from the turn's
tail and store one settled assistant turn carrying the stitched partial
text. the marks are cleared on continuation success and on the
content-filter rollback so cleanup can never delete fragments whose text
was already consumed.
also stop labeling a finish_reason='length' stub a network error: report
it as a truncation (stream ended before completion) and say the partial
response is kept when the ceiling is exhausted.
hermes doctor already warns when the linked SQLite carries the WAL-reset
bug, but it never said which databases are actually exposed. A database
already in WAL mode on a vulnerable runtime can still corrupt; one on a
rollback journal cannot. Doctor now lists each Hermes-managed database
with its journal mode next to the SQLite version line and marks the WAL
ones as exposed when the runtime is vulnerable.
The probe reads the 20-byte file header and checks byte 18 (2 = WAL,
1 = rollback journal). It deliberately avoids the SQLite engine: even a
read-only open creates -wal/-shm sidecars next to a WAL database, needs
directory write access, and can wait on locks. The header read does none
of that. It cannot tell delete from truncate/persist, so doctor reports
'rollback journal mode' rather than an exact mode name. Missing files
are skipped; empty, unreadable, or corrupt files are reported as
unreadable without failing doctor.
The database list reuses backup.py's _QUICK_STATE_FILES plus per-board
kanban databases. Exposure uses hermes_state.is_sqlite_wal_reset_vulnerable,
so the 3.50.7 and 3.44.6 backports count as fixed.
`worktree` sat in _KNOWN_GIT_BUILTINS, so the guard returned safe for the
whole family. That allowed `git worktree remove [--force] <root>` and
`git worktree move <root> <dest>` against the very checkout this process
runs from, which the guard already treats as a source root when its .git
is a linked-worktree file.
Both name their target as an argument rather than acting on the cwd, so
they also slipped past the "is the cwd inside root" gate when run from
outside. Resolve the target against the command's cwd and block it when
it lands on the running root, from any directory. `worktree add`, list,
prune, lock, unlock, and operations on other worktrees stay allowed.
The function-scoped import at the end of run_conversation loads
agent.turn_finalizer fresh from disk on the first turn that reaches it.
On a source/editable install whose checkout changed mid-session, that
pairs an old caller with a new callee at the exact seam where every turn's
work is persisted — the turn crashes on a signature mismatch after the
work is done. The lazy import was never cycle-forced: turn_finalizer
defers its own conversation_loop import.
Wire the self-repo guard in next to the gateway lifecycle hard-block,
before the force check — force=True cannot make the command safe, only
delay the crash. Local backend only: sandboxed backends cannot reach the
host checkout. The block message explains the version-skew mechanism and
redirects to git worktree add / a temp clone, or running the command
outside hermes with a restart after.
When hermes runs from a source/editable install, a git checkout/reset/pull
in its own repo swaps code on disk under the live interpreter. Modules
imported before the switch stay old while later lazy imports load new code,
producing delayed signature TypeErrors and tracebacks that don't match the
source, typically losing the in-flight turn.
New tools/self_repo_guard.py detects working-tree/ref mutations (checkout,
switch, reset, rebase, merge, pull, restore, stash, clean, cherry-pick,
revert) whose target repo is the source root the process runs from, via
cwd, git -C, cd chains, and subshell segments. Read-only git, commits,
fetch, and git worktree add stay allowed; packaged installs (no .git) are
inert.
dispatch() called logger.exception with the exception interpolated into the
message. exc_info renders the same exception again in the traceback, so a
failing tool wrote its error body to the log twice. Every tool exception
passes through this one handler, so a large HTTP error body from any tool
landed here at full size.
Bound the message copy. The traceback still renders the exception once,
which is what an operator needs to place the failure.
Same double-write @arimu1 fixed in the vision, image, and TTS handlers in
#75938.
The debug line that fires when an error body is truncated interpolated the
whole untruncated body, so capping the model-facing copy still wrote the
original to the log. A large HTTP error body — a Cloudflare challenge page
or a proxy 502 — reached the log at full size on every failed call.
Log a bounded prefix instead. It stays longer than the model-facing cap so
an operator still has something to diagnose with, but it no longer grows
with the size of the response body.
Reported for the logging handlers in #75938 by @arimu1; the same pattern
was present here.
tool_error() caps its message at _MAX_TOOL_ERROR_CHARS (2048), logging
the full body at DEBUG before trimming the context-bound copy.
Handlers that serialize exceptions directly -- json.dumps({"error":
str(exc), ...}) -- bypass that helper, so _normalize_handler_result
also runs every string result through _bound_json_error_result: if it
parses as a JSON object with an oversized string error field, only
that field is trimmed and the payload re-serialized. Non-error
results, non-JSON strings, and multimodal envelopes pass untouched.
- Add contributors/emails/Axmr1@users.noreply.github.com for CI
attribution check (bare noreply format needs explicit mapping)
- Update opencode-zen plugin docstring: Go routing now includes
GPT → codex_responses and Qwen → anthropic_messages (was stale,
only listed MiniMax and GLM/Kimi)
OpenCode Go serves GPT 5.6 Luna only via the Responses API per its
published endpoint table (https://opencode.ai/docs/go/#endpoints), but
opencode_model_api_mode() had no gpt- case in the Go branch, sending
Luna to /v1/chat/completions. The relay's shim streams full text but
never emits a finish_reason chunk, so every complete answer is
classified as a mid-stream drop and each turn fails with 'Response
remained truncated after 4 continuation attempts'.
Mirror the Zen branch: gpt- on Go -> codex_responses. Base URL needs
no change (normalize_opencode_base_url already keeps /v1 for
codex_responses). Extend test_opencode_go_api_modes_match_docs with
the Luna assertions.
Gap-fill from the follow-up commit's own review:
- __init__.py: restore getattr tolerance in _pop_auth_notice — test
fixtures outside tests/honcho_plugin/ install minimal fake managers
without pop_auth_notice (tests/test_honcho_startup_fail_open.py's
SlowManager failed with AttributeError). Exceptions still propagate;
only the blanket except was dropped.
- test_auth_recovery.py: the fast-path test used a raising stub, but
_reauth_required swallows all exceptions — the test passed even with
the fast path removed. Rewritten as a recording spy with a call-count
assertion; mutation-verified (removing the fast path now fails it).
- test_auth_recovery.py: autouse fixture resetting oauth module dicts
(_dead_grants, _refresh_failure_at, _reauth_check_cache,
_expiry_cache) so state can't leak between tests.
honcho_plugin 293 + test_honcho_startup_fail_open 7 + plugins/memory
285 = 585 passed.
Follow-ups from review of #80590:
- oauth.py: extract _rotate_and_persist() — the twin ~18-line
OAuthRefreshError permanent/transient handling blocks in
ensure_fresh_token and force_refresh_token were byte-identical
except the log verb.
- oauth.py: cap the exchange cycle at _REFRESH_TOTAL_BUDGET_SECONDS
(20s). The retry runs while holding the global refresh locks on the
path to a memory call; a timed-out first attempt no longer earns a
second full 15s exchange (~32s lock hold -> <=20s).
- oauth.py: transient-failure cooldown (_refresh_failure_at, 30s).
Waiting threads and later turns fail open to the stale token instead
of serializing their own full exchange cycles against an endpoint
that just failed. Cleared on successful rotation and re-login.
- oauth.py: mtime-gate reauth_required()'s config read — the dead-grant
state persists until re-login, and the verdict can only change when
the config file is rewritten; drop the per-call read+parse.
- oauth.py: derive _TOKEN_VALUE_RE from ACCESS_TOKEN_PREFIX /
REFRESH_TOKEN_PREFIX so a prefix change can't silently break
redaction; promote redact_tokens to public (session.py imported the
private name).
- session.py: fast path in _reauth_required — skip config-path
resolution entirely while no grant is dead (runs before every SDK
call).
- session.py: client-generation counter closes the fetch/store race in
_sdk_session/_get_or_create_peer — an object resolved from the old
client mid-rebuild is no longer cached (it would 401 forever and burn
a token rotation per retry).
- __init__.py: drop the getattr/callable/except triple-guard in
_pop_auth_notice; the manager is always None or HonchoSessionManager.
7 new tests (budget, cooldown x3, generation guard, fast path); all
mutation-checked (disabling each guard fails its test). honcho_plugin
293 passed; plugins/memory 285 passed; live E2E against a real HTTP
token endpoint re-verified.
An init-time HonchoAuthError discarded the manager that recorded it, so
context/hybrid prefetch returned nothing and tools mode returned the
generic init error. The provider now keeps the failure detail across the
manager discard, prefetch emits the one-time notice at the readiness
guard, tools mode returns an explicit authentication error, and a
successful re-login retry clears the stored failure. Non-auth init
failures keep failing open with no notice.
_authed_call checks the dead-grant marker before calling, retries a
confirmed auth failure once after a forced refresh, and records the
failure for the one-time notice. Operations re-resolve their peer and
session objects inside the call, so a retry after a client rebuild no
longer reuses objects bound to the old transport. Tool handlers now
return an explicit auth error instead of an empty result, and non-auth
failures keep their fail-open behavior.
_is_auth_error matched the substring '401' anywhere in an error string,
so a latency figure ('retry after 4010 ms'), a request id, or a
workspace name containing those digits classified as an auth failure.
A false positive calls _force_reauth, which runs a real token exchange;
the server rotates the refresh token on every exchange, and a lost
rotation response leaves Hermes holding a superseded token whose later
replay revokes the whole grant — the exact wedge this branch fixes.
The status attribute check (SDK AuthenticationError carries status=401)
does the real work and stays first. A concrete non-401 status now wins
over ambiguous text. The text fallback keeps only specific markers:
'invalid or expired access token', 'authentication failed' (not bare
'authentication', which also matches auth-infrastructure outage
messages), 'unauthorized', and '401' only with HTTP context ('HTTP
401', 'status 401'), never as a bare number. The classifier is biased
toward false negatives: a missed auth error costs one un-recovered
call, a false positive spends a rotation.
Also redacts token values in _record_auth_failure, _auth_error_message,
and the two retry warnings, matching oauth.py. The SDK's auth errors
carry no token values today, but this is the one credential path where
an upstream regression would leak silently.
Tests: the four false-positive strings stay non-auth, HTTP-context 401s
still match, a concrete 429 status beats 'authentication failed' text,
and the recorded failure plus notice redact token values.
reauth_required() existed but nothing called it, so after a grant died
every dialectic fire and sync flush still sent a Honcho API call that
401ed. dialectic_query and _flush_session now check the dead-grant flag
first and skip the call: dialectic raises HonchoAuthError (exempt from
cadence backoff), sync returns False with the failure recorded so the
one-time notice still fires.
The check compares the on-disk refresh-token digest, so a re-login flips
it back with no network call and the next cadence resumes immediately.
Transient auth errors keep the existing force-refresh-and-retry path.
Four new tests: a dead grant issues no dialectic or sync call, and a
re-login resumes both without waiting.
An expired access token could pause Honcho memory for hours with no
user-facing signal: ensure_fresh_token swallowed every exchange failure
and returned the stale token, no code handled a 401 from the Honcho API,
and each failed dialectic cycle widened the cadence backoff. Hypothesis
for the trigger (not confirmed): the refresh POST times out after the
server already rotated the token pair, Hermes keeps the old refresh
token, and the eventual replay lands outside the server's 60-second
rotation grace window, which revokes the whole grant.
- oauth: the exchange reads the token endpoint's error body instead of
discarding it. invalid_grant and other permanent OAuth errors mark the
grant dead so no code retries a revoked grant; transient failures retry
once immediately, which keeps a replayed refresh token inside the grace
window. Log lines redact token values.
- oauth: force_refresh_token() rotates the token now, ignoring local
expiry, to recover from a server-side 401.
- session: dialectic_query and _flush_session treat a 401 as a trigger to
force one token rotation and retry the call exactly once. A persistent
auth failure raises HonchoAuthError (dialectic) or records the failure
(sync) instead of being returned as an empty result.
- provider: injects a one-time notice into the memory context so the
model tells the user memory is paused and 'hermes honcho setup'
restores it. Auth failures no longer widen the dialectic cadence
backoff.
New tests cover the exchange retry, invalid_grant terminality plus
re-login recovery, forced refresh, 401 retry on both the sync and
dialectic paths, the one-time notice, and the backoff exemption.
- Wire the Pass-1 dedup floor (len < 200) to the shared _PRUNE_MIN_CHARS
constant it was already documented as matching, and use the constant in
the remaining test literal.
- Restructure the clarify 'resolved' computation (is_answer_shaped +
sentinel check) instead of compute-then-flip.
- Add a live producer->recognizer drift guard: the REAL oneshot no-user
callback's output must be recognized as a sentinel, so producer wording
drift fails a test instead of silently reintroducing false attribution.
- Document the any()-poisoning semantic for multi-select sentinel lists.
Follow-up to the salvaged #81244 commits:
- Timeout/no-user clarify callbacks (CLI timeout, gateway timeout and
delivery failure, oneshot no-user) embed sentinel prose as
user_response; quoting those as '[clarify] user responded: ...' would
be false attribution. Route them to the generic summary path.
- Extract the shared _PRUNE_MIN_CHARS = 200 floor (prune default +
proactive clamp) and cap the clarify summary at _PRUNE_MIN_CHARS - 1,
removing the knife-edge equality the summary's survival depended on
and keeping it out of the >=200-char dedup pass.
- Tests: 4 sentinel shapes + multi-select sentinel; mutation-checked.