Commit Graph

3173 Commits

Author SHA1 Message Date
teknium1 0ea0c53e89 fix: record a known exit status when the reader's wait() raises
The early-EOF reaper made _finish_reader return without publishing when
wait() raises, so the session stays tracked for later reconciliation.
That is right for the pipe path (_reconcile_local_exit can still reap via
session.process), but PTY sessions have no session.process: when
ptyprocess.wait raises (waitpid ECHILD after isalive() already reaped the
child) the exitstatus is known, yet poll() reported "running" forever.

Only leave the session tracked when exit_code() is still None; otherwise
record the known status and finish as before.

Review finding: PTY session whose pty.wait raises stays in _running forever (fail-open regression vs main)
2026-09-15 04:23:32 -07:00
teknium1 dc4a2aeb10 test(process): trim early-EOF reaper test to its two invariants
Drop the wait-timeout call-shape assertion; the invariants are that the
session records the real exit code and that a failed reap does not publish a
false completion.
2026-09-15 04:23:32 -07:00
fangliquan 1bfbeff4e5 fix(process): reap children after early stdout EOF 2026-09-15 04:23:32 -07:00
teknium1 9efa50071a fix: match find/read-tool dynamic words only as unquoted command arguments
The dynamic-shell-word rules fired on any `-del*`/`-exec*` substring after a
`find` anywhere in the segment, so quoted predicate arguments the shell never
expands were flagged: `find . -name 'log-del*'`, `find . -name 'pre-exec*.sh'`,
`find src -path '*-exec[0-9]*'`, and `echo find . -{delete,print}`.

Three changes close that:
- `find` must be the command word (_CMDPOS anchored, same as mkfs/rm/dd) and
  the dynamic word must start a whitespace-delimited token (`(?<!\S)`), for
  both the find rule and the rg/sort/ag/man program-option rule.
- Both rules now scan the quote-masked variant (_QUOTE_MASKED_DANGEROUS_
  DESCRIPTIONS, same _mask_quoted_prose used by the positionless hardline
  rules) so glob characters inside quotes are data, not expansion.
- _iter_shell_command_starts no longer treats the `{` inside a brace-expansion
  word (`-{delete,print}`) as a brace-group opener; it split the word across a
  marked start so `echo x; find . -{delete,print}` matched nothing. A brace
  group opener is `{` as its own word (after whitespace or a separator).

The quoted-name cases join the inert parametrized test; the separator case
joins the dangerous one. Still two parametrized functions.
2026-09-15 04:17:42 -07:00
Teknium 06a3a98751 fix: gate dynamic shell words in approval checks
Port from openai/codex#39159: require approval when shell expansion could synthesize destructive find flags or program-executing read-tool options.
2026-09-15 04:17:42 -07:00
teknium1 ef44c1b73f fix: restore TestBridgeDispatch, trim #39797 tests, drop dead execution_guidance_text param
Why: the rebase conflict resolution in tests/tools/test_model_tools.py
deleted the unrelated TestBridgeDispatch class (3 tests from 73163e3);
it is restored verbatim from main with TestBrowserRetrievalHints after it.

The fix had five tests for one invariant: the OPENAI_MODEL_EXECUTION_GUIDANCE
check duplicated test_phantom_tool_references, and the two static-schema
"toolset-neutral" checks are now folded into test_silent_without_web_tools,
which runs _apply_dynamic_schemas over the real browser_navigate/browser_cdp
schemas so the rendered descriptions are what is asserted.

execution_guidance_text() no longer takes valid_tool_names: the guidance is
toolset-neutral, so the parameter was ignored; the single caller in
agent/system_prompt.py and its test are updated.
2026-09-15 04:13:13 -07:00
teknium1 ac63d0eea5 fix(agent): execution guidance and browser hints drop the web_search stripper; tests assert the invariant
The rebased guidance text no longer names web_search anywhere, so
execution_guidance_text()'s replace() calls (3733e4aff5) matched
nothing and were dead; the function now returns the neutral text for
every toolset and its phantom-tool test asserts "no web tool named"
instead of the removed sentence. model_tools ports the PR's hint layer
into main's _DYNAMIC_SCHEMA_REWRITERS table (browser_navigate +
browser_cdp) rather than a second pass after it.

Tests: the two browser_cdp registry tests were re-added by the PR but
main pruned them in 39975613b13b4; replaced with one schema-neutrality
invariant. Exact-wording assertions ("lightweight retrieval tool",
"appropriate permitted retrieval/search tool") were change detectors and
are dropped. tools-reference.md row updated to the new schema text.
2026-09-15 04:13:13 -07:00
Kevin Yin ccf380f634 fix(agent): respect permitted web retrieval guidance 2026-09-15 04:13:13 -07:00
teknium1 6dc6c92ea2 fix: release the profile MCP stderr handle before rename too
rename_profile moves the profile directory while this process may still
hold the cached per-profile mcp-stderr.log handle (left behind by a
completed probe or a running server). On Windows a directory containing
an open file cannot be renamed, the same WinError class delete_profile
now avoids. On other platforms the stale handle stayed cached under the
old home key, so a later probe on the renamed profile opened a second
handle and a new profile re-created under the old name wrote its MCP
stderr into the renamed profile's log. Release the scoped handle next to
the multiplexer unroute, mirroring delete_profile.

Review finding: rename_profile missed the sibling surface of the
delete_profile handle release.
2026-09-15 04:12:41 -07:00
tarkilhk 68dea35e0e fix(mcp): release profile stderr handles before deletion 2026-09-15 04:12:41 -07:00
teknium1 23036e20a6 fix(ux): plain-language, actionable user-facing messages (core)
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
2026-09-15 04:12:13 -07:00
teknium1 d0bdf39dad test: rewind-exclusion assertion inspects the real results key
The discovery payload exposes hits under `results`, not `matches`, so the
loop over `result_rewind.get("matches", [])` never iterated and the
invariant (the rewound row never surfaces, even via the OR-relaxed retry)
was not being checked. Assert on the anchor message id and snippet of each
returned result instead.
2026-09-15 04:01:17 -07:00
Teknium 2a2c3aa588 feat(session-search): OR-relaxed retry recovers paraphrased recall
Port from nearai/ironclaw#7553 (Filter::FtsRanked): FTS5's implicit AND
between terms means a paraphrased multi-word query misses a stored
sentence that lacks even one of the words. When the exact-match search
and the substring fallbacks all return zero rows, retry the same
unicode61 FTS index with the terms OR-joined, ranked by bm25 so rows
covering more terms surface first.

Strictly additive: gated on a zero-result miss, so successful searches
keep exact-match semantics and ordering. Queries with explicit OR/NOT,
single-term queries, and CJK-routed queries are left untouched. Quoted
phrases relax as whole units.

Adapted for hermes-agent: implemented inside SessionSearchMixin's
zero-result fallback chain (after the CJK-bigram/trigram substring
retries) rather than as a separate filter variant, reusing the already-
built SQL/params so all source/role/sort filters apply to the retry.
2026-09-15 04:01:17 -07:00
teknium1 681a28b06a test(session_search): fold the temporal-narrowing tests to four invariants
Parse-unit cases (relative units, ISO, empty, invalid) become one parametrized test; one
end-to-end per parameter: ISO window enforced in SQL (survives a truncated FTS scan),
relative after/before + exclude_session_ids driven through INLINE_TOOL_EXECUTORS so the
production dispatch is what is tested (red on the executor before this fix), and lineage
exclusion. Drops the schema-membership and unbounded-equals-None change-detectors.
2026-09-15 03:58:05 -07:00
Teknium e819846b10 Inspired by Amp: relative time bounds (7d/24h/2w) + wrapper forwarding for session_search after/before
Amp's thread feed supports relative time filters (`after:7d`,
`updated_before:7d`) alongside ISO dates. Extend the salvaged
after/before bounds (PR #86067 by @Moodtuner997) the same way:

- `_parse_iso_bound()` now accepts relative durations `Nh`/`Nd`/`Nw`
  (case-insensitive) meaning "now minus N", alongside ISO
  dates/datetimes. Clearer error message names both accepted forms.
- Forward after/before/exclude_session_ids through the public
  `session_search()` wrapper (the PR predates the wrapper/impl split;
  without this the SQL bounds were unreachable from the registry
  handler — same class as the earlier `detail` forwarding fix).
  Appended after `detail` to preserve positional compatibility.
- Tool schema descriptions teach both forms.
- Tests: relative after/before against the discovery shape, unit
  checks for h/d/w math, case-insensitivity, and bad-unit rejection.
- Docs: tools-reference row mentions time bounds + exclude_session_ids.
2026-09-15 03:58:05 -07:00
Théo 6f793ddbdc fix(session_search): apply after/before in SQL WHERE
Push the session-start bounds into search_messages so FTS LIMIT
cannot be filled by out-of-range hits. Covers FTS5, CJK, trigram,
LIKE fallback, and the unindexed-gap supplement.

Refs #86021.
2026-09-15 03:58:05 -07:00
Théo 5655920f9a feat(session_search): add after/before bounds and exclude_session_ids
Discovery-only filters for issue #86021. sort remains a ranking
bias. Date-only before is an exclusive midnight UTC bound.
exclude_session_ids drops the named session and its lineage (cap 20).
2026-09-15 03:58:05 -07:00
teknium1 dddefaefae fix: paged, extracted and post-compaction reads count as a write_file baseline
The stale-overwrite refusal made write_file permanently unusable for any
existing file it could not show in one read_file page: every >2000-line
(or >100K-char) page was recorded as partial, no full baseline ever
existed, and the refusal told the model to "re-read the whole file", which
the tool cannot do. Track the line ranges each task pages through per path
at one mtime; contiguous pages from line 1 to total_lines are a full read
(a new mtime between pages restarts the coverage). The same gap hit two
siblings: the extracted-document branch (.ipynb, text-authorable) returned
before any read bookkeeping, so an existing notebook could never be
overwritten; and reset_file_dedup dropped every baseline on compaction
while keeping read_timestamps, so every write after compaction was refused
even for files unchanged on disk. Baselines now survive compaction exactly
like the dedup mtime map does — only while the recorded mtime still matches.

Refusal texts no longer embed the pre-PR "Warning: … Consider re-reading"
copy inside "Refusing to overwrite", and every refusal names a recovery the
model can perform: read the remaining pages, or use patch.
2026-09-15 03:57:24 -07:00
teknium1 4613f895ab test: give the rewrite-hint fixtures a read baseline; align docs row with schema
The stale-write guard now refuses write_file on an existing file the task
never read in full, so test_write_file_rewrite_hint's overwrite-without-read
fixtures were refused before the hint could be computed. Reading first is
the exact read->whole-file-rewrite pattern the hint exists for.

tools-reference.md's write_file row now mirrors the WRITE_FILE_SCHEMA
description (one-sentence contract + the recovery step) instead of a
longer paraphrase.
2026-09-15 03:57:24 -07:00
Teknium 6569651b87 fix: harden salvage of #65605 — redaction-gated test, sibling test baseline, docs
- test_file_staleness redacted-read case now force-enables redaction
  (matches tests/agent/test_redact.py convention) so it exercises the
  sentinel path in hermetic CI where security.redact_secrets is unset.
- test_write_verification CRLF case establishes a read baseline first
  (the new guard refuses unread existing-file overwrites by design).
- tools-reference.md documents the read-before-overwrite contract.
- contributors/emails mapping for DanSpicyTaco.
2026-09-15 03:57:24 -07:00
DanSpicyTaco a8af57fcd2 fix: block stale write_file overwrites
Require an explicit full-file baseline before replacing existing host-visible files with write_file, and fail closed when that baseline is stale. This prevents stale conversation context from clobbering manual or external edits.\n\nRefs #65604
2026-09-15 03:57:24 -07:00
teknium1 04fcf9159c fix: keep api_server approval bridge and cron self-scheduling after presence strip
Widening the _presence() clearing from single-query to every unattended
context also cleared is_ask for platform=api_server. That surface answers
approvals through the /v1/runs bridge (approval.request ->
POST /v1/runs/{id}/approval), so a dangerous command that used to park in
waiting_for_approval became an instant BLOCK with no approval.request.
Restrict the clearing to single-query + cron, where nobody can answer.

Stripping HERMES_INTERACTIVE/HERMES_GATEWAY_SESSION/HERMES_EXEC_ASK from
the external worker env also made check_cronjob_requirements() False, so
the cronjob toolset vanished for every job on a managed-systemd gateway
even with cron.allow_agent_scheduling: true. Accept the existing
HERMES_CRON_SESSION marker (set by run_one_job's context) as well.

Review finding: _presence() over-widening broke the /v1/runs approval bridge; env strip hid the cronjob toolset in external workers.
2026-09-15 03:56:17 -07:00
teknium1 b6b7802447 fix(approval): every unattended context clears leaked presence vars
Widen the cron-only clearing to `_unattended_contexts()`: a webhook /
api_server session running inside a gateway inherits HERMES_EXEC_ASK=1
exactly like an external cron worker does, and `_presence()` returning
is_ask=True sent it to the gateway-decision branch with no notifier — a
pending card nobody can answer — instead of `approvals.unattended_mode`.
Same class as #110932, one predicate.

Test trimmed to two invariants (cron / webhook leak → cleared; interactive
keeps presence); the launch-path comment in cron/scheduler.py names the
env-fallback consumers instead of an internal incident log.
2026-09-15 03:56:17 -07:00
fabiantax 2a630671d7 fix(approval): cron context is never interactive, even with leaked presence env
_presence() cleared is_cli/is_gateway/is_ask for single-query sessions but
not for cron, so a cron worker that inherited HERMES_INTERACTIVE /
HERMES_EXEC_ASK from its launching gateway resolved as an interactive CLI
and blocked on an approval card nobody could answer (measured: 31
pending_approval hangs/hour, 6 stranded claims — #110932).

Mirror the single-query clearing for _is_cron_approval_context(), matching
the cron exclusion already inside _is_gateway_approval_context(). Layer 1
(#110942) strips the vars at the launch path; this makes the gate robust
to any other leak route.
2026-09-15 03:56:17 -07:00
teknium1 39fbfbe4e6 test: drop prose change-detector for terminal description
The test asserted three verbatim substrings of TERMINAL_TOOL_DESCRIPTION,
so any future rewording of the guidance would fail it without a behavior
change. The PR's change is prose-only guidance; the description text is
not a stable interface worth pinning.
2026-09-15 03:52:24 -07:00
Teknium 325d73ce07 docs(tools): clarify terminal background waits
Port from Kilo-Org/kilocode#13224: fixed waits belong in foreground terminal calls, while background mode is reserved for independently running processes.
2026-09-15 03:52:24 -07:00
teknium1 45ab3ad57f fix(delegate_task): return a schema-invalid child's raw text instead of failing the task
When a child's final answer still missed its output_schema after the one
bounded retry, the result entry flipped to status=failed with the error
"Final answer does not satisfy the declared output_schema" — the completion
line printed ✗ and orchestrators read a finished audit as a failure. Five
audits of 413-4103 s were lost this way in the Sep 10-14 retrospective and
the parent had to mine the live transcripts; in four of them the "violation"
was a ```json fence around a valid array, which the candidate extractor
sliced to its first..last object.

Now: status stays completed, `summary` is the child's raw final text,
`schema_valid: false` + `schema_errors` carry the verdict and a `schema_note`
says the text is unvalidated; the sync completion line shows ⚠ with the
reason. The extractor tries the earliest-opening bracket span and keeps the
first that parses (fenced arrays validate). The OUTPUT CONTRACT the child
sees now says "ONLY the JSON value — no prose, no code fence" and what a miss
costs. One bounded retry is unchanged.
2026-09-15 03:45:41 -07:00
teknium1 8a8c3634e8 fix(kanban): scope the delegated-child write fence to the lineage's board root
HERMES_DELEGATED_CHILD_CONTEXT=1 is deliberately carried into every shell/
execute_code subprocess a delegate_task child spawns (the fence must survive
exec so a grandchild `hermes kanban complete` cannot promote itself). But the
readers treated the bare flag as "fence every Kanban DB": kanban_db_connect
opened ANY board ?mode=ro and write_txn refused ANY mutation. A subagent
running a Kanban reproduction against a scratch HERMES_HOME therefore got a
silently read-only board with a misleading "descendants require an
initialized board" error; only one lane in the retrospective ever discovered
why (deleg_15dac332), every earlier kanban repro ran degraded.

The marker's value is now the fenced board ROOT (kanban_home() at spawn) and
readers deny only paths under that root or the dispatcher-pinned
HERMES_KANBAN_DB (kanban_path_is_fenced). In-process children and a legacy
"1" marker still fence everything; an inherited path marker is never
re-derived, so a grandchild that moved HERMES_HOME cannot unfence the real
board. Owner-gate tests (test_kanban_descendant_scope, cron env isolation,
kanban CLI exit status) are unchanged and green.
2026-09-15 03:45:41 -07:00
teknium1 9a49b3c984 fix(execute_code): subagent kernels survive the LRU cap for the child's lifetime
A delegated child's execute_code kernel was keyed correctly
(<owner>::child::<session>) but counted against the process-wide
max_session_kernels LRU cap (default 4) like any other kernel. In a fan-out
wider than the cap every child's first cell spawned a kernel and evicted the
oldest sibling's, so the sibling's next cell started a fresh interpreter and
NameError'd on state its own previous cell had set — while the tool schema
promised "variables, imports, and loaded data survive across execute_code
calls". Finished children's kernels also squatted the cap for
kernel_idle_timeout (1800 s) after the child was gone. 48 NameErrors across 28
subagent lanes in the Sep 10-14 retrospective.

A live child's kernel (local and remote) is now pinned: exempt from LRU
eviction while the child runs, disposed by the delegation cleanup path
(shutdown_kernels_for_delegated_child) as soon as the child finishes. Top-level
sessions keep the existing cap and idle reaping unchanged.
2026-09-15 03:45:41 -07:00
teknium1 081421d838 fix: keep the write-side outcome-uncertain verdict when no server can reconnect
_handle_session_expired_and_retry only reached the at-most-once guard when a
reconnectable server record existed; without one (server torn down, MCP loop
not running) a write-capable call fell through to the generic "MCP call
failed" error, which invites the model to replay a write that may already
have landed. The session-expired classification now runs first and a
write-capable call always gets the outcome_uncertain error; the reconnect is
attempted only when a server can be signalled.

_track_inflight_rpc's teardown RuntimeError said "retry the request on the
rebuilt session" for every op; for a write-capable tools/call it now says the
request may already have been dispatched and must be verified first, so the
wording matches the at-most-once contract the recoverer enforces.

Docs: the readOnlyHint row explains that the same hint gates auto-retry after
a mid-call session expiry, and that unannotated tools on an idle-TTL
Streamable-HTTP server return outcome_uncertain on the first call after idle
instead of being transparently replayed.
2026-09-15 03:45:29 -07:00
Teknium 26f5643dc9 Port from cloudflare/cloudflare-os#168: never auto-retry write-capable MCP tools after mid-flight session expiry
A 'session expired' / transport-closed failure can arrive AFTER the server
already accepted and executed the request (proxy-synthesized 404s, pod
rotation, ClosedResourceError firing mid-response). Auto-retrying a
write-capable tool in that window risks a duplicate side effect that MCP
offers no way to undo.

The session-expired recovery path now consults the discovery-time
readOnlyHint capture (same data the trust gate uses): only tools whose
annotation is exactly True keep the reconnect+retry-once behavior. Write-
capable calls still get the transport healed (reconnect, breaker reset on
success) but return a structured outcome_unknown error telling the model
to verify with a read before re-invoking.

The OAuth 401 path keeps its retry for all tools: a 401 means the server
demanded authorization before dispatch, so the call never executed —
matching the upstream classifier's McpAuthRequiredError => safe rule.

Fails safe: missing/unknown annotations classify as write-capable.
2026-09-15 03:45:29 -07:00
teknium1 5ba3a698d9 test: fold host spillover verification into the size-probe invariant
The PR added four test functions for one fix. The host-side short-write and
multibyte cases are the same invariant as the sandbox size probe (an archive
that is not byte-exact is never referenced to the model), so they become two
parametrized rows of test_size_probe_decides_lossless, which now also uses
multibyte content so every row pins the byte-vs-char comparison. Net new
tests for the fix: 2 functions.

Also names in _write_to_sandbox why the +1 tolerance is keyed on heredoc
mode only: the payload backend delivers stdin verbatim and is expected to be
byte-exact. The three bare write_text() calls the footgun scanner flags in
this file gain encoding= while it is being touched.
2026-09-15 03:41:43 -07:00
teknium1 fb2e623008 test: collapse sandbox size-probe cases into one parametrized invariant
Five near-identical MagicMock scripts pinned the same contract (byte-exact
or discard, heredoc gets exactly one extra byte); one parametrized test
plus the unprobeable-backend case cover it. Drop the host-side happy-path
test that duplicated test_multibyte_content_verified_by_byte_count.
Docstring now names the real heredoc wrapper
(BaseEnvironment._embed_stdin_heredoc) and notes the extra exec RTT per
oversized result (review point).
2026-09-15 03:41:43 -07:00
Teknium fc93a1b55a Port from lobehub/lobehub#18258: verify persisted tool-result archives before referencing them
Oversized tool results are archived to disk and replaced in-context with a
'Full output saved to: <path>' reference. Until now the write was trusted
blind: a partially-flushed host file (ENOSPC/quota races) or a lossy sandbox
write (API-body truncation on payload backends) still produced the archive
reference, so the model was told the full result was recoverable when bytes
had silently vanished.

Both persistence paths now round-trip-verify size before building the
reference and fail closed to the bounded inline truncation otherwise:

- _write_to_spillover: byte-count check via os.stat after write; mismatched
  archives are deleted and the caller falls through to inline truncation.
- _write_to_sandbox: wc -c probe after the cat; heredoc-mode backends get a
  +1 byte tolerance (wrap_modal_stdin_heredoc appends one newline by
  construction), unprobeable backends stay best-effort success.

Regression tests fail without the fix (verified by stashing the source
change: 4 failed). E2E-verified against a temp HERMES_HOME with real file
I/O including multibyte content and a simulated short write.
2026-09-15 03:41:43 -07:00
teknium1 d8053f4806 fix(video_gen): cap LTX 2.5 at 10s for 1440p/2160p; omit unset enum durations
fal's LTX 2.5 fast endpoints accept 6-20s only up to 1080p — "At 1440p and
2160p, all frame rates support up to 10 seconds" — so a 4K request with the
family's 20s ceiling was rejected by the vendor. Families can now declare
`duration_cap_by_resolution`, applied after the enum snap / range clamp on the
resolved resolution enum.

An unset duration on a duration_enum family also snapped to enum[0] (6s),
silently overriding the endpoint's own "auto" default; None now omits the key
for enum families exactly as it already did for range families.

test_managed_media_gateways asserts the alibaba/happy-horse/ namespace by
prefix rather than the exact v1.1 literal so the next version bump doesn't
flip an unrelated gateway test.
2026-09-15 03:37:11 -07:00
teknium1 1c1980dc1f fix(video_gen): make the duration enum explicit instead of sniffing tuple shape
`durations` carried two meanings told apart only by len==2 and gap>1: a
(min, max) range to clamp, or an enum to snap. A family with exactly two
legal values would have been misread as a range (review finding on #91311).
`durations` is now always the (min, max) window (what capabilities()/
list_models() read) and families with discrete values add `duration_enum`;
_clamp_duration takes the family and branches on the key, not the shape.

Also: restore the exact v1.1 endpoint assertion in the gateway namespace
test (a startswith/endswith check would not catch a silent version drift),
add the ltx-2.5 i2v snap case, and keep happy-horse on audio_native (the
schema test forbids audio+audio_native together, and v1.1 audio is always on).
2026-09-15 03:37:11 -07:00
Teknium b30df1303c test: pin happy-horse namespace invariant, not exact 1.0 endpoint strings
The managed-gateway test asserted the literal v1.0 endpoint ids; its
stated purpose is verifying the alibaba/ (not fal-ai/) namespace. Assert
prefix+modality-suffix instead so version bumps don't break it.
2026-09-15 03:37:11 -07:00
teknium1 754a9ef4e7 test(stt): give the silent-stall test the same idle budget
test_silent_stall_still_times_out kept the 0.1s idle window and flaked
once locally under heavy load (load avg ~250): the child was killed
before its single stderr line was read, so the pre-stall-output
assertion saw an empty stderr. Same class as the progress test this PR
de-flakes; give it the same 0.25s budget. The 30s sleep still trips the
window, so the must-time-out direction is unchanged.
2026-09-15 03:36:11 -07:00
teknium1 7ee9fa52c0 test(stt): correct the de-flake provenance in the idle-timeout comment
andrexibiza's review is right: the base test already printed tick 0
before its first sleep, so this change never removed a
sleep-before-first-tick race. The actual de-flake is the larger idle
budget (0.1s -> 0.25s) plus a longer heartbeat sequence whose ~400ms
runtime still exceeds the idle window. Say exactly that so the causal
record is accurate.
2026-09-15 03:36:11 -07:00
andrexibiza a70ea873cf test(stt): stabilize idle-timeout progress test against spawn latency
The stderr-progress idle-timeout test used a 0.1s idle window with 0.04s
ticks — shorter than Windows process spawn, so the first chunk could never
arrive in time (deterministic failure on Windows, flake under Linux CI
load). Verified failing identically on pristine main before the change.

Fix: emit the first tick immediately, tick every 50ms for ~400ms total,
250ms idle window (5x tick period). The pass still depends on the progress
extension while tolerating real spawn/scheduling latency.

Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
2026-09-15 03:36:11 -07:00
OOOOOAO 1a6503a520 fix(mcp): thread RFC 9207 iss through every OAuth callback relay
mcp 2.x rejects an authorization response that omits the RFC 9207 `iss`
parameter when the authorization server advertised
`authorization_response_iss_parameter_supported`. Cloudflare advertises it
AND sends it; the CLI loopback handler has always forwarded it, but every
other callback producer parsed only code/state/error, so the SDK raised:

    OAuthFlowError: Authorization response missing iss parameter
    advertised by the authorization server

and the server parked. Same machine, same config, `hermes mcp login <name>`
from a terminal succeeded — the failure is specific to the non-CLI relays.

Forward `iss` on every producer, matching `_make_callback_handler()`:

- tools/mcp_dashboard_oauth.py: `deliver_callback()` accepts `iss`;
  `wait_for_callback()` returns `(code, state, iss)`. The bridge in
  tools/mcp_oauth.py already splats that tuple into
  `_authorization_code_result(code, state, iss)`, so it needs no change.
- tui_gateway/mcp_oauth_sessions.py: the gateway-hosted loopback listener
  parses `iss`, and `deliver_callback_flow()` forwards it.
- tui_gateway/methods_tools.py: the `oauth.callback` RPC passes `iss`.
- hermes_cli/web_routers/mcp.py: the dashboard callback route accepts it.
- apps/desktop/electron/mcp-oauth-callback-ipc.ts: the one-shot listener
  reads `iss` off the redirect (the renderer already spreads the whole
  callback object into the RPC, so it flows through unchanged).

Providers that omit `iss` round-trip as `None`/`null` rather than being
dropped, so servers that do not advertise RFC 9207 keep working.

Verified live on Windows against mcp.cloudflare.com, whose metadata sets
`authorization_response_iss_parameter_supported: true`: the server that
previously parked on the missing-iss error now reports
`Authenticated — 3452 tool(s) available` and `hermes mcp test cloudflare`
connects. State-mismatch and replay rejection are unchanged.

Tests (each fails on base, passes with the fix):
- test_dashboard_flow_preserves_rfc9207_iss
- test_deliver_callback_forwards_iss (client-redirect relay)
- test_loopback_listener_forwards_iss (real HTTP redirect)
- two vitest cases on the Electron listener, incl. the iss-absent case

Refs #92758, #99984. PR #92765 fixes the dashboard route and the loopback
listener but not the client-redirect relay
(`deliver_callback_flow` / `oauth.callback` / the Electron listener), which
is the path Desktop drives against a remote backend.
2026-09-15 13:00:12 +05:30
brooklyn! b79107c565 fix(gateway): include command context in sudo password requests 2026-09-15 02:22:22 -05:00
kshitijk4poor 8ad7ae06e7 test(mcp-oauth): bind the 400-recovery path to issuer binding, guard live-TTL on an empty context
The 400-recovery reload installs a disk pair and only then re-runs issuer
binding on it. When that pair was minted by a different issuer the enforcer
strips its refresh token, and the reload must report "no recovery" so the
session is cleared like any other dead grant. No test pinned that verdict:
a reload that ignored the install result would keep a stripped pair in the
context and return True. The new invariant drives a real 400 through
_handle_refresh_response against a foreign-bound disk pair and asserts the
result is False, the context is cleared, and the foreign refresh token does
not survive on disk.

_hermes_live_ttl read expires_in off current_tokens without checking for
None; getattr(None, ...) happened to yield the default and report "live".
Both current callers install a pair first, but the helper is now explicit
that an empty context is never live, so a future call site cannot adopt
nothing.
2026-09-15 10:50:13 +05:30
kshitijk4poor f4bf786c57 fix(mcp-oauth): return a verdict from disk-pair install, count a missing expiry as live
Two defects in the peer-adoption path of the refresh fence:

1. `_hermes_install_disk_pair` raised `_RefreshCompletedByPeer` when issuer
   binding stripped the candidate's refresh token. That is the right outcome
   for `_refresh_token` (restart the flow so the SDK lands in 401 -> full
   auth), but `_hermes_reload_tokens_after_refresh_failure` shares the helper
   and must instead treat the candidate as rejected: restore the previous
   pair and return False so the caller clears state and prompts. The helper
   now returns whether a refresh token survived binding and each caller
   decides; the one-shot adopt wrapper is inlined into `_refresh_token`.

2. `_hermes_live_ttl` treated `expires_in is None` as expired. RFC 6749 makes
   `expires_in` optional, the SDK's `is_token_valid()` is True with no expiry,
   and `_rebase_expires_in` preserves None on read, so a peer's rotated pair
   without an expiry was never adopted and we POSTed its refresh token
   anyway, burning a generation on single-use providers. None now counts as
   live; the try/except around a pydantic `int | None` field is dropped.

One new test drives the real auth flow against a peer pair with no
`expires_in` and asserts the pair is adopted with zero POSTs.
2026-09-15 10:50:13 +05:30
kshitijk4poor 60262f71bd refactor(mcp-oauth): split the refresh fence into acquire/release functions, drop the lock sidecar on remove
The SDK drives a refresh as a generator (request yielded from
_refresh_token, response consumed in _handle_refresh_response), so the
fence was a hand-driven @asynccontextmanager: __aenter__ in one method,
__aexit__ in another, generator object stashed on the provider. A plain
`acquire_refresh_fence(path, timeout) -> fd` / `release_refresh_fence(fd)`
pair says what actually happens and leaves nothing half-entered to leak.
The descriptor is opened with os.open at 0600 and closed on every
acquisition failure.

The `_refresh_token` release-on-exception stays: `_refresh_token` is also
reachable outside `async_auth_flow` (tests call it directly), and the
wrapper's finally only covers the generator-driven path.

HermesTokenStorage.remove() now unlinks the `.refresh.lock` sibling so
logout leaves no stray file. It is deliberately NOT added to
_state_paths(): snapshot()/restore(only_if_absent=True) treat any
existing state path as "newer state exists", and a lingering lock file
would silently veto a rollback.

tests/tools/test_mcp_oauth.py: drop the unused `Path` import (`time` is
still used by the socket poll helper).
2026-09-15 10:50:13 +05:30
kshitijk4poor f07ea70de7 fix(mcp-oauth): only treat contention errnos as "a peer holds the fence"
The poll loop swallowed every OSError from the lock syscall as contention,
so a filesystem that cannot take advisory locks at all (ENOLCK on some
network mounts, EMFILE, ...) stalled for the full 60 s deadline and then
blamed a peer. Only EWOULDBLOCK/EAGAIN/EACCES/EDEADLK mean "held by
someone else"; anything else now raises RefreshFenceTimeout immediately
with the real errno. Still fails closed -- the refresh is never POSTed
without ownership -- but the failure is diagnosable and instant.

The errno set mirrors cron.scheduler._is_lock_contention_errno; it is
duplicated rather than imported because importing the scheduler pulls in
the whole cron module graph for a four-value tuple.
2026-09-15 10:50:13 +05:30
kshitijk4poor 8933d355a9 fix(mcp-oauth): share one rotated-candidate rule between adopt and reload, re-bind issuer on every disk pair
Both fence paths that pull a peer's pair off disk now go through
_hermes_rotated_candidate (different, non-empty refresh token + non-empty
access token) and _hermes_install_disk_pair, which runs
enforce_refresh_token_issuer on the installed pair. Before, the adopt path
skipped the issuer check entirely, so a pair minted by a different issuer
could be POSTed straight to the new one.

The adopt path installs the candidate even when its access token has
already expired: the POST we are about to build needs the new refresh
token, and skipping the POST (_RefreshCompletedByPeer) is only correct
when the peer's access token is live with a positive TTL, mirroring the
reload path's clamp-to-zero guard. When the issuer enforcer strips the
refresh token there is nothing to refresh with, so the flow restarts into
401 -> full authorization instead of failing with OAuthTokenError.

The reload helper drops its outer except-Exception: get_tokens already
returns None for absent or corrupt files, so the blanket catch only hid
programming errors. Comment updated: this path exists for writers outside
the fence (interactive login, pre-fence Hermes), not for a fenced peer.
2026-09-15 10:50:13 +05:30
kshitijk4poor 76e2e8fb64 refactor(mcp-oauth): drop the per-access token-store lock now that the fence owns the refresh
`_token_store_lock` serialized a single get_tokens()/set_tokens() call and
then released. Its two justifications no longer hold:

- torn reads: `_write_json` goes through `atomic_json_write` (write to a
  sibling, rename), so a reader can never observe a half-written token
  file, locked or not;
- the read-modify-write of a single-use refresh token: a lock released
  between the read and the POST cannot close that race. `_refresh_fence`
  now spans read -> POST -> persist, and every store access on the refresh
  path (adopt-from-disk read, post-failure reload, `_store_tokens` write)
  runs inside it.

The remaining unfenced accesses are the cold `_initialize` read and the
authorization-code exchange's full overwrite -- neither is a
read-modify-write, so neither needs mutual exclusion. Keeping a second,
fail-open lock layer only adds a 10 s stall on a stale lock file with no
correctness gain. Tests that exercised the removed lock go with it.
2026-09-15 10:50:13 +05:30
kshitijk4poor 90f5502339 test(mcp-oauth): replace the subprocess fence harness with two in-process invariants
The 433-line harness spawned three interpreters and an HTTPServer to show
that one refresh generation is consumed once. The same invariant holds
in-process: flock is per open file description, so two real provider
instances sharing one token store contend for the fence exactly like two
processes do, and the SDK auth flow can be pumped with asend() the way
httpx does. Two tests now bind the fix deterministically:

- two providers, one store, a single-use token endpoint: exactly one POST
  carries R1 and both providers end holding the rotated pair (the loser
  adopts from disk and never presents the burned grant);
- fence held by another holder past the deadline: the refresh fails
  closed, no POST is sent and neither memory nor disk loses the tokens.
2026-09-15 10:50:13 +05:30
anhtahaylove a0810c9cc9 fix(mcp-oauth): fence one refresh generation across the consuming POST
The token-store lock is per-operation: get_tokens() and set_tokens() each
take it and release it. With a provider that issues single-use refresh
tokens, two processes can therefore both read R1, both POST it, and the
loser gets invalid_grant on a session that was healthy:

    A: get_tokens() -> R1   (lock taken and RELEASED)
    B: get_tokens() -> R1   (lock taken and RELEASED)
    A: POST R1              -> 200, receives R2
    B: POST R1              -> 400, credential already burned

Add _refresh_fence(), held across read -> POST -> persist so exactly one
process consumes a refresh generation. It fails CLOSED: unlike the
token-store lock it raises RefreshFenceTimeout instead of degrading to
unlocked, because proceeding without ownership is the race itself. It
locks a .refresh.lock sibling rather than the token file, since
flock/msvcrt locks are per-descriptor and nesting one path would
self-deadlock on Windows and silently no-op on POSIX.

The provider takes the fence before its final read, re-reads under it so
a peer rotation is adopted instead of overwritten, and releases in
_handle_refresh_response. async_auth_flow also releases on abandonment:
a cancelled generator never reaches the handler, which would strand the
fence and turn the race into a deadlock. That wrapper delegates send and
throw manually -- async generators have no yield from, and `async for`
would feed the SDK response to the inner generator as None.

tests/tools/test_mcp_oauth_refresh_fence.py is an acceptance test, not a
unit test: two real OS processes refresh against a single-use-token
authorization server that audits every redemption. It asserts R1 is
presented exactly once, neither process clears the session, and disk
converges on the newest token. Verified to FAIL without the fence
(audit=[rt-1, rt-1]); a threading-only lock cannot catch this.

(cherry picked from commit c1508a1d47e2cbd94e05fa507684f3716a1e988c)
2026-09-15 10:50:13 +05:30