Skip to content

AgentOS 2026.9.6

Choose a tag to compare

@github-actions github-actions released this 06 Sep 09:56
· 183 commits to main since this release
0ed6c5c

A contributor-fix release. Thirteen community PRs, no new surface — this one is
mostly about MCP: two transports that could not talk to a compliant server at
all, a third that leaked its connection on every close, and a bridge that
forwarded whatever a model handed it straight through to SQLite.

MCP transports that never worked against a compliant server

The stdio client was speaking the wrong protocol. MCPStdioClient wrote
Content-Length: N\r\n\r\n<body> to the server's stdin — LSP framing — and
rejected any reply that did not carry the same header with Missing Content-Length header in response. MCP stdio frames on newlines. No
spec-compliant server could be used at all, the reference
@modelcontextprotocol/server-* implementations included.

Requests now go out as one compact JSON-RPC object per line, and a reply is read
to the newline delimiter and matched to its request id — so a server that
interleaves notifications/message or notifications/tools/list_changed with
its replies is followed, rather than having the notification reported as the
result of whichever request was in flight. The short-read guarantees are kept: a
message split across pipe writes is reassembled instead of truncated, and EOF
before the delimiter raises a clear error instead of reaching json.loads as a
partial line. A message past the 64 KiB asyncio pipe buffer — a large tool
result, or tools/list from a large catalog — is read whole where readline
would have raised and discarded it, and a server that never sends a delimiter is
cut off at 16 MiB rather than buffered without bound. (#894)

The legacy sse transport had the 2024-11-05 HTTP+SSE lifecycle inverted.
MCPSSEClient.connect() POSTed initialize before any stream existed, sent it
to a guessed /message path, then opened a fresh GET per request. A compliant
server picks its own message URI and announces it in an endpoint event on a
stream the client must open first — so it either rejected the handshake or
emitted the response into a window with nothing listening.

The client now drives mcp.client.sse, the sibling of the SDK transport the
Streamable HTTP client already uses: the stream opens first, the advertised
endpoint is resolved against the configured URL and is the only POST target, one
receive stream serves the connection with responses correlated by JSON-RPC id,
and close() cancels the reader task. An endpoint pointing at a different
origin is refused before anything is posted, and both channels dial through the
same connect-time SSRF guard as before — which matters more now that the server
chooses the POST target. The handshake is bounded by tool_timeout_seconds, so
a server that opens the stream and never advertises an endpoint fails instead of
hanging the caller. (#922)

Both SDK-backed HTTP transports were also leaking. The SDK transports and
ClientSession are built on anyio task groups, and an anyio cancel scope may
only be exited by the task that entered it — but nothing closed an MCP client
from the task that opened it: discover_and_register runs during boot or in an
RPC handler, while close_active_clients runs from gateway shutdown or a later
mcp.disconnect. Closing raised RuntimeError: Attempted to exit cancel scope in a different task, which close_active_clients swallows, leaking the stream
and its connection for the life of the process. The transport is now opened and
unwound in a task the client owns, in the shared MCPSessionClient base. (#922)

And the MCP bridge stopped trusting its caller. These arguments are chosen by a
model on every call: timeout_ms=3_600_000 held a tool call for an hour,
indistinguishable from a stuck gateway, and a negative conversations_list
limit reached SQLite as LIMIT -1 — which means no limit, and loaded every
session row. events_wait now caps timeout_ms at 5 minutes (applied before
the deadline is computed, so the cap reaches recv_event) and max_events at
10,000; conversations_list and messages_read — and therefore
transcript_export — clamp limit into 1..5000. Clamping is silent, so a
badly chosen argument degrades instead of surfacing a tool error. (#685)

Budgets, sessions and search

A concurrent subagent fan-out could overshoot a [budgets] ceiling by the width
of the fan-out. Spend is recorded by UsageTracker.add() only as a turn burns
tokens, so children dispatched at once by SubagentManager.spawn all read the
same pre-fan-out snapshot and all cleared the same limit — with
max_concurrent=5, five full turns past a ceiling the code documented as
bounded by one. UsageTracker.reserve_turn_budget() now checks and reserves in
a single synchronous call and books the hold against every ledger scope the
session bills to — session, gateway daily, agent daily, channel daily — and
TurnRunner._run_turn releases it in a finally, so success, error,
cancellation and an abandoned turn all hand the headroom straight back. The hold
is sized by the new budgets.turn_reservation key (default $0.25, 0 to opt
out), and because it lives only for its own turn it never shrinks a ceiling for
turns that run one after another. The re-check between iterations inside a turn
still weighs recorded spend only, so no turn is stopped by its own reservation.
(#823)

Deleting a session left three process-global stores holding its state.
sessions.delete (the Web UI "Delete Chat"), SessionManager.cap_entries(),
prune_stale() and the cron SessionReaper all went straight to
storage.delete_session(), orphaning entries in SpawnGroupTracker's
closed/woken sets, the Pilot router's per-session routing history, and the
per-parent spawn locks — on a gateway that stays up for weeks, one more leaked
entry per deleted or pruned session with nothing to bound the growth. Eviction
now lives in agentos.session.runtime_state, is idempotent, and runs on all of
them. sessions.delete also cancels and drains the session's active and queued
tasks first, so an in-flight turn handler cannot write to a session whose rows
are about to disappear, or repopulate the state just evicted. (#750)

Memory search stopped re-walking the workspace on every query.
MemorySyncManager.sync() lets a pending session delta bypass the clean-search
fast path, but it consumed that delta only when a session indexer was
configured. Session indexing is off by default, and with it off
_do_session_sync() is a successful no-op — so nothing ever cleared the delta,
and after a single message every later search took the slow path and walked the
workspace tree again, with no new messages and no file changes. The reset now
keys off whether the sync succeeded rather than whether an indexer exists.
(#956)

Security

Cron webhook delivery had a DNS-rebinding window the fetch-tool conversion left
open. validate_webhook_url resolves the hostname once and clears it; the plain
httpx.AsyncClient that followed resolved the same name again when it dialled,
so a short-TTL domain could answer with a public address for the check and
169.254.169.254 for the socket — handing the job id, job name and run summary
to the cloud metadata service. Delivery now uses
ssrf_guarded_client(validator=validate_metadata_only_address), which dials the
address it validated, on the first attempt and every retry_request retry. The
URL check stays in front of it for the legible invalid webhook URL message at
add time, and the metadata-only floor keeps localhost and LAN hooks (n8n and
friends) working. (#725)

The Telegram webhook compared its secret token with !=, which returns as soon
as two bytes differ — response latency then tracks how long a prefix matched,
letting an unauthenticated remote caller recover the configured token one byte
at a time and post forged updates into the channel. The header now goes through
hmac.compare_digest over UTF-8 bytes, with a missing header treated as an
empty candidate rather than skipping the comparison, the same guarantee
gateway/auth.py and channels/slack.py already give. (#962)

An approval could silently cover a stronger delete than the one you saw. Flags
were dropped during normalisation, so rm /tmp/logs and rm -rf /tmp/logs
shared a cache key: approving the first — a no-op on a directory, since plain
rm refuses it — let the second run without a prompt, and -rf had never
appeared on anything the user was shown. The key now carries a capability set —
recursive, parents, force — parsed from -r/-R/-f/--recursive/
--force (bundles, flags after the target, the -- terminator and the
abbreviations getopt_long accepts all handled) and from the Python spelling:
shutil.rmtree is recursive, os.removedirs is recursive and prunes empty
ancestors, os.remove, os.rmdir and Path.unlink are neither. A cached
approval satisfies a retry only when its capability set is a superset — so the
module's reason for existing is intact: rm X still covers os.remove("X"),
and rm -r X and rm -rf X both still cover shutil.rmtree("X"), without
re-prompting. /forget <path> clears every grade for the path. (#849)

robinhood-rwa-addresses passed --rpc-url straight into
urllib.request.urlopen under a blanket # noqa: S310 — and urlopen also
speaks file:, ftp: and data:, so --rpc-url file:///etc/hosts made the
process read and parse that path. In an agent workflow the endpoint can be
steered by model output, which turns an unchecked flag into arbitrary local
reads. A new validate_rpc_url() applies the same scheme allowlist the bundled
http_fetch script uses: main() rejects a bad scheme with a usage error and
exit 2 before any network or filesystem work, and _rpc_batch() re-checks at
the one call site that reaches urlopen, so no caller can route around it.
(#968)

Skills and printed commands

gmgn-holder-analysis prints usage instead of crashing. analyze.py read
sys.argv[1] and sys.argv[2] at import time with no length check, so
analyze.py on its own and analyze.py --help both died with an unhandled
IndexError traceback rather than saying what the script wants. It now answers
-h/--help on stdout with exit 0, and a missing token address or chain with
Usage: analyze.py <token_address> <chain> [zh|en] on stderr and exit 2 — the
guard its sibling gmgn-wallet-score already carried. (#957)

gmgn-wallet-score stops reporting a six-figure fantasy for a break-even
wallet. score.py floored the per-trade return with wallet_pct or 0.0001, but
wallet_pct is always a float, so or fired only on an exact 0.0 — a real
break-even wallet, or a dev wallet whose bought_cost is 0 and whose ROI the
API reports as 0. That 0.0001 then became the divisor in copy_7d = realized_profit * (copy_pct / wallet_pct), so a wallet that realised $800 on no
recorded cost printed as a $567K copy-trade gain, and one that lost $500
printed as a $567K profit — sign and magnitude both wrong, in the headline
number of the report. The floor is gone; the if wallet_pct else 0.0 guard
already next to it handles a genuine zero. (#971)

And two commands AgentOS printed or documented now work when followed.
docs/cli.md and README.product.md told users to run agentos config set gateway.port 18791; there is no [gateway] table — the listen port is
top-level port on GatewayConfig, which is extra = forbid — so the
copy-pasted line exited 1 with Key not found. Separately, channels add and
channels edit printed Verify: uv run agentos channels status <name> --json;
uv is not present on a pipx or pip install, both documented install methods,
so that hint exited 127 while the line directly above it already printed a plain
agentos gateway restart. A test now runs every agentos config set example in
both docs files through the CLI and fails if one does not exit 0. (#840, #835)

Install

curl -fsSL https://raw.githubusercontent.com/use-agent-os/agent-os/main/install.sh | bash

or uv tool install --python 3.12 "use-agent-os[recommended]==2026.9.6".

Contributors

Thanks to @iamhaniofficial — every fix in this release came from a community PR.

Full changelog: https://github.com/use-agent-os/agent-os/blob/v2026.9.6/CHANGELOG.md