8099745a4e23cd07aa1ede41cc65cb0bb12a427f
2971 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
580322ef1e |
fix(mcp): stdio MCP children get the routed profile's vault secrets, not the default's
Under a multiplexed gateway, `_build_safe_env` forwarded `os.environ[name]` for every name tagged in the process-global `_SECRET_SOURCES` map. That map is filled by EVERY served profile's secret-source hydration, while `os.environ` only ever holds the LAUNCH (default) profile's values — so once any profile's 1Password/Bitwarden source supplied e.g. GITHUB_TOKEN, every profile's stdio MCP server was started with the default profile's token. Resolve those names through the active profile's secret scope (`get_secret`) instead: the routed profile's value, or omitted when that profile has none. Under multiplex `get_secret` never falls through to environ; single-profile runs keep the .env overlay + environ behaviour, so the existing "vault vars reach MCP subprocesses" contract still holds there. `secret_source_names()` exposes the tagged NAMES only — values are never read from the shared map. Docs: the multi-profile guide's "MCP subprocesses only see their own profile's secrets" claim is now true for source-injected names too; say so explicitly. |
||
|
|
1bd4e33d36 |
fix(vault): make browser_vault_fill work on the default Browser Use backend
On the default backend (browser.backend unset → browser_exec) the vault tools were advertised but could never fill: the CDP supervisor that carries the secret-bearing eval is started only by the built-in browser_* session path, so _eval_js_secret failed closed with supervisor_required and the origin pre-check fell back to an agent-browser CLI eval against a browser browser_exec never touched. - browser_exec now attaches SUPERVISOR_REGISTRY to the CDP endpoint it just routed the harness to (BU_CDP_WS/BU_CDP_URL), so the fill talks to the SAME browser over the same secret-capable WebSocket. BU direct-cloud (BU_AUTOSPAWN) exposes no endpoint and keeps the supervisor_required refusal. - CDPSupervisor.focus_page(origin, accept=<js>) (used by browser_vault_fill, next commits) re-attaches the page session to the open tab on the item's origin whose DOM holds the form being filled (browser_exec opens its own tabs; the supervisor's initial attach picks the first page target, which is chrome://new-tab-page). browser_vault_fill uses it before the origin pre-check with a per-kind probe (password input / card fields / address fields). Live: evals/vault_fill_live_e2e.py drives the real browser_exec tool against Hermes' packaged Chromium with the login page in the third tab; A/B with the attach line disabled fails at "did not attach a supervisor", enabled fills the password into the /login tab and card fields into the /checkout tab with every model-facing read scrubbed. Also: browser_vault_list/fill described the workflow as "type the identifier with fill_input", a helper that exists only inside browser_exec code (toolset browser-use) and is a ghost on the built-in stack. model_tools._rewrite_browser_vault substitutes the concrete name from the session's actual tool set (`fill_input` inside browser_exec, or browser_type), the same dynamic cross-reference pattern browser_navigate uses for web_search. |
||
|
|
4140901d15 |
fix(browser): stop refusing credential-named query params on cloud browser/extract backends
browser_navigate / browser_exec / web_extract refused any URL whose query carried a
credential-NAMED parameter (token, signature, access_token, ...) when the backend was
a cloud provider. That is exactly the shape of magic links, OAuth callbacks and signed
CDN assets, so on Browserbase/Browser Use the agent could not finish a sign-in flow or
open an X video asset ("Blocked: URL contains a credential-like query parameter").
The floor protected nothing: the cloud browser already sees every cookie and typed
password of the session, and with the credential vault it receives the real password at
fill time. Hermes' own secrets leaking into a URL stay blocked by the value-shaped
_PREFIX_RE check (_secret_url_error), which is backend-independent. IMDS and
private-address floors are unchanged.
|
||
|
|
b51da65258 |
fix: adapt execute_code cell authority to the widened prompt-callback table
_callback_api() now yields (getter, setter) pairs for every per-thread prompt (approval, sudo, vault unlock); the kernel cell captured and restored the old fixed 4-tuple. Iterate the table so a cell carries every callback and a future addition needs no change here. Test recorder unpacks the new shape. Also: perfectionist import order in ui-tui interfaces.ts (CI lint). |
||
|
|
ff90eb28ff |
test(bot-mode): trim the author and loop-guard suites to their invariants
Keep one or two behaviour tests per seam (author reset on a cached agent, forged _turn_author refused, guard trips and cools, single charge on the busy path) and drop the parser/setting enumerations. a2a_key goes with them: nothing in this PR reads it; the honcho follow-up that does can bring it back with its consumer. |
||
|
|
091ac34ba9 |
fix(bot-mode): qualify the peer-dm author id with the sender's hostname
message_agent sent a bare bot:<profile> id through hermes peer dm, so a remote coder and the recipient's own coder shared one author id. The peer branch now sends bot:<hostname>/<profile>, with the hostname cleaned like any author field and slashes dropped. The direct local path keeps the bare id. |
||
|
|
3db4defcc1 |
fix(bot-mode): qualify a relayed author from the local connection too
delivery_turn_author kept the bare bot:<profile> id when the sender's connection was the Desktop's own "local", so a DM relayed from that machine collided with the recipient's profile of the same name. A relayed DM always crosses gateways, so the connection id is now part of the id whenever the Desktop sends one, and only the direct message_agent path in tools/bot_mode_dm.py stays bare. The relay.ts and session_auto_continue.py comments added earlier are cut to one line each. |
||
|
|
d3c8bacbe3 |
fix(bot-mode): qualify relayed authors with the sender's connection id
A relayed DM stamped bot:<profile> on the recipient turn, so an ops profile on another machine and the local ops profile shared one author id. The Desktop now forwards from_connection with each bot_relay.deliver, and delivery_turn_author builds bot:<connection>/<profile> for it while the Desktop's own gateway ("local") keeps the bare id. An api author object accepts an optional origin string that yields the same shape.
|
||
|
|
6881e4d3fc |
fix(bot-mode): carry the relay sender into a live Bot Chat turn
When the target Bot Chat is already open on this gateway, the relay handler delivers through `prompt.submit` with `queued: true`, and that branch dropped the envelope's sender. The model still saw the text prefix, but the turn reached the agent unattributed, the exact case the subprocess branch fixes. The relay handler now stamps the author on the submit as a `DeliveryAuthor`, an in-process object a JSON client cannot build, so `prompt.submit` accepts it the way it accepts a hosted-room callback and refuses a dict with error 4124. The busy queue keeps an authored envelope in its own slot, the drain hands the author to the turn runner, and the runner passes it to an agent that declares the keyword. A plain prompt after an authored dm carries no author. Local deliveries to a desktop-owned Bot Chat take the live-owner mailbox instead. The admission intent and the mailbox record now carry the author, a retry under the same id with a different author is refused, and the owner gateway hands the author to the turn it runs. Isolated compute turns still run unattributed, because the compute-host frame has no author field. |
||
|
|
55b3ea0b11 |
fix(gateway): count each bot message once in the loop guard and consume the author variable
The Telegram adapter asks the authorization check before dispatch, the ingress gate asks it again, and the busy path asks a third time. Each call counted one loop-guard event, so a Telegram bot tripped the budget after a third of the configured messages. The verdict now only refuses a chat that is cooling down. The ingress gate counts an admitted bot message once. `parse_turn_author` treats only booleans, integers and the strings true/1/yes as a bot flag, and returns None for an author with neither id nor name. Names keep format characters and non-breaking spaces so emoji sequences survive. The quiet one-shot pops HERMES_TURN_AUTHOR before the turn so tool subprocesses do not inherit it. `max_events` must be a whole positive number. Issue numbers move out of code comments. |
||
|
|
16d15869ba |
feat(bot-mode): carry the sender through local and relay deliveries
a bot dm arrived as an ordinary user message. the only trace of the sender
was the "Message from" text prefix, which the model reads and nothing else
does. the recipient's memory provider saw its own configured user.
message_agent now passes the sender as {"id": "bot:<profile>", "name":
<handle>, "is_bot": true} to the delivery runner (--author <json>), which sets
HERMES_TURN_AUTHOR on the recipient one-shot only. the -Q turn reads it and
passes turn_author into run_conversation. the desktop relay forwards the
envelope's from_profile/from_handle to bot_relay.deliver, which sets the same
variable on its delivery turn. the runner drops any inherited author first so
a delivery without one stays unattributed. the text prefix is unchanged.
|
||
|
|
ac07e20407 |
fix: resolve subagent control authority from the live session slot
Subagent list/tail/steer/interrupt authorized against a per-record copy of the owning session's transport (`owner_transport`). That copy had to be re-synced at every reattach site; `_rebind_live_transport` did it for session.resume/activate but prompt.submit and the queued-prompt drain still attached bare, so a client that reconnected through a prompt (the common path on a remote gateway / Bot Mode switch) streamed fine while `subagent.list` returned [] and controls rejected. Read `owner_session_record["transport"]` at check time instead: the slot is already mutated by every attach/detach/viewer-failover path, so no site can forget the sync. `owner_transport` stays as the capture-time "commissioned by a gateway session" marker (None = no RPC authority ever); non-dict owners keep the exact-object rule. Drops the registration-time re-read and the attach-time registry loop. Diagnosis credit: nftpoetrist (#106663) — their prompt.submit / drain regression tests pass against this change with no call-site edits. |
||
|
|
37eb6e1b05 |
test(bot-mode): pin the waiter budget from Python constants, not relay.ts text
The waiter budget test read relay.ts with a regex, which AGENTS.md bans and which the Python CI lane would not rerun on an apps/-only PR. It now checks DESKTOP_DELIVER_TIMEOUT_SECONDS against the module's own constants and that REPLY_WAIT_SECONDS exceeds it. relay-deliver-budget.test.ts still pins the TS mirrors against those Python constants from the Desktop side. |
||
|
|
b1915cb02d |
fix(bot-mode): keep the relay waiter watching past the Desktop deliver deadline
The sender-side waiter gave up at 900s while the Desktop held bot_relay.deliver open for 1500s, so a turn finishing between minute 15 and minute 25 wrote a reply nobody read. REPLY_WAIT_SECONDS now rebuilds the Desktop budget from the same numbers and waits 60s past it. The two turn constants move into tools/bot_relay.py so the gateway handler and the waiter share one definition. |
||
|
|
49ef015ca3 | fix: join delegated work before finite chat exits | ||
|
|
b4d04eb8fd |
Connector tools (Gmail, Linear, Notion, ...) are searchable and callable through tool_search for signed-in Nous users (#106842)
* feat: add session-scoped connector access for onboarding
* fix(connectors): availability is the config flag AND the portal entitlement — no free-tier leg
The port carried a third availability leg from hermes-magic: a stored guest
(free-tier) identity short-circuits the managed-tool entitlement check. That
leg reads hermes_cli.anon_auth, which does not exist on hermes-agent main, so
connectors_available() raised ImportError inside its fail-closed try and the
whole connector surface was silently dark on a plain upstream checkout.
On this tree availability is the two-leg AND the design started with:
tools.connectors.enabled AND managed_nous_tools_enabled(). The free-tier leg
is a hermes-magic concern and belongs in hermes-magic's own delta over this
branch, next to the identity it depends on. Its integration test goes with it.
* docs(tool-search): connectors section — remote tools through the bridge
The squashed port carried the code but not the user-facing docs. Restores the
Connectors section of the Tool Search page and the connector-gateway host /
CONNECTOR_GATEWAY_URL override on the Tool Gateway page, updated for the
manage_connections tool and the pure-connector batch rule.
* fix(tool-search): connector tools rank with local tools in one pass instead of taking leftover slots
dispatch_tool_search ran BM25 over the local catalog, filled `limit` slots,
then appended connector hits only into slots left empty. On a 300-tool
catalog no slot was ever empty, so with Gmail and Google Calendar connected
"send gmail email" returned five betterstack tools and zero connector tools.
The gateway's hits for a query now become catalog entries (connector name,
slug words, description as the search text) and join the local catalog for
that query's BM25 pass. One ranking, one rarest-token admission rule for both
sources, `limit` as the total per query. The merge loop and the separate
record builder for connector hits are gone; `_shared_tool_record` serves both
sources.
The gateway search timeout rises from 8 s to 30 s. One request with six
use_cases measured 7 s, so 8 s sat on the edge and cut real answers off; the
failure path is unchanged (local-only results, no error to the model).
Live, 311 local tools + gateway, before -> after:
"send gmail email": 5 betterstack tools -> gmail SEND_EMAIL, CREATE_EMAIL_DRAFT
"read google calendar events": 5 betterstack tools -> googlecalendar EVENTS_LIST_ALL_CALENDARS
"linear create issue", "betterstack incident": unchanged
Benchmark (25 labelled queries): connector recall 0.09 -> 0.82, precision@5
0.18 -> 0.59, false positives on absent intents 17 -> 2.
* refactor(tool-search): connector leg into tools/connector_search.py
tools/tool_search.py is a facade. The connector leg (gateway hits as catalog
entries for tool_search, remote schemas for tool_describe, the
connections_in_scope gate) was appended to it by the port. It now lives in
its own sibling, tools/connector_search.py, and the facade imports the three
entry points: connections_in_scope, connector_entries_by_group,
remote_schemas_for.
No behaviour change. The tool_describe remote block became
remote_schemas_for(names, current_tool_defs, connector_describe) with the
same inputs, the same silent-degradation contract and the same injection
seam the tests already use.
* fix(tool-search): at most 7 queries per call, the gateway's search limit
One tool_search call sends all its queries to the connector gateway as one
search request. The gateway answers 7 use_cases per request and returns
HTTP 502 for 8 or more (measured 2026-09-09, re-measured with one-word
use_cases: it is a count limit, not a size limit). With the client cap at
10, a model sending 8 to 10 queries lost every connector hit for that call
and saw local-only results with no error.
The shared constant splits: _MAX_QUERIES_PER_CALL = 7 for search,
_MAX_DESCRIBE_NAMES_PER_CALL = 10 for describe, which has no remote count
limit. Eight or more queries now get the existing "too many queries" retry
hint before any request is made. No chunking: one call, one request.
* fix(tool-search): the model is told that connectors__ names are manage_connections accounts
tool_search results carry names like connectors__gmail__CREATE_EMAIL_DRAFT and
manage_connections is the tool that checks and connects those accounts, but
nothing told the model the two are the same thing. A model that hit
CONNECTION_REQUIRED had to infer the fix on its own.
The tool_search description gains one sentence making the link, added at
assembly only when manage_connections is in the session's tools. Signed out
or with connectors off the tool is absent and the description is unchanged,
so it never names a tool the model cannot call. This follows the existing
rule for cross-tool references (tools/AGENTS.md): they are added dynamically
from the session's actual tool set, never hardcoded in a schema.
Tool defs are fixed for the life of a conversation, so the description is
byte-stable per conversation; this is a one-time prefix change.
Live, real get_tool_definitions() against a signed-in home: sentence present.
Same home with auth.json removed: manage_connections absent, sentence absent.
* fix(connectors): /stop halts a connector batch before the next remote call
dispatch_connector_batch runs every remote entry of a tool_call batch in
sequence. The executor only checks the interrupt flag between tools, and
the whole batch is one tool to it, so a /stop landing during entry 1 of
20 still sent the other 19 to the gateway.
The loop now reads tools.interrupt.is_interrupted before each dispatch.
Once set, it stops calling handle_function_call and fills every unstarted
slot with the loop's existing error-slot shape, code INTERRUPTED and the
message "Stopped by the user before this call was made.", so the result
envelope stays valid and the counts stay honest. Entries already
dispatched keep their real results.
Test: three connector calls where the fake client sets the interrupt on
the first execute. The client sees exactly one call and slots 2 and 3
carry INTERRUPTED. Red on the base branch, green with the fix.
* test(connections): schema assertions become dispatch contracts
test_schema_documents_wait_and_its_timeout froze description fragments
("REQUIRED", "can NOT disconnect", "Nous Portal"). A wording edit fails
it while a real regression (a disconnect that reaches the gateway) does
not. That is a snapshot of prose, not a behaviour contract.
Delete it. The requirement that wait needs connectors is already covered
by test_wait_requires_connectors. The user-only disconnect boundary is
now asserted as behaviour: action disconnect with a connector returns an
error and the fake client records no call. That replaces the earlier
de-authenticate test, which only checked that the word "dashboard"
appeared in the error text.
Test count in the file goes from 26 to 25.
* docs(tool-search): connector batches are one gateway request per entry
The user guide said a connector batch travels as one gateway request. It
does not: model_tools_connectors.dispatch_connector_batch re-enters core
dispatch per entry, and each entry becomes its own execute request in
bridge._run_remote (plus at most one literal-slug retry when the gateway
reports TOOL_NOT_FOUND under the conventional slug). The docstrings in
tools/tool_gateway/bridge.py and tools/tool_gateway/__init__.py still
described the abandoned V1 plan and claimed nothing outside the package
imports it.
Rewrite those sentences to match the code: one request per entry, in
input order, dispatched from model_tools_connectors.py, with the per-entry
approval and interrupt behaviour that motivated the split. The guide also
still showed the single-call shape tool_call(name, arguments); both
places now show the `calls: [{name, arguments}]` array the schema
advertises and note that a single local call is an array of one.
Docs only, no test.
* fix(tools): the between-turns refresh never rewrites the bridge tools
The per-turn MCP refresh folds a fresh tool snapshot into the live array
with preserve_prefix: order and membership stay, but a name present in both
takes the fresh schema. That is right for ordinary tools, whose schema is a
constant. tool_search is the one tool whose description is derived from the
session: the deferred-tool count, the embedded listing, and, on this branch,
whether manage_connections was present. A late MCP server or one failed
portal lookup (manage_connections' check_fn fails closed) changed those bytes
on the next turn, and every byte after tool_search in the cached prefix was
re-prefilled. The array also contradicted itself in that case: the flapping
manage_connections was carried forward while the description lost its hint.
The bridge entries now keep the bytes they were built with for the life of
the conversation. Nothing is lost: tool_search reads the live catalog at
dispatch, so tools that arrived late are still found; connector availability
is checked at dispatch too. The compaction-boundary rebuild (content_aware,
the one sanctioned cache break) still refreshes the description.
Consequence: connector exposure in the prompt is decided once, at agent
build, by whether the user was signed in then. That is the intended
contract.
* refactor(tool-search): normalize_tool_call_entries lives with the other argument validation
The port appended the tool_call argument parser to the tool_search facade.
The family already has tools/tool_search_validation.py for exactly this
work (schema validation of deferred call arguments), so the parser moves
there and the facade imports it. No behaviour change; the one test that
imported it now imports from the defining module.
* refactor(connectors): delete the unused batch dispatcher; _run_remote becomes run_remote
bridge.dispatch_calls and its helpers (_dispatch_calls_inner, _run_pre_dispatch,
_run_local, _error_slot, _maybe_parse_json) and the LocalDispatch / PreDispatch
seams had no production caller. Connector dispatch runs through
model_tools_connectors: dispatch_connector_batch re-enters handle_function_call
once per entry, so scope, hook, approval and middleware policy fire against each
composed name inside core dispatch, and dispatch_connector_call hands the single
planned entry to the bridge's transport function. Only tests called the batch
dispatcher, and they exercised policy seams that production never wires.
The transport function is the module's real entry point, so it drops the
underscore: _run_remote becomes run_remote, body unchanged. The module
docstring now describes the two legs that exist (availability with D32 silent
degradation, and run_remote) instead of the injected seams. Imports that only
the deleted code used are gone; merge.py is untouched because every export
still has a caller.
Tests that drove dispatch_calls are deleted where they covered the removed
seams (pre_dispatch blocks and rewrites, local_dispatch classification, mixed
batches). The literal-slug fallback, the per-entry transport failure, and the
hook rewrite reaching the gateway request body are re-targeted at
handle_function_call('tool_call', ...) with the fake client swapped in at
bridge._default_client_factory, the same seam test_connector_dispatch_policy
uses. Each re-targeted test fails when the retry is disabled in run_remote.
* fix(connectors): search keeps the twin a colliding name reaches, and says so
format_connector_name strips the toolkit prefix, so GMAIL_FETCH_PROFILE and a
literal FETCH_PROFILE on gmail both compose to connectors__gmail__FETCH_PROFILE.
describe and execute decode that name to the prefixed slug first, so the
literal twin is unreachable under it. If a vendor ever shipped both, search
could describe the literal under a name that runs the prefixed tool.
Search is the one place that sees both twins in one response. It now keeps
the twin the name reaches and drops the other with a WARNING that names both
slugs, whichever the gateway listed first. Short names stay; no marker, no
per-process map, no change to describe or execute. No such pair exists in the
live catalog today; the guard turns a silent alias into a logged one.
|
||
|
|
cf4b78e91f |
fix(tool-search): a query no tool answers returns nothing, not five tools sharing one word (#106676)
search_catalog admitted every document with BM25 score > 0 and then padded
to `limit`. BM25 sums over the tokens a document shares with the query, so
on a 300-tool catalog "send gmail email" returned five incident tools that
shared only "email", and the discriminating word ("gmail", in no document)
had no say. The model read those as the answer.
Admission is now the query's rarest token: a document is a result only if it
contains the query token with the highest IDF, the one that names the intent.
Common verbs ("send", "read", "create") sit in dozens of documents and never
gate; vendor and object words ("gmail", "github", "incident") do. A token no
document carries admits nothing, and the existing empty-group hint tells the
model to retry without it. The name-substring fallback is deleted: it admitted
tools that matched no query token at all.
Result descriptions are clipped at 500 characters instead of 400. Over 353
vendor tool descriptions, 500 keeps 91% whole and every first sentence
(first-sentence max 329); 400 kept 82%.
Measured on the live 311-tool catalog with 25 hand-labelled queries:
precision@5 0.18 -> 0.43, wrong names returned 102 -> 66, false positives on
absent intents 17 -> 13. Live before/after: "send gmail email" went from five
betterstack tools to an empty group with the retry hint; "linear create issue"
and "betterstack incident" are unchanged.
|
||
|
|
b7bef04861 |
fix(kanban): an explicit scratch workspace never inherits the board's project
Move the "explicit scratch means no project" decision into the one resolver every surface funnels through, `kanban_db.create_task`: board-project inheritance now runs only when the caller left `workspace_kind` open (`None`), and `workspace_kind` defaults to scratch after that check. The tool handler keeps the #106347 fix for `project=""` (no `or` collapse) and `board=` scoping but drops its handler-local sentinel logic, since the resolver now owns the rule; the `self_task` project inheritance for dispatcher-owned workers is unchanged. Sibling surfaces had the same bug through the same line and are fixed by the same change: - CLI `hermes kanban create --workspace scratch` on a project-scoped board produced a project worktree; `--workspace` no longer defaults in argparse so the resolver can tell "omitted" from "scratch". - Dashboard `POST /tasks` with `workspace_kind: "scratch"` did the same; `CreateTaskBody.workspace_kind` defaults to `None` for the same reason. - `kanban_swarm.create_swarm` threads `None` through for consistency. Tests: one resolver invariant in test_kanban_board_project.py (explicit scratch stays scratch, omitted still inherits) and the salvaged tool test folded into a single parametrized matrix over scoped/unscoped target boards. |
||
|
|
dc5c87c6c1 |
fix(kanban): honor explicit scratch/project on MCP kanban_create
Pass the caller's board into create_task and treat explicit workspace_kind=scratch or empty project as no-project so ambient board project_id cannot override MCP args. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
85e423482a |
fix(tools): kill the browser_exec CLI tree on timeout on Windows too
The salvaged fix only ran the CLI in its own session on POSIX and kept plain subprocess.run on Windows — the one platform where the wedge in #106244 is actually reproducible: CPython's run() retries an unbounded communicate() after kill() there, so a grandchild holding the capture pipes blocks the worker forever. On POSIX run() wait()s the PID and returns promptly; the grandchild merely leaks (live-reproduced on Linux). One code path for both platforms: - _group_popen_kwargs: start_new_session=True on POSIX, CREATE_NEW_PROCESS_GROUP + hide flags on Windows (replaces the hide-only _windows_popen_kwargs). - _kill_cli_process_group: os.killpg SIGKILL on POSIX, taskkill /T /F on Windows (same kwarg set as the sibling taskkill sites). - The Popen decodes with encoding="utf-8", errors="replace" like every other subprocess call in this file (windows footgun rule). - Drain test patches the kill helper instead of os.killpg so it runs on every host. Windows behaviour is not live-verifiable on this Linux host. |
||
|
|
6788c3224f |
test(tools): trim the browser_exec group-kill tests to two invariants
Drop test_gone_group_still_surfaces_timeout: the killpg-race branch is a contextlib.suppress and the test only pinned the exact communicate() timeout sequence (a change-detector). The real-grandchild test and the bounded-drain test remain — the two behaviour contracts of the fix. |
||
|
|
60debff28d |
fix(tools): kill the whole browser-use CLI process group on browser_exec timeout
subprocess.run only kills the direct CLI child on TimeoutExpired; a browser_harness daemon / Chrome helper grandchild inherits the stdout/ stderr pipes and keeps them open, so the internal communicate() blocks on pipe EOF forever. The wedged tool call never returns, its activity heartbeat keeps stamping last_activity_at every 30s, and the session is pinned at "now" in the desktop sidebar indefinitely (#106244). On POSIX, run the CLI in its own session (start_new_session=True) and SIGKILL the whole process group on timeout, then drain the pipes under a bounded deadline. Windows keeps subprocess.run. |
||
|
|
acf9177c70 |
test(fal): trim the billing-409 salvage to two invariant tests
Keep the two tests that fail on main without the fix: - test_fal_common: a keyed managed submit makes exactly one POST (plus the negative arm: an unkeyed submit still goes through the SDK retry ladder) - test_image_generation: the 409 BILLING_ERROR body surfaces `unsupported_pricing_meter` instead of the generic "not yet enabled" text Dropped from #106484: the duplicate video-plugin billing test (same helper, same assertion), the `_fal_client = fake` / `import_fal_client` stub churn and the `tools.lazy_deps` stub — fal-client is installed in CI (`--extra fal`) so those fixtures were not needed; the `_load_fal_client` no-op fixture on TestManagedGatewayErrorTranslation for the same reason. Also drop the redundant `retry_request is None` re-check in `_ManagedFalSyncClient.submit` — `__init__` already raises when the helper is missing. |
||
|
|
0c6b94e499 |
fix(image-gen): preserve managed FAL billing errors
Avoid retrying idempotent managed FAL submissions because the retry can mask the initial billing failure. Surface structured Nous billing diagnostics consistently for image and video paths, with hermetic regression coverage. (cherry picked from commit 289ce039e9a522dc8016ae4a512214c05d0a8bc0) |
||
|
|
9678d59c23 |
test(mcp): stdio arm of the transport-rebuild test now expects no replay
The parametrized lifecycle test drove both transports through a ClosedResourceError on the first call_tool and asserted a replay. For the HTTP arm that is still the session-expired contract; for the stdio arm a pipe closing after dispatch is now the ambiguous mid-call death and must surface outcome_uncertain with exactly one invocation. The transport is still rebuilt on both arms. |
||
|
|
cff103a8b7 |
fix(mcp): classify an SDK-first transport close after dispatch as an ambiguous stdio death
The child watcher polls every 250 ms, so in practice the MCP SDK sees the closed pipe first and `call_tool` raises ClosedResourceError / "Connection closed" before the watcher fires. That exception is not a _StdioChildExited, so it fell past the stdio recoverer into _handle_session_expired_and_retry, which reconnects and replays the call -- the exact duplicated-side-effect path the previous two commits close. Live repro against a real stdio child that applies an effect then exits without replying: 2 effects with the contributor's commits alone, 1 effect + outcome_uncertain after this. On a stdio server, a session-expired-class error raised by an RPC that was already dispatched is re-raised as _StdioChildExited(in_flight=True) so the stdio recoverer owns it. HTTP servers are untouched: their session-expired retry is still the right recovery. Tests trimmed to the salvage bar: the contributor's test_precall_respawn_retry_dying_midcall_is_uncertain_without_replay (a variant of the watcher-race case) is replaced by the SDK-first regression the review on #106440 asked for; the existing pre-call retry test still pins that a never-sent call is retried once. |
||
|
|
73c104ee35 |
fix(mcp): preserve uncertainty after retry exit
(cherry picked from commit 775dd2c4d7eb28cd82cb8d943c8ea11640474227) |
||
|
|
144b86ef48 |
fix(mcp): avoid replay after mid-call stdio exit
(cherry picked from commit b29e68d0b020c805d1fccfc55f3a7c6ee6589ba9) |
||
|
|
d5926b2494 | fix: persist API delegation units once without waking the model | ||
|
|
179790df6f | test: reproduce API delegation provenance and replay loss | ||
|
|
e2763baf1c |
refactor(kanban): route the reviewer guard through _check and tighten its tests
Use the module's `_check`/`_Reject` idiom instead of an inline `return tool_error(...)` so every kanban_request_review validation failure renders through the same path, and drop the unreachable `or "none"` (list_profile_names() always contains "default"). Tests: compare the task's (status, assignee, run) tuple and the event log before/after instead of the unordered 6-assert block, use the context-managed kanban_db_connect.connect (the kb.connect alias is a plugin-compat pointer — scripts/check_compat_pointers.py flagged it), and reference #106163 in the invariant's docstring. Salvage note vs #106214 (@gaoanze888): that PR guards the same condition inside hermes_cli/kanban_db.py::request_review, but the DB primitive is also the chokepoint for `hermes kanban request-review` and the dashboard's drag-to-review, both operator surfaces where a non-profile assignee (external/human review lane) is a documented board shape (website/docs/user-guide/features/kanban-worker-lanes.md) — and it forced five unrelated test fixtures to monkeypatch profile_exists to True. The model-facing tool wrapper is the layer where a typo'd string is a bug, so the guard lives there. |
||
|
|
1d89286b36 |
fix(kanban): reject phantom worker reviewers
kanban_request_review(reviewer=<name>) reassigned the task to whatever string the model supplied. A non-profile value (e.g. the literal "reviewer") parked the card in `review` on an assignee the dispatcher can never spawn, with no error to the worker — the chain stalled silently (#106163). Validate the explicit reviewer against installed profiles before touching the board and return a tool error listing the installed profiles so the model can self-correct. Salvage of #97429: the kanban_diagnostics `review_reopened` hunk and its tests were dropped (main already replaced that loop with `_latest_event_ts`; the tests targeted the PR's pre-refactor base). Re-authored from the placeholder identity `regen <regen@local>` to the PR author's GitHub noreply address (misconfigured local git, not malice). |
||
|
|
bf53ff00a7 |
fix(config): one bounded backups/config/ dir replaces four config.yaml.bak schemes
Four writers each dropped their own uniquely-named copy of config.yaml next to the real file and none of them ever deleted anything: hermes setup (config.yaml.bak.YYYYMMDD_HHMMSS, one per run even with no change), the corrupt-YAML snapshot (config.yaml.corrupt.<ts>.bak), hermes migrate xai (config.yaml.bak-pre-migrate-xai-<ts>) and the Docker boot migration (config.yaml.bak-<ts>, .env.bak-<ts>). A home dir accumulated a dozen variants with no way to tell which mattered. hermes_cli/config_backups.py::backup_config is now the single writer: backups/config/config.yaml.<reason>.<YYYYMMDD-HHMMSS>, skipped when the newest copy for that reason is byte-identical, rotated to the newest five per reason. backups/ is already excluded from full backups so nothing nests. Legacy siblings written by the old schemes are moved into the dir on first use; hand-named copies (config.yaml.bak-my-note) are left alone. Live: three `hermes setup --non-interactive` runs against an unchanged config went from three .bak files in HERMES_HOME to one pre-setup copy under backups/config/; repeated loads of broken YAML produce one corrupt copy instead of one per process (deduped by content). |
||
|
|
e333113871 |
fix(review): keep /refine under the background_review origin; attendedness is its own flag
The salvaged commit forked an explicit /refine under a new "refine_review" origin so the memory delete gate would not treat it as unattended. But is_background_review() is the key for every other review guard — skill_manager_guards (curator-owned-only, read-before-write), skill_manager_tool (archive instead of rmtree), skill_ledger actor, write_approval staging, the [auto] tag — so a /refine fork silently escaped all of them. Carry attendedness separately: the fork keeps origin "background_review" and sets _review_attended; turn_context binds it beside the origin ContextVar; the memory gate keys on the new is_unattended_review(). Also run the gate AFTER _validate_single_op / the operations list check, as memory_tool's own docstring requires, so an invalid replace is rejected now rather than staged and failed at approve time. |
||
|
|
c0714575c3 |
fix(review): distinguish explicit /refine from unattended reviews and surface staged consolidations
Review follow-up on #105944 (#105921): - explicit /refine forks now run under the refine_review write origin (explicit flows from the CLI/gateway handlers through _spawn_background_review_now and spawn_background_review_thread down to build_cache_parity_fork), so a user-requested review keeps the full memory operation set; only automatic reviews stay behind the unattended delete gate. - the unattended delete gate now stages the denied replace/remove (or whole batch) into the pending store instead of dropping it: the fork's own review summary is never published, so a plain denial lost the consolidation request with no surfacing path. The staged proposal carries a proposal_staged marker that summarize surfaces as an action line, and a staging failure still fails closed to a plain denial. - regression tests: explicit-path origin pass-through, refine_review keeping replace working, near-limit denial end to end (add rejected by budget -> replace staged -> proposal surfaces, store unchanged). |
||
|
|
1571f502a9 |
fix(agent): scope background review memory access to its trigger (#105921)
The review fork's tool whitelist granted the whole memory toolset whenever the profile had memory enabled, regardless of which nudge fired, so a skill-nudge fork held remove/replace on MEMORY.md it was never asked to use; combined with the memory tool's near-limit 'consolidate now' hint, an unattended fork deleted standing rules with no user in the loop. - Pass review_memory from spawn_background_review_thread through _run_review_in_thread/_run_review_fork into _review_tool_whitelist; a skill-only review no longer gets the memory tool at all. - Fail-closed operation gate in memory_tool: a background-review fork may add, never replace/remove (single or in a batch) — consolidation decisions reach a human via the review summary instead. - Keep the deny/prompt wording in sync with the whitelist so a memory-less review doesn't advertise memory. |
||
|
|
ac10770894 |
fix(cron): don't stamp the next occurrence on an off-tick manual run
claim_job_for_fire() derives the occurrence identity from next_run_at before the same function advances it. On a scheduler tick next_run_at is the occurrence being run, which is correct; on an off-tick manual run it is the NEXT occurrence, so the execution is stamped with the identity of a slot that has not happened yet. _job_is_due() then finds a completed execution carrying that identity and skips the real slot, returning before the last_dispatch write — no error, no log line, no dispatch record. The manual flag already guards this and both _job_is_due() and claim_job_for_fire() honour it; the agent-facing run-now path never declared itself. Add a keyword-only manual= parameter and pass it from _claim_for_manual_run(). Deliberately not force=True: force also calls _activate_job_record(), which would resume a paused or disabled job, and the run-now tool depends on continuing to refuse those. The local flag is renamed to manual_fire so the new parameter is not shadowed inside the apply closure, which would raise UnboundLocalError. Three existing tests in tests/tools/ pinned the old call signature via assert_called_once_with; they now pin manual=True, so dropping the flag again fails loudly rather than silently reintroducing the skip. Restores the intent stated in #104790 — the column records the scheduled instant an execution was claimed for, and an off-tick manual run was claimed for none. Fixes #105690 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0e9fc2cc15 |
test(process-registry): exercise the portable probe payload
Execute the selected no-op rather than freeze its spelling, while rejecting /bin/true to model the NixOS failure. Mark the regression Linux-only and retain the current user-bus environment handling. Consolidates the earlier NixOS scope-probe report and fix in #102587 with the PATH-independent payload from #105436. The fallback resolver is not needed when /bin/sh is used directly. Co-authored-by: Scott Garrand <sgarrand@gmail.com> |
||
|
|
37ce016dc6 | test(process-registry): mark systemd probe tests linux_only | ||
|
|
7a7ead8179 | fix(process-registry): use portable /bin/sh probe for systemd-run scope availability (#105365) | ||
|
|
9d865810b6 |
fix(mcp-oauth): keep refresh_token when a refresh response omits it (#62333)
HermesProviderMixin._handle_refresh_response overrides the SDK's handler (to accept any 2xx and keep token bodies out of logs) but dropped the SDK's RFC 6749 section 6 carry-forward. An authorization server that does not rotate refresh tokens (TinyFish, Google, Zoho, Asana, Futu) answers the refresh grant without a refresh_token; we then stored the response verbatim, erasing the only refresh token we had, so the next expiry had nothing to refresh with and forced a browser re-auth roughly one TTL after every login. Carry the prior refresh_token (and scope, per section 5.1) forward on the OAuthToken before _store_tokens, so both the live provider and the on-disk token file keep it. A rotating AS still wins: only None fields are filled. Tests: two invariants on the real HermesMCPOAuthProvider + HermesTokenStorage (omitted -> preserved in memory and on disk; provided -> rotated). The carry-forward test is red on main. |
||
|
|
b1f003e186 |
feat: add FAL GPT Image 2.5 generation and editing selections
CI / OSV scan (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Supply-chain scan (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
|
||
|
|
aa83c6d614 | fix: hide inactive grouping options from delegation schema | ||
|
|
be9c2bd19c |
fix(cron): derive the user bus env per probe and scoped spawn
A system-level gateway unit has no ordering against user@<uid>.service and linger may be enabled after boot, so the bus can appear after the one-shot adoption in run_gateway() ran. Derive XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS fresh for the availability probe and every scoped spawn (cron worker, Kanban worker, PTY/pipe terminal spawns, scope cleanup) so the 60s failure TTL can actually recover. Refs #104893. |
||
|
|
13fb5e1ece | test(browser): migrate pinned-profile fixtures to real SQLite | ||
|
|
58d6d5223a |
fix(browser): bound auth backups without replacing live SQLite files
Preserve Ben Barclay's diagnosis and replace the staging-file approach with SQLite-coordinated writes and a five-second backup callback deadline. A main-file replacement can replay an abandoned destination WAL; immutable source reads can miss committed source WAL. Neither raw copy nor replacement is safe when the destination is locked. Refuse unavailable auth databases without raw-copy fallback, retaining the existing close-browser-and-retry flow. Keep two invariant tests for lock refusal/recovery and source-versus-destination WAL contents. Convert existing text masquerading as database fixtures into real SQLite fixtures. Related: #105754 Related: #96659 |
||
|
|
b4d7cf735d |
fix(browser): real-profile auth mirror hangs forever on a locked destination
A `browser_exec` call could park a thread in `sqlite3_sleep` permanently
while mirroring Chrome's auth DBs, holding the agent's turn open. The turn
never reaches its `finally`, so no `session.info running=false` settle is
emitted and the Desktop composer latches busy — every later message queues
and never sends. Captured live: one thread stuck 24+ minutes across two
dumps, turn accepted at 15:03 with no `tui turn finished` 46 minutes later.
Root cause is the DESTINATION, not the source. `Connection.backup()` retries
a busy destination internally and ignores the connection's busy timeout, so
`sqlite3.connect(dst, timeout=5)` cannot bound it. A destination left locked
by an earlier hung mirror therefore blocks the next mirror forever — and
because the tool-level 420s timeout abandons the thread without interrupting
a C-level lock wait, the lock is never released and every subsequent launch
re-hangs the same way. Self-perpetuating.
Two changes:
- Back up into a fresh `<dst>.new` and `os.replace()` it into place. No other
process can hold a file we just created, so there is nothing to contend on,
and the swap stays atomic. Measured against a live Chrome with a
deliberately locked destination: 0.0006s vs an indefinite hang.
- Drop the `mode=ro` (no `immutable=1`) source fallback.
|
||
|
|
866332bfb5 |
fix(relay): authorize send_message targets and surface egress declines (P5) (#99220)
* fix(relay): authorize send_message targets and surface egress declines
P5 of the relay egress-authorization workstream. The relay path
authenticated the SENDER but never authorized the DESTINATION, and the
gateway compounded it from both ends.
(a) send_message could silently name an arbitrary relay target. Its
`target` parameter is free-form ('platform:chat_id'), so a model could
name ANY chat id and the gateway would emit an outbound frame for it.
gateway/relay/egress.py adds an attestation floor: a relay-routed
destination must have a provenance this gateway can show -- the
operator's home channel, the channel directory, or its own gateway
session origins. Anything else is refused HERE, with a visible tool
error naming the target, before a frame is written. Non-relay platforms
and platforms served by a live native adapter in this process are
untouched (same precedence resolve_delivery_transport applies).
(b) Connector declines were swallowed into apparent successes. The
connector's egress floor answers an unauthorized destination with a
DEFINITE failure whose text is deliberately uniform (F-005). Several
relay lanes degrade a *transport drop* by design and were degrading an
*authorization refusal* the same way:
- _send_media returned None, sending the caller into
BasePlatformAdapter's text fallback -- a DIFFERENT op re-addressed at
the very chat the connector had just refused.
- _send_prompt returned None, so exec-approval / slash-confirm /
clarify reported "relay prompt op unavailable" (a wrong reason) and
ran their numbered-text fallbacks into the refused chat.
- task_card_stop discarded the error entirely.
- typing / delete / react / thread ops degraded silently at debug.
is_egress_decline() classifies THAT a decline happened (never why --
the uniform text is not parsed for reasons) and requires a definite,
non-ambiguous failure, so a lost-ack retry is still a transport
outcome. Lanes with an error-carrying contract now report the decline
verbatim; cosmetic bool/None lanes still degrade but log it at WARNING.
Advisory progress drops that legitimately degrade are unchanged: the
task_card send lane, the draft ambiguous/except branches, and every
transport-exception path keep their existing fail-open behaviour.
Tests: 21 mutations of the production source, all KILLED.
* fix(relay): authorize the RESOLVED target; declines must not fall back
Review round 1 (independently confirmed by a second reviewer) found three
blockers. Two are fixed here; the third (B-2, Telegram @username) is a policy
decision left open deliberately.
B-1 — THE FIX CAUSED THE OUTAGE IT PREVENTED (tools/send_message_tool.py)
The P5(a) guard ran ABOVE Slack user->DM resolution, so it authorized the
internal pseudo-id `_parse_target_ref` emits (`user_name:ben`, `user:U...`).
Provenances only ever hold RESOLVED conversation ids, so a fully attested DM
was compared as a handle against a set of `D...` ids and refused:
base slack:@ben SENT head(before) slack:@ben REFUSED
Every Slack DM by handle was broken. Moved the guard below resolution; it now
authorizes the destination that is actually sent to, and the refusal names the
resolved id. Position is load-bearing, so it is commented as such and pinned:
reverting the move turns exactly the four new cases red.
B-3 — A DECLINE IS NOT A LANE FAILURE (gateway/run.py)
`_approval_send_outcome` had only sent/failed/ambiguous, so a connector
decline collapsed into `failed` — which is the cue to run the plain-text
fallback into the chat the connector had just refused. The adapter fix in the
previous commit improved the error STRING while user-visible behaviour stayed
identical to base; the commit message overstated it. Fixed properly:
- new `declined` verdict, recognised via the shared `is_egress_decline`
contract (not string sniffing at the call site)
- exec-approval returns without the text fallback
- slash-confirm suppresses the text reply AND clears the registration, so a
card that never rendered cannot capture the user's next message
`send_clarify` was already correct (returns early inside the adapter).
MUTATIONS (production source; both directions)
classifier never returns 'declined' -> KILLED (4 cases)
ALL failures classified as 'declined' -> KILLED (2 cases)
guard moved back above Slack resolution -> KILLED (4 cases)
decline CODE changed (review M05) -> KILLED
marker match made case-sensitive (M10) -> KILLED
M05 was a tautology: the test asserted the imported constant against itself,
so changing the constant could not fail it. The wire contract is now pinned as
a literal, because the connector stamps that exact string and a one-sided
change is a silent cross-repo break.
REGRESSION CHECK: the 12 failures + 1 collection error in this test selection
are PRE-EXISTING cross-test contamination — the identical set fails at
7cf86188ac. Verified by diffing the failing sets: no new failures, 363 -> 374
passed.
NOT FIXED (deliberate): B-2, Telegram `@username`. The Bot API resolves handles
at send time, so there is no id to compare and no canonicalization exists yet.
That is a policy decision, not a code move.
* fix(relay): fail CLOSED on guard faults; classify the structured decline
Third independent review. Two more blockers, both reproduced before fixing.
1. THE GUARD ITSELF FAILED OPEN (tools/send_message_tool.py:158)
`_authorize_relay_target` wrapped BOTH the import and the call in one
`except Exception: return None` — and None means AUTHORIZED at every call site.
So any runtime bug inside the guard silently switched the entire P5(a) boundary
off. Reproduced: with the guard raising, an unattested target sent.
The docstring already stated the correct intent ("must not fail closed on its
own IMPORT error") and the code did something broader. The two failures are not
the same: a missing gateway package means there is no relay egress to
authorize; a fault inside the guard means authorization did not happen. The
import is tolerated, the call is not — a guard that cannot answer refuses.
2. THE STRUCTURED DECLINE WAS THROWN AWAY (gateway/run.py)
The adapter preserves the connector's dict in `SendResult.raw_response`. My
previous commit rebuilt a dict from the error STRING, which loses two
contracts:
* a decline carrying `code: egress_declined` and NO text renders as
"relay egress declined" — no marker colon — so it classified as `failed`,
which is exactly the cue to run the fallback into the refused chat;
* `ambiguous: True` (lost ack) was flattened into a DEFINITE failure,
re-sending a card that may already be on the user's screen. That is the
duplicate-card bug the ambiguous verdict exists to prevent, reintroduced
by the fix meant to harden the same path.
Both call sites now classify `raw_response` when present, ambiguity first, and
fall back to the wire sentence only for connectors that send no structured
response.
I had fixed the text-marker path and tested only the text-marker path. Worth
naming: the review's probe was a shape my tests never produced.
MUTATIONS (production source)
guard fault returns None (fail open again) -> KILLED
classifier ignores raw_response -> KILLED (3 cases)
ambiguous treated as a definite failure -> KILLED (2 cases)
40 focused tests pass. Regression check vs be321faf27: identical 13-item
failing set (pre-existing cross-test contamination), no new failures.
STILL OPEN: B-2 / finding 3, Telegram `@username`. The reviewer is right that
this is a REGRESSION of an existing contract (#53573 added Bot API username
support), not merely an unspecified input, since relay provenance stores the
numeric chat id. Fixing it means resolving the handle before authorization, or
explicitly revoking the contract. That is a policy decision, not a code move,
and it is Ben's call.
* test(relay): pin M21 and M25, the survivors whose comments called them load-bearing
Round-2 review reported six unpinned survivors from round 1. Two guard real
behaviour and are now covered; the other four are cosmetic-lane warnings and
fail-open branches I am leaving documented rather than pretending to close.
M25 — thread-qualified session ids. `_session_ids` adds BOTH "chat:thread" and
the bare chat, because the connector authorizes the CHAT. Without the split a
gateway whose session origin is `-100999:77` cannot send to `-100999`, the chat
it is demonstrably already talking in. KILLED.
M21 — the generic `relay` plane must union every fronted platform, since a
relay session is filed under its LOGICAL platform. KILLED.
MY FIRST M21 TEST WAS THE DEFECT IT WAS TESTING FOR. I patched `_relay_fronted`
— the very function the mutation empties — so emptying it changed nothing the
test could see, and the mutation SURVIVED against a green test. Rewritten to
drive the real `relay_fronted_platforms()` through its env source
(`GATEWAY_RELAY_PLATFORMS`), which is how production learns it.
That is the same "the test verifies my stand-in" failure I have spent this
workstream removing from the connector harnesses, reproduced here in three
lines of Python. The tell was identical: a mutation that survives a test
written specifically to kill it.
334 tests pass.
NOT PINNED, deliberately: M03 (success-guard on a malformed dict), M24
(empty-target allowance — the one fail-open branch, reachable only when the
bare-platform path already resolved a home channel), M35/M36 (decline WARNINGs
on cosmetic lanes). All four are observability or defence-in-depth rather than
authorization, and the review agrees they are non-blocking.
* fix(relay): defer Telegram @username authorization to the connector (B-2)
Closes the last blocker. Two reviewers independently called this a REGRESSION
of the public-channel username support added in #53573, not an unspecified
input, and they were right: provenance stores RESOLVED numeric chat ids, so
comparing `@channel` against them could only ever refuse.
WHY THE GATEWAY CANNOT ANSWER IT. The guard fires only when there is no live
native adapter — i.e. relay-fronted deployments — and on exactly those the
CONNECTOR holds the bot token, not this process. There is no local way to turn
a handle into the numeric id. Refusing here is not "fail closed", it is "fail
always".
WHY DEFERRING IS SAFE. The destination is still authorized one layer out: the
connector's Telegram egress floor (gg#238, merged 743a7c2) classifies and
refuses unauthorized destinations after ITS resolution — the layer that closed
the reported vulnerability in the first place. Handles go from two guards to
one, the authoritative one, not to zero.
The carve-out is deliberately narrow and its EDGES are pinned, because the
failure mode of an exemption is silent widening:
telegram `@handle` -> deferred (the regression case)
telegram numeric id -> still guarded
matrix `@user:server` -> still guarded (telegram-only)
bare name, no `@` -> still guarded
attested handle -> normal path, attestation still consulted
MUTATIONS
carve-out widened to all platforms -> KILLED
carve-out widened to every target -> KILLED
carve-out removed (regression back) -> KILLED
carve-out checked BEFORE attestation -> KILLED
THE ORDERING MUTANT SURVIVED MY FIRST TEST. Both orderings return None, so
asserting the verdict could not tell them apart — the test asserted the claim
instead of the mechanism. Rewritten to observe that attestation is actually
consulted. Same defect class as the M21 test earlier in this branch: a
mutation surviving a test written specifically to kill it means the test is
measuring the wrong thing.
341 tests pass.
FOLLOW-UP (option 2, Ben's call, deliberately NOT done here): resolve the
handle before authorizing so BOTH layers apply. That needs a resolution
round-trip through the connector — new wire surface — so it belongs in its own
phase rather than bolted onto this one. Recorded in the code comment at the
carve-out, not just here.
* fix(relay): close two fail-open boundaries; test the code-only decline for real
Both blockers from review, each REPRODUCED before fixing.
1. STRUCTURED DECLINE HAD NO GUARD. Deleting `raw_response=result` from both
`_send_prompt` return branches left all 34 tests green — a surviving,
non-equivalent security mutant. The `code` field is the documented
PREFERRED signal precisely because a connector may send no prose, and a
caller rebuilding `{"success": False, "error": ...}` cannot see it.
Cause: every existing case declines with marker TEXT. The evidence for the
code-only path was a hand-built SimpleNamespace in a different file — a
stand-in for the adapter, so it verified my fixture instead of production.
Fixed with a CodeOnlyDecliningConnector driving the real
`send_exec_approval` -> `_send_prompt`, feeding the REAL SendResult to the
REAL `_approval_send_outcome`, plus the same shape on the media lane.
drop raw_response SURVIVED (34 passed) -> KILLED
2. TWO FAIL-OPEN BOUNDARIES, both "absence" and "fault" sharing a return.
`_relay_fronted` swallowed EVERY exception and returned an empty set, which
`relay_routed_platform` reads as "not relay-routed" — skipping the guard.
Probe, with a positive control in the same run:
positive_control_denied = True
discovery_fault_denied = False <- unattested target AUTHORIZED
`_authorize_relay_target` caught every exception during IMPORT as "no
gateway package". A module that exists and fails to initialize is a fault,
not an absence, and returning None there means authorized.
Now: ImportError alone is absence; anything else raises RelayRouteUnknown
and `authorize_relay_target` converts it to a REFUSAL STRING (not a raised
exception — every caller treats the return value as the verdict, so raising
would trade a fail-open for a crash).
Kept the converse under test so "fail closed" does not silently become
"refuse everything in CLI/cron", which is the outage the broad except
existed to prevent.
discovery fault -> empty set KILLED
RelayRouteUnknown -> authorized KILLED
import fault -> authorized KILLED
397 passed (was 392, +5 new cases), zero failures.
* fix(relay): close all seven review-round-3 blockers
Every finding reproduced before fixing; every fix mutation-checked after.
CONTENT LEAKS (the decline was laundered into a different op, same chat)
#1 A declined DRAFT SEAL replayed as a plain send. On stream-is-the-message
platforms the turn-final becomes draft(final=True); `_seal_open_draft`
dropped the structured body, so `_absorb_into_open_draft` read a REFUSAL as
a lane failure and fell through. Probe, Slack descriptor:
before: draft(partial) -> draft(final,SECRET) -> send(SECRET)
after: draft(partial) -> draft(final,SECRET)
My first probe of this used a discord descriptor and showed no seal at all —
the leak is real, my probe was wrong (streams only arm for Slack).
#6 Task-card PROGRESS had the same defect one lane over: a bare failed
SendResult reads as "card lane unavailable", and TurnRunner then sends the
task text to the same chat. Both card methods now carry raw_response and
the caller suppresses the fallback on a decline.
AUTHORIZATION BYPASSES
#2 `except ImportError` was NOT the fix I claimed last round. ImportError also
covers a broken dependency inside an INSTALLED gateway; review probed
`ImportError.name = "gateway.relay.dependency"` and got an authorized
verdict. Now only a name identifying the gateway relay module itself is
absence. An ImportError with NO name stays absence — refusing on a fault we
cannot attribute would trade an unidentifiable bug for a real CLI/cron
outage, and an existing test caught exactly that when I first got it wrong.
#3 `relay_routed_platform` lowercases the requested platform; `_relay_fronted`
returned configured names verbatim. A platform configured as "Discord"
missed the membership test, looked native, and skipped the guard:
'discord' => refused 'Discord' => ALLOWED 'DISCORD' => ALLOWED
An attestation bypass on a string comparison.
UNDELIVERABLE PROMPTS THAT HUNG
#4 `_clarify_send_disposition` handled `failed` and `ambiguous` but not
`declined`, so a REFUSED clarify card fell through to wait_for_response and
blocked until clarify_timeout — indefinitely when configured non-positive.
A decline is more definitive than a failure, not less.
#5 The exec-approval decline branch returned quietly, which suppressed the text
fallback (right) but left the CENTRAL approval entry pending (wrong) — the
dangerous command stayed blocked until the approval timeout. My comment
claimed the registration was torn down; only RelayAdapter's private map was.
It now raises `_ExecApprovalDeclined`, which propagates to
`_await_gateway_decision`'s existing notify-failure path (drops the entry,
unblocks the tool). A dedicated type, re-raised past the local
`except Exception` that would otherwise have restored the leak.
#7 THE GAP THAT LET ALL OF THIS SHIP. Both caller-level suppressions were
unfalsifiable: deleting either branch left 36/38 tests green. The suites
drove `_approval_send_outcome` and `RelayAdapter` but never the real
TurnRunner / busy-session callers, so nothing observed whether a text send
FOLLOWED a decline — which is the whole property.
tests/gateway/test_decline_fallback_suppression.py drives both real callers
and records every send. Each decline case is paired with an ordinary-FAILURE
control, because without one a caller that never falls back would also pass.
MUTATIONS (all on production source, anchors count-checked, restored after)
#1 seal decline -> plain send KILLED
#1b seal drops raw_response KILLED
#2 nested ImportError -> authorized KILLED
#3 fronted set not normalized KILLED
#4 clarify declined branch removed KILLED
#5 approval decline returns not raises KILLED
#6 task_card drops raw_response KILLED
#7 slash-confirm suppression removed KILLED
#7's two were the reviewer's SURVIVORS (36/38 passing); both now die.
425 passed, zero failures.
* fix(relay): close the three round-4 blockers
Round 4 confirmed six of seven round-3 fixes and found three more. Each
reproduced before fixing, each mutation-checked after.
1. A NAMELESS ImportError still authorized. Last round I admitted it as
"absence" to protect the CLI/cron path. That reasoning was WRONG and the
interpreter says so:
import gateway.relay.nope -> ModuleNotFoundError, name="gateway.relay.nope"
import totally_absent_pkg -> ModuleNotFoundError, name="totally_absent_pkg"
Genuine absence is ALWAYS ModuleNotFoundError with `.name` set, so the
CLI/cron path never produces a bare ImportError and nothing legitimate was
being protected. A plain or nameless ImportError comes from an import hook
or a module that failed while initializing — an unattributable FAULT.
Now: absence is ModuleNotFoundError naming gateway / gateway.relay /
gateway.relay.egress; everything else refuses. Two existing tests raised a
bare ImportError to simulate absence and were corrected to the real shape.
2. SESSION ATTESTATION INVENTED IDS. `_session_ids` split every id on the first
colon to recover "chat" from "chat:thread". Matrix ids contain a colon
natively, so `!room:server.org` attested a bare `!room` — the guard
vouching for a destination on its own fabrication. The split now applies
only to platforms whose ids genuinely carry a `:thread` suffix (allow-list;
unknown platforms are treated as un-splittable, which can only refuse more).
Kept a Slack control: dropping the split entirely would refuse legitimate
thread replies, which is the outage the split exists to prevent.
3. THE TASK-CARD FIX WAS UNFALSIFIABLE — my own round-3 mistake, and the same
one round 3 caught me making. I added the production branch AND a test, but
the test stopped at RelayAdapter: it proved `raw_response` is carried and
never called `TurnRunner._task_card_publish`, which owns the property.
Deleting the real branch left 30 tests green. Now driven through the real
caller, with an ordinary-failure control.
The lesson generalises: proving the DATA reaches the boundary is not proving
the CALLER acts on it. Every one of these decline fixes has two halves and
the second half is where the security lives.
Also closed the round-4 non-blocking finding: `gateway/relay/egress.py` has its
OWN import boundary, and the existing test intercepted the earlier import in
tools/send_message_tool.py, so it was never exercised. Mutating that classifier
to treat every ImportError as absence now dies.
MUTATIONS (production source, anchors count-checked, restored after)
R4-1 nameless ImportError -> authorized KILLED
R4-2 session split unconditional KILLED
R4-3 task-card caller branch removed KILLED (was SURVIVED)
egress classifier: any ImportError = absence KILLED
Also probed and found NOT a leak: a refused OPENING draft frame disarms the
stream and the turn-final goes out via `send`. That send is itself guarded and
the connector refuses it too, so no content is delivered — unlike the seal case
(round 3, #1) where the seal was the only check on that path.
452 passed, zero failures.
* fix(relay): recover the thread parent from thread_id, not a colon split
Round 4 blocker 2 was closed with an allow-list of platforms whose ids have no
native colon. Reviewing my own fix while round 5 ran, the allow-list is the
wrong mechanism: it NARROWS a guess instead of removing it, and it still gets
Matrix wrong the moment a Matrix session is thread-qualified
(`!room:server.org:$thr` -> split yields `!room`).
The structured field was there all along. `_session_entry_id` composes the id
as f"{chat_id}:{thread_id}" and the entry still carries `thread_id`
separately, so the parent is knowable EXACTLY: strip the known suffix, or add
nothing. No platform list, no guessing, correct for ids that contain colons.
Mutations:
back to splitting on the first colon KILLED
thread parent never recovered (over-refuse) KILLED
Both directions matter: the first invents attestations, the second refuses
legitimate thread replies.
One existing test (M25) asserted the right PROPERTY with a fixture that omitted
`thread_id` — a shape real entries never have. Fixture corrected, assertions
untouched.
453 passed.
* fix(relay): close the four round-5 blockers
Each reproduced before fixing, each mutation-checked after.
R5-1 A DISABLED NATIVE ADAPTER BYPASSED AUTHORIZATION. `_has_live_native_adapter`
treated any entry in the adapter map as native; `resolve_delivery_transport`
ignores a native adapter whose config is disabled and routes over Relay.
Two independent routing classifiers, disagreeing:
guard says native: True delivery routes relay: True
So the guard skipped authorization for a send that went over the relay.
The guard now applies the router's enabled-state rule; probed both
configurations and they agree.
R5-2 THREAD IDS WERE NEVER AUTHORIZED. The parser splits chat_id and thread_id;
only chat_id reached the guard. On Discord the thread IS the destination —
`POST /channels/{thread_id}/messages` — so an attested parent channel
authorized an arbitrary caller-supplied thread. `authorize_relay_target`
now takes thread_id and requires its own attestation (bare id or the
`chat:thread` form a session origin produces); both call sites forward it.
R5-3 A DECLINED **INITIAL** DRAFT WAS RETRIED AS A PLAIN SEND. Round 3 fixed the
declined SEAL; the declined OPEN was a different path. `send_draft`
returned a bare failure, so the stream consumer read "draft transport
unusable", disabled drafts and fell through to `_first_send`. Measured
through the real adapter and real StreamTransportMixin:
before: ops ['draft', 'send'] after: ops ['draft']
send_draft now carries raw_response; a decline is terminal for the run and
the guard sits in `_first_send`, where every fallback path converges.
R5-4 MY ROUND-4 TASK-CARD FIX SUPPRESSED EXACTLY ONE UPDATE. It set
`native_failed`, which the entry gate already uses for an ordinary broken
lane, so the next progress event skipped the decline branch and went
straight to the text fallback:
after first publish: [] after second: ['send']
Terminal declines are now a separate `egress_declined` state checked at the
entry gate. A refusal does not expire after one tick.
MUTATIONS
R5-1 disabled native counts as native KILLED
R5-2 thread_id not authorized KILLED
R5-2b tool does not forward thread_id KILLED (was SURVIVED)
R5-3 initial-draft decline not terminal KILLED
R5-3b _first_send guard removed KILLED
R5-4 declined state not persistent KILLED
R5-2b is the same gap that produced findings 3 and 4 of the last two rounds, a
third time: every test called `authorize_relay_target` directly, so dropping the
argument from the TOOL WRAPPER changed nothing. Testing the callee never proves
the caller uses it — now pinned explicitly.
Each fix ships with an ordinary-failure control, because every one of these
makes the guard refuse MORE, and over-refusal is now the larger risk.
474 passed, zero failures.
* refactor(relay): declare the terminal-decline state where it lives
Both terminal-decline flags were set dynamically. They worked (neither class is
frozen or slotted) but an undeclared attribute hides the state from anyone
reading the class, and this one is security-relevant.
_TaskCardState.egress_declined — declared dataclass field
StreamConsumer._egress_declined — initialised in __init__
Lifetime verified while checking whether a refusal can leak ACROSS turns and
mute a healthy destination: it cannot. _TaskCardState is constructed per
progress-drain (run_turn_runner.py:420) and the consumer's flags per run
(stream_consumer.py:163), so both are fresh each turn.
Also verified the guard's blast radius after adding thread authorization: the
ONLY callers of authorize_relay_target are the two model-facing send_message
call sites. Gateway-internal sends — notably the handoff path, which creates a
thread and immediately posts to it with no session provenance yet — go through
transport.adapter directly and are unaffected. That was the most plausible
over-refusal, and it does not reach this guard.
461 passed.
* fix(relay): close the four round-6 blockers — the edit lane
R6-1 MY OWN R5-1 FIX REINTRODUCED THE BYPASS IT CLOSED. I wrote
`except Exception: return True` around the config lookup, so a config read
fault declared the platform native while the ROUTER, reading the real
config, sends over the relay:
guard_has_live_native True guard_verdict None router relay
Routing we cannot determine is UNKNOWN. It now raises RelayRouteUnknown,
which the outer handler must re-raise rather than flatten to False, and
`authorize_relay_target` turns into a refusal. This is the second time a
convenience `except` in this function created a bypass; there is now no
permissive return left in it.
R6-2/3/4 THE NINTH LANE: `edit`. ONE dropped field, THREE leaks.
`RelayAdapter.edit_message` discarded the connector response, and three
independent callers read a bare edit failure as "editing is unavailable"
and re-send the content as a NEW message to the same chat:
stream edit fallback ['edit', 'edit', 'send'] the unseen tail
queued reconciliation ['edit', 'send'] the WHOLE response
task-card fallback ['edit', 'send'] the task text again
Fixed at the source (edit_message carries raw_response) plus each caller:
`_on_edit_failure` — the single funnel for stream edit failures — makes a
decline terminal for the run, `_send_fallback_final` refuses to deliver a
continuation after one, the queued reconciler returns instead of sending,
and the task-card fallback sets the same terminal state R5-4 introduced.
R5-4 fixed the native task-card op and I did not check its sibling
fallback path. The pattern across rounds 3-6 is consistent: the fix goes
where the decline is OBSERVED, and the leak lives wherever someone else
later decides to retry.
MUTATIONS
R6-1 config fault -> assume native KILLED
R6-1b RelayRouteUnknown swallowed as False KILLED
R6-2 edit drops raw_response KILLED
R6-2b edit-failure decline not terminal KILLED
R6-3 queued reconcile falls back on decline KILLED
R6-4 task-card fallback edit decline KILLED
Each with an ordinary-failure control: a genuinely un-editable message must
still be delivered, and a broken card lane must still reach the user.
481 passed, zero failures.
* fix(relay): add a terminal-decline latch at the adapter choke point
THE STRUCTURAL FIX, not a twelfth local check.
Rounds 3-6 of review found ONE defect in eleven lanes: the connector refuses an
op, and some caller downstream reads that as 'this lane is unavailable' and
retries the same content through a DIFFERENT op against the SAME chat. Media,
prompt, draft-open, draft-seal, native task card, task-card fallback edit,
slash-confirm, exec-approval, clarify, stream edit, queued reconciliation.
Each was closed by adding a check at one more call site. That approach cannot
converge: gateway/ has ~60 outbound call sites, every one of them a place a
future change can reintroduce this, and four consecutive review rounds each
found another. The reviewer's own count of lanes is the argument against the
per-site design.
Every relay frame from every one of those callers passes through
_transport.send_outbound. One latch there covers them all: once the connector
refuses a chat, this adapter stops emitting CONTENT frames for that chat.
Proven to subsume the local checks: with the stream-edit per-site check
DISABLED, the leak probe still reports blocked=true — the frame never reaches
the wire. The local checks stay as defence in depth and for their better error
messages, but they are no longer the only thing standing between a decline and
a re-addressed send.
Scope is deliberately narrow, and each limit is mutation-pinned:
per CHAT - a refusal must not mute other conversations
CONTENT ops - typing/delete carry nothing; latching them would leave a
stuck typing indicator for no security gain
self-healing - cleared when the connector accepts that chat again, so a
transient policy change does not need a restart
Mutations:
latch never set KILLED
latch never consulted KILLED
latch is global, not per-chat KILLED
latch never clears KILLED
485 passed.
* fix(relay): one route source; the latch already covered round 7's lanes
Round 7 reviewed 573e41e294 — one commit BEFORE the terminal-decline latch —
and independently reached the same conclusion I had: 'The per-call-site
approach is structurally wrong. Use one turn-scoped choke point.' That is the
latch in 6dbc004594.
Its four 'still broken' lanes (tool-progress edit, progress-overflow edit,
long-running heartbeat edit, stale streamed-final reconciliation) all share the
shape edit_message->declined->adapter.send(same chat, same content), and NONE
has a local check. Probed all four against the latch:
tool_progress ops ['edit'] blocked
progress_overflow ops ['edit'] blocked
heartbeat ops ['edit'] blocked
stale_final ops ['edit'] blocked
That is the argument for the choke point, measured: lanes nobody patched are
safe anyway. Pinned by a parametrized test named for those four lanes.
R7-1 IS A REAL BYPASS THE LATCH DOES NOT COVER, and it is fixed here. The guard
rebuilt routing from GATEWAY_RELAY_PLATFORMS while resolve_delivery_transport
asks the CONNECTED adapter (fronts_platform, from the handshake identity set).
Different snapshots: with env discovery stale or momentarily empty, the guard
said 'native' and the router sent over the relay, skipping authorization.
before: guard_relay_routed False / delivery relay
after: guard_relay_routed True / delivery relay / unattested target refused
The guard now asks the live adapter first and falls back to config only when
there is no runner (CLI/cron) — pinned in both directions.
R7-5 (non-blocking, and a fair hit): my stream-fallback test asserted
_egress_declined and never drove _send_fallback_final, so removing that early
return SURVIVED. The test now calls the real fallback and asserts the wire is
untouched; the mutation dies.
Mutations:
R7-1 guard ignores the live adapter KILLED (was SURVIVED)
R7-5 fallback early return removed KILLED (was SURVIVED)
latch not consulted KILLED
491 passed.
* fix(relay): close three holes found by attacking my own latch
Round 8's brief told the reviewer to attack the latch. I did the same in
parallel and found three real holes in it before the review returned.
1. send_for_platform BYPASSED THE LATCH ENTIRELY. It builds and posts its frame
directly rather than through _outbound — and it is the delivery resolver's
OWN entry point, so it is the single most important caller.
before: ops ['edit', 'send'] after: ops ['edit']
gateway/AGENTS.md states the rule I had just broken: 'Seal-interception
exists at BOTH egress doors (send() and send_for_platform()); a new egress
door needs the same two checks.' The latch is a third such check and I had
wired it to one door.
2. A COSMETIC SUCCESS CLEARED THE LATCH. Clearing on ANY success meant a
typing indicator — routinely allowed for a chat whose content is refused —
re-opened the door for the very next send:
ops ['edit', 'typing', 'send']
Only a CONTENT op the connector accepted may clear it now.
3. A THREAD INSIDE A REFUSED CHAT WAS NOT COVERED. A thread lives inside its
parent, so the same content reached the same conversation one level down:
ops ['edit', 'send']
The latch key now strips the thread suffix.
Also normalised int/str chat ids (callers pass both; a type mismatch would
silently unlatch).
MUTATIONS
send_for_platform not latched KILLED
cosmetic success clears the latch KILLED
thread suffix not stripped KILLED
draft-seal retry not latched SURVIVED — EQUIVALENT, proven:
is unreachable while latched (a declined edit before the seal
produces ZERO seal frames, measured). Kept as defence in depth because it
posts directly, and documented at the site rather than covered by a
test that could not fail.
One self-inflicted bug on the way: a blanket replace put 1Password CLI brings 1Password to your terminal.
Turn on the 1Password app integration and sign in to get started. Run
'op signin --help' to learn more.
For more help, read our documentation:
https://www.1password.dev/cli
1Password CLI is built using open-source software. View our credits and
licenses:
https://downloads.1password.com/op/credits/stable/credits.html
Usage: op [command] [flags]
Management Commands:
account Manage your locally configured 1Password accounts
connect Manage Connect server instances and tokens in your 1Password account
document Perform CRUD operations on Document items in your vaults
events-api Manage Events API integrations in your 1Password account
group Manage the groups in your 1Password account
item Perform CRUD operations on the 1Password items in your vaults
plugin Manage the shell plugins you use to authenticate third-party CLIs
service-account Manage service accounts
user Manage users within this 1Password account
vault Manage permissions and perform CRUD operations on your 1Password vaults
Commands:
completion Generate shell completion information
inject Inject secrets into a config file
read Read a secret reference
run Pass secrets as environment variables to a process
signin Sign in to a 1Password account
signout Sign out of a 1Password account
update Check for and download updates.
whoami Get information about a signed-in account
Global Flags:
--account account Select the account to execute the command by account shorthand, sign-in address, account ID, or user ID. For a list
of available accounts, run 'op account list'. Can be set as the OP_ACCOUNT environment variable.
--cache Store and use cached information. Caching is enabled by default on UNIX-like systems. Caching is not available on
Windows. Options: true, false. Can also be set with the OP_CACHE environment variable. (default true)
--config directory Use this configuration directory.
--debug Enable debug mode. Can also be enabled by setting the OP_DEBUG environment variable to true.
--encoding type Use this character encoding type. Default: UTF-8. Supported: SHIFT_JIS, gbk.
--format string Use this output format. Can be 'human-readable' or 'json'. Can be set as the OP_FORMAT environment variable.
(default "human-readable")
-h, --help Get help for op.
--iso-timestamps Format timestamps according to ISO 8601 / RFC 3339. Can be set as the OP_ISO_TIMESTAMPS environment variable.
--no-color Print output without color.
--session token Authenticate with this session token. 1Password CLI outputs session tokens for successful 'op signin' commands when
1Password app integration is not enabled.
-v, --version version for op
Run 'op [command] --help' for more information on the command. into
send_for_platform, which has no such variable. Two existing unfurl tests caught
it — NameError at adapter.py:1407.
504 passed.
* fix(relay): Telegram handle exemption + a turn boundary for the latch
Round 8 blockers. Two of its four were already closed by 93750e351a (it
reviewed the commit before it); these two are real and both are mine.
B1 — THE TELEGRAM @HANDLE EXEMPTION COVERED A NATIVE SEND.
_is_unresolved_handle exempts telegram @handles from attestation because
"the connector resolves and authorizes it". That justification is FALSE
whenever the gateway holds its own token: _send_to_platform calls
_send_telegram(pconfig.token, ...) directly and no connector is involved.
So an unattested @handle went out under the gateway's own credential
while the numeric control was correctly refused.
The exemption now requires that no native credential exists. A probe
fault WITHDRAWS the exemption (falls back to the ordinary attestation
check) rather than granting it.
Shipped with the converse control: relay-only config still exempts
@handles, and numeric targets stay guarded in both modes.
B4 — THE LATCH HAD NO BOUNDARY, SO IT WAS AN OUTAGE MECHANISM.
My own regression, and worse than reported. Removing "clear on cosmetic
success" (correctly) removed the ONLY way the latch could ever clear: a
content op can never reach the connector to succeed, because the latch
blocks it locally first. A refusal at 09:00 muted that chat forever.
A new inbound message for a chat is the generation marker — the natural
teardown point. Suppression still holds for the whole turn.
same_turn_blocked: true next_turn_delivered: true
MUTATIONS (all killed)
handle exemption ignores native credential
native-credential fault GRANTS the exemption
no turn boundary (latch never clears)
teardown clears ALL chats not just this one
teardown ignores the chat
The last two SURVIVED first: I tested _clear_declined_for_turn directly
and never proved _on_inbound calls it — the caller-level gap that has now
produced four blockers on this branch. Added a test driving the real
inbound entry point.
One self-inflicted bug, caught by my own fault test: the probe imported
load_config, which does not exist (it is load_gateway_config), so it
always threw and returned the fault default. The test that pinned fault
behaviour is what exposed it.
510 passed.
* fix(relay): correct latch identity and boundary; one config snapshot
Round 9, four blockers, all reproduced.
B1+B4 — THE TEARDOWN WAS AT THE WRONG PLACE, twice over.
It sat on the adapter's raw _on_inbound, which runs BEFORE profile
routing, the ignored-channel guard, plugin hooks and user authorization.
An unauthorized or dropped event could therefore clear a refusal
belonging to an active turn, and stale content then went out as a
different op. The same placement missed Discord interaction passthrough,
which builds its own MessageEvent and calls handle_message directly, so
slash commands and modal submits stayed muted after an earlier decline.
Both are one mistake: I picked a lane instead of a boundary. Teardown now
runs immediately after _hm_admit_event, the single admission gate every
entry path shares.
dropped event -> latch survives, stale send blocked
admitted event -> latch clears
B2 — THE LATCH KEY SPLIT ON ':', WHICH IS A MISTAKE I ALREADY FIXED ONCE.
_latch_key did str(chat_id).split(":", 1)[0], so !room:tenant-a and
!room:tenant-b both keyed !room: a decline in one Matrix room muted
another, and inbound from one cleared the other's refusal. egress.py
::_session_ids stopped doing exactly this in round 4 and I reintroduced
it three rounds later.
Parent identity is never recoverable from identifier TEXT. Thread
coverage is now structural: _thread_parent looks the relationship up in
the recorded auto-thread map.
B3 — AUTHORIZATION AND DISPATCH USED DIFFERENT CONFIG SNAPSHOTS.
_handle_send retains one pconfig; the guard independently reloaded
config. Across a transition the authorization snapshot could see a
connector-only setup (exemption granted) while dispatch still held the
native token and sent the unattested @handle itself. The guard now takes
native_token from the SAME snapshot dispatch will use. A caller that
omits it does not silently look like "no token".
NB-1/2/3 also closed: real-object snapshot tests, an exception shield
that faces a real exception, and send_follow_up no longer discards the
connector's verdict (that discard is exactly how the edit lane laundered
declines).
MUTATIONS (all killed)
latch key splits on colon again
thread parent lookup disabled
dispatch token ignored by guard
tool drops the snapshot token
admission teardown removed
teardown moved BEFORE admission
exception shield removed
follow_up drops raw_response
"admission teardown removed" SURVIVED first: I had tested the helper, not
_handle_message. Added a test driving production _handle_message with
admission stubbed both ways. Fifth caller-level gap on this branch.
One self-inflicted bug caught before commit: I passed pconfig.token in
_handle_react, which has no pconfig — a NameError on every reaction.
516 passed.
* docs(relay): pin the latch's thread coverage limit as a deliberate trade
_thread_parent only sees connector auto-threads, and that map is capped at
256 entries, so a user-created or evicted thread does not inherit its
parent's latch. Documented at the site and asserted by a test, because the
alternative - deriving parents from identifier text - is exactly what muted
unrelated Matrix rooms in round 9.
The primary control is unaffected: authorize_relay_target takes thread_id as
part of the destination and attests it on every send (6 thread tests).
* refactor(relay): one SendResult decline classifier for all 8 gateway lanes
The extraction found a DEFECT, not just repetition.
Eight gateway lanes each hand-rolled the unwrapping of a decline from a
SendResult, and they did not agree. Six checked only raw_response. Two
also checked the error text. A connector that answers with the uniform
decline SENTENCE and no structured code - the documented contract for
older connectors, per _approval_send_outcome - was therefore classified
as an ordinary failure by those six lanes, so each treated a refusal as
"editing unavailable" and retried through another op.
Measured:
text-only decline six-site check False two-site check True
structured decline six-site check True two-site check True
No content leaked, because the adapter latch classifies the transport
dict directly and catches both shapes (verified: text-only decline still
latches C1 and keeps SECRET off the wire). The cost was wrong verdicts
and futile retries, not disclosure.
declined_send(result) in gateway/relay/egress.py now owns this. It checks
raw_response when structured, else the error text, and preserves the
ambiguous exclusion - an ambiguous result is a transport outcome, so it
must never read as a refusal.
run.py keeps its own shape deliberately: that lane has three verdicts
(ambiguous / declined / failed), so it checks ambiguous first and then
delegates the boolean.
MUTATIONS (all killed)
helper drops the text-only branch
helper drops the structured branch
ambiguous no longer excluded
draft lane decline check removed
edit-failure lane decline check removed
prompt verdict lane check removed
slash-confirm lane check removed
draft lane goes terminal on ANY failure (over-refusal direction)
"draft lane decline check removed" SURVIVED first: _send_draft_frame had
no test driving an unsuccessful send_draft at all. Added one, with an
ordinary-failure control so the fix cannot silently become "one flaky
frame mutes the chat". A non-unique anchor also masked the edit-failure
lane on the first pass - the trap my own skill warns about.
This closes the duplication that caused four of nine rounds of blockers:
a new lane now calls one classifier instead of copying three lines.
519 passed.
* fix(relay): latch identity, new-turn boundary, seal arming, ambiguity
Round 10, four blockers, each reproduced before fixing. Two are my own
regressions from the previous two rounds.
B1 - ADMISSION IS NOT A NEW-TURN BOUNDARY.
Round 9 moved teardown to just after _hm_admit_event. That is only an
ADMISSION gate: an authorized message can be steered into a running
session, answer a pending prompt, run a busy slash command, or be refused
by the pause/drain gates - all without starting a turn. Each of those
cleared the ACTIVE turn's refusal, and a later fallback from that turn
reached the wire (probe: latch emptied, wire ops ['edit', 'send']).
Teardown now runs after _claim_active_session_slot, the first point the
runner OWNS a new turn. The new test drives production _handle_message
through all four non-turn lanes plus the real new-turn path.
B2 - LATCH IDENTITY OMITTED THE LOGICAL PLATFORM.
One relay adapter fronts several platforms, so native ids collide. A
Discord refusal for chat 42 was cleared by clear_egress_latch("telegram",
"42") - the method took a platform and ignored it - and the Discord
fallback then reached the connector. Keyed by normalized platform plus
exact chat id; thread-parent expansion keeps the platform component.
B3 - THE DIRECT DRAFT-SEAL PATH DID NOT ARM THE LATCH.
_seal_open_draft posts through _attempt directly rather than _outbound,
so a definite decline logged and returned but never latched. The
immediate plain-send fallback was suppressed by the caller's own check;
later same-turn sends were not (wire ['draft', 'draft', 'send'], the
third frame carrying refused content).
B4 - MY OWN REFACTOR MADE AMBIGUOUS RESULTS TERMINAL.
send_draft's ambiguous projection discarded raw_response, so
declined_send fell through to the error-text branch - and an ambiguous
result whose text carries the decline marker ("... egress declined: ack
lost") read as a DEFINITE refusal and terminated the run. Ambiguous means
the frame may well have been delivered: a transport outcome, never an
authorization one.
Fixed on both layers: the projection carries the body (and the seal's
ambiguous return is now explicit too), and declined_send's text-only
branch - which cannot see the ambiguous flag - treats ack-lost text as
transport ambiguity. Audited every SendResult projection in adapter.py
for the same shape.
MUTATIONS (all killed)
latch key drops the platform
clear_egress_latch ignores platform
draft seal does not arm the latch
ambiguous projection drops raw body
declined_send infers decline from ack-lost text
teardown back at admission
523 passed.
* refactor(relay): split the terminal-decline latch out of the guard PR
The latch moves to feat/p5-egress-decline-latch (pushed at 3cf45736d7,
which retains the full history) for redesign. This PR keeps the
authorization guard and the per-site decline checks.
WHY. Across eleven review rounds the two halves behaved very differently.
The guard is a PURE FUNCTION of the destination - its blockers were all
"you asked the wrong question" (case sensitivity, nested ImportError,
missing thread_id, config snapshot skew), each a one-line correction that
then stayed fixed. Rounds 7-10 found nothing new in it.
The latch is MUTABLE STATE WITH A LIFETIME living on RelayAdapter - an
object registered once per process that holds the WebSocket and has no
concept of a turn. Nine of its blockers reduce to three questions the
adapter cannot answer: when does it end, who arms it, what is it keyed
on. Every answer so far has been a proxy (a successful op, an inbound
message, an admitted event, a claimed session slot) and every proxy was
wrong in a lane found later.
The per-site checks hold identical information on `st` - a PER-TURN
object - and have produced zero blockers, because the state dies with the
turn and nobody has to decide when it ends.
The no-relaunder property does NOT depend on the latch. Measured on the
real consumer path with the latch absent: a declined draft frame sets
_egress_declined and puts nothing on the wire.
Removal verified structurally rather than by eye: an AST diff of every
symbol between HEAD and this tree reports only latch symbols gone,
nothing added. That check caught two over-deletions my strip made -
_on_inbound (consumed by a "next def" boundary) and _SEEN_INBOUND_MAX
(a class constant inside the removed span). Both restored; 19 failures
went to 0.
ALSO: RESTORED A TEST I WRONGLY REPORTED AS PASSING.
test_tool_guard_forwards_thread_id never made it into the repo - `git log
-S` finds it in no commit - though round 5 recorded its mutant as killed.
Dropping thread_id from the guard call therefore survived the entire
tests/tools suite (146 passed). Written properly this time, driving the
real _handle_send far enough to reach the guard. It now KILLS that
mutant.
MUTATIONS on this tree
guard fault authorizes instead of refusing KILLED
thread_id dropped from the guard call KILLED (was SURVIVED)
handle exemption ignores native credential KILLED
draft lane decline check removed KILLED
prompt verdict lane check removed KILLED
slash-confirm lane check removed KILLED
503 passed.
* test(relay): close the phantom-coverage gaps the guard audit found
The thread_id test that was reported as killing a round-5 mutant turned
out never to have been committed. That is a reason to distrust the other
claimed kills, so I re-ran every guard mutation against the COMMITTED
tree instead of trusting the earlier reports.
Result: 9 of 11 killed, and the two "SKIPPED" ones had non-unique
anchors hiding SIX separate sites. Mutating those individually found
three real survivors.
CASE NORMALISATION (round 3, finding 3) WAS HALF-COVERED.
test_relay_fronted_matching_is_case_insensitive varies the CONFIGURED
name but always requests lowercase "discord", so it pins _relay_fronted's
normalisation and nothing else. The REQUESTED name's `.lower()` was
covered by nothing at all. Probe with it removed:
relay_routed("Discord") -> False
authorize("Discord", unattested) -> AUTHORIZED
which is exactly the bypass round 3 reported, alive again and untested.
Two further sites were untested in the OVER-REFUSAL direction: the
attested store is keyed lowercase, so a mixed-case request missed its own
attested set and refused legitimate traffic. attested_relay_targets' own
normalisation was invisible to every existing test because they all
monkeypatch that function away; it is now asserted against the real
function with only its leaf sources stubbed.
Three tests added. All six case sites now die when mutated.
I also re-did the three fail-closed RelayRouteUnknown mutations properly.
The first pass swapped whole lines and produced IndentationErrors, so
"KILLED" there proved nothing but a syntax error. Neutralising each raise
at correct indentation: all three genuinely KILLED.
FINAL AUDIT ON THIS TREE — 17 mutations, zero survivors
guard: thread_id dropped at the call site
guard: react path unguarded
guard: handle exemption ignores native credential
guard: 3x fail-closed raise neutralised
guard: 6x case-normalisation site
classifier: ambiguous treated as a decline
classifier: text-only decline branch removed
lane: draft / stream-edit / prompt / slash-confirm checks removed
511 passed.
* test(relay): make the stream-edit test fail for the right reason
Review of 45835a282d raised one blocking issue and three non-blocking
ones. All four are addressed; none was a production defect.
BLOCKING — the stream-edit test failed on the double, not on a leak.
test_declined_stream_edit_does_not_send_the_unseen_tail implemented only
the GUARDED path in its consumer double. Removing either guard therefore
raised AttributeError inside the fake before any send could be observed:
guard 1 removed -> AttributeError: no attribute '_is_flood_error'
guard 2 removed -> AttributeError: no attribute '_clean_for_display'
Red, but for the wrong reason — the test could not have caught the leak
it is named for. My own docstring claimed it drove the fallback and
checked the wire; it did neither.
The double now implements everything the UNGUARDED path reaches
(_is_flood_error, _flood_strikes, _current_edit_interval, _last_edit_time,
_notify_new_message, _try_strip_cursor, _clean_for_display,
_fallback_prefix, _metadata_for_send). Both mutations now fail on real
assertions:
guard 1 removed -> assert consumer._egress_declined is True
guard 2 removed -> AssertionError: the unseen tail reached the wire:
['send']
NON-BLOCKING 1 — a docstring claimed more than the test exercises.
test_requested_platform_name_is_also_normalised described a mixed-case
send_message(target="Discord:999") bypass. That entry point cannot reach
it: _resolve_tool_target lowercases the platform at
tools/send_message_tool.py:47 before the guard runs. The test still pins
a real contract — the helpers must not assume a lowercased argument, for
the gateway lanes and any future non-normalising caller — so the claim is
narrowed to that rather than the test removed.
NON-BLOCKING 2 — the module docstring said "every lane drives the REAL
RelayAdapter". The stream tests drive mixin doubles by design, because
the behaviour under test belongs to the adapter's CALLER. Docstring now
distinguishes the two kinds.
NON-BLOCKING 3 — latch-deletion residue in gateway/relay/adapter.py:418:
return None
return latched if surface_declines else None
The second line was unreachable and referenced a name deleted with the
latch. Removed, along with the 20-line comment block describing the latch
as "the structural fix" — that mechanism now lives on
feat/p5-egress-decline-latch, not here.
The reviewer independently confirmed the large deletion: an AST census
between 3cf45736d7 and f57a2298fa reports only latch symbols removed and
nothing added.
511 passed.
* docs(relay): correct three claims that outran the code
Review of 41ce3cc765 found no new production defect but three overstated
claims, one of them in my own commit message.
1. THE LATCH COMMENTARY WAS STILL THERE. My previous commit message said
it removed "the 20-line comment block describing the latch as the
structural fix". It removed only the unreachable statement. Twenty
lines at adapter.py:361-380 still described a per-chat latch, a choke
point and its scope rules - none of which exist on this branch. In a
refusal-sensitive module that reads as coverage this branch does not
have. Now removed for real.
This is the same defect class as the tests: a claim that outran what
the code does. I made it while fixing that class.
2. THE STREAM-TEST DOCSTRING OVERSTATED BOTH MUTANTS. It said the
mutation "now fails on the assertion that a send reached the wire" -
true of one guard, not both. Verified separately:
remove the _on_edit_failure check -> dies on _egress_declined,
never reaches the fallback
remove the fallback early return -> dies on the wire: ['send']
Both are valid behavioural failures, which is what the blocker asked
for; they are different observables and the docstring now says so.
3. Duplicate `from types import SimpleNamespace` from an earlier scripted
insert; imports reordered.
112 tests pass in the four focused files.
* fix(relay): close two authorization defects found in review
Both were reproduced before fixing and both mutants are pinned.
1. A LIVE relay adapter whose fronts_platform() raised degraded into the
config fallback. `_live_relay_fronted` returned None for every failure,
and None means "no live adapter, use the config snapshot" — so a faulting
adapter plus an empty/stale snapshot made the guard conclude "not
relay-routed" and authorize an unattested destination, while
resolve_delivery_transport asks that same adapter and still routes over
the relay. Measured: relay_routed=False, verdict None for chat 999.
Absence and fault now have separate return values: None only when there
is no runner or no relay adapter; a live adapter that cannot answer
raises RelayRouteUnknown. This is the third instance of this bug class in
this file, and the first two were also mine.
2. An attested chat whose id equalled the requested THREAD id vouched for
that thread. The `thread in attested` arm proved nothing about parentage.
Measured: attested {"-100A", "7"} authorized (-100A, thread 7).
Only the bound `parent:thread` form is accepted now. Nothing legitimate
needed the bare arm — _session_entry_id records a threaded origin as
f"{chat_id}:{thread_id}", and a thread addressed as its own channel
arrives as chat_id and passes the parent check.
The existing test blessed the bare form via parametrize, so it PINNED the
defect. Corrected, plus negative controls for the sibling-chat and
other-parent cases and a positive control proving genuine absence still
takes the config path (otherwise fix 1 would break native-only deploys).
Merged origin/main (was 22 behind). 428 passed via scripts/run_tests.sh;
full 10-row mutation ledger re-killed on the merged tree, none dying on an
exception rather than an assertion.
* fix(relay): only a missing adapter is absence; everything else is a fault
Reviewer BLOCKER, reproduced before fixing. Two more paths where a PRESENT
relay adapter still degraded into the config snapshot:
1. `fronts_platform` may be a property or descriptor, so the ATTRIBUTE
LOOKUP can raise — and the lookup sat inside the absence handler. Probed
with a raising property plus an empty snapshot: live=None, routed=False,
verdict=None, i.e. an unattested target authorized. The previous test made
an already-retrieved METHOD raise, so it could not reach this.
2. A present adapter with no usable `fronts_platform` returned None for the
same reason. An adapter that cannot say what it fronts is broken, not
absent, so it now raises too.
Also found by my own spot-check while the review ran: the nested imports of
`gateway.config` / `gateway.run` inside the live probe shared the broad
handler, so a broken installation degraded to the snapshot as well. Probed
with a healthy-adapter positive control in the same run — healthy refused
the unattested target, faulted authorized it. `_relay_fronted` one function
below already drew this exact distinction for its own import.
The boundary is now: `relay is None` is the ONLY absence. Everything about a
present adapter — attribute access, callability, the call itself, and the
imports needed to reach it — is a fault and raises RelayRouteUnknown.
This is the fourth variant of absence-vs-fault in this file and all four
were mine. The lesson is in the code as a comment rather than in a commit
message nobody re-reads.
Four controls keep genuine absence benign: no runner, no relay adapter in
the runner, a real ModuleNotFoundError naming the gateway package, and the
configured-attested-target-still-sends case.
434 passed via scripts/run_tests.sh; 9-row mutation ledger re-killed
including both new guards, none dying on an exception.
* fix(relay): invert the live probe to fail closed by default
Reviewer BLOCKER round 2, reproduced: reading the adapter registry can also
raise. A runner whose `adapters.get()` raised gave relay_present=True,
live=None, routed=False, verdict=None — unattested discord:999 authorized.
That was the FIFTH boundary in one function with the same defect: the call,
the attribute lookup, a non-callable attribute, the nested imports, and now
the registry lookup. Each round I patched the reported boundary and the
defect moved one statement up. The cause was the shape, not the statements:
the function asked "did something go wrong?" and answered None, and None
MEANS "no live adapter, use the config snapshot" — so every statement was a
new chance to fail open, and every new statement would have been too.
Inverted rather than patched a sixth time. Each `return None` now sits
behind an explicit narrow check that cannot itself be the fault (no runner,
no adapters, no relay key, gateway package genuinely absent), and one outer
handler turns anything else into RelayRouteUnknown. A statement added inside
this function is now fail-CLOSED by default.
Verified all six fault shapes raise (call, attribute, missing method,
registry .get, .adapters property, runner ref) and all five absence shapes
stay benign, plus a liveness control where the config snapshot disagrees
with a healthy adapter and the adapter still wins.
Four new tests, including the two absence controls that keep native-only and
CLI deployments working. 438 passed via scripts/run_tests.sh. Mutation
ledger: 8 killed. One survivor recorded as a proven equivalent mutant —
widening `if not registry` to `or {}` is behaviourally identical because
`{}.get()` returns None, i.e. the same absence; it is a readability guard.
|
||
|
|
5280fe9987 |
fix: cron and local DMs reach an open Desktop Bot Chat
Route local producers to durable owner ingress before attempting the unowned CLI lane. Preserve per-run/per-message IDs and receipt-first retry handling; never fall back after ambiguous admission. Report cron admission as queued, not completed or failed, in job status, the execution ledger and CLI/tool UX. Native isolated Electron validation reproduces SESSION_NOT_OWNED on main for both idle and busy owners. Fixed owner consumes idle cron, busy cron, local DM and mounted-chat cron exactly once, keeps its lease, yields to queued human input, and preserves the prior model-request prefix and tool schema. Inference alone used a deterministic loopback wire stub; no paid model call. |
||
|
|
db96c8ced7 |
fix: admit Bot Chat deliveries through the live session owner
Adapt FalconOrtiz's owner-mailbox proposal from #101564 onto the current notification poller and topical modules. The durable mailbox is cross-process ingress only: the existing owner admits its normal prompt turn after the current turn and human FIFO clear. Retain immutable receipts, stable admission identities, capability and lease fencing, compression lineage, and disable blind recovery replay of imported turns. The original stale server hunks and expiring receipt protocol were rebuilt rather than cherry-picked: the current facade decomposition and durable busy admission contract differ. Credit the earlier owner-mailbox work in #100544 and durable producer work in #100319. Co-authored-by: fangliquanflq <fangliquan@qq.com> Co-authored-by: 686f6c61 <github@00b.tech> |