AgentOS 2026.9.9
A contributor-fix release. Twenty-six community PRs, no new surface — this one
splits cleanly into two themes: credentials the sandbox was not guarding, and
work that was quietly lost while the tool reported success.
Credentials the sandbox was not guarding
The sensitive-path denylist matched on directories. ~/.ssh, ~/.aws,
~/.kube and the rest were all prefixes, so a credential file sitting
anywhere else was invisible to it — ~/.git-credentials, ~/.pgpass,
~/.dockercfg, ~/.htpasswd and ~/_netrc reached the executor unguarded,
past a block that is meant to survive user approval.
Those names now derive from the credential-file list in redact.py — the
module that already knows which files carry secrets — with a guard test
asserting the two stay in sync, so the next name added to the redactor cannot
silently miss the sandbox. They are blocked in the destructive-target gate and
the read gate alike, including with Windows separators.
One interaction was worth getting right rather than papering over:
sensitive_path_in_text now scans the expanded spelling before the raw
text. The unexpanded ${HOME}/.npmrc yields a bare /.npmrc, because } is a
token edge the same way $ is, and matching that tail would have reported
/.npmrc in place of the ~/.npmrc prefix the expansion actually resolves to —
breaking the ~/X / $HOME/X parity that #985 exists to protect. Both
spellings were blocked before and after; only the reported marker was at stake.
Nothing that was blocked before stopped being blocked. (#981)
Four prefixes join the list: ~/.config/gh, ~/.anthropic, ~/.openai and
~/.vault-token. ~/.config/gh/hosts.yml is the one that mattered — AgentOS
agents run gh routinely, so a live GitHub token sat in a path the sandbox did
not consider sensitive, immediately beside an entry that already protected
~/.npmrc. (#1138)
agentos skills update was not running the security scan that
agentos skills install runs. SkillInstaller.update() called
self.install(..., force=True), and force=True is precisely the flag that
bypasses the scan verdict — so updating a skill installed dangerous content
that a fresh install of the same skill would have refused. update() now
passes force=False. The reporting half went with it: _handle_skills_update
built its result from success, name and message and dropped the scan
object entirely, so a caller had no way to see why an update was refused. It
now carries scan_verdict and scan_findings when present, matching
_handle_skills_install, and the CLI prints the verdict. (#988)
web_search was the last path pulling remote text into the model's context
without the untrusted envelope. web_fetch.py, web.py and browser.py all
fence what they fetch; _search_payload passed title and snippet through
raw — and those two fields are written by whoever ranks for the query, so an
attacker who gets a page ranked for a query the agent runs landed unfenced text
next to the operator's own instructions. #688 shipped the source tag but not
this half. Each result's title and snippet now go through
wrap_untrusted_boundary, tagged with that result's own URL and falling back
to the provider tag and then the tool name. url and source are untouched,
so links stay clickable and the existing origin tag keeps working. (#1132)
Work that was quietly lost
Every bug in this section reported success.
git_commit(files=[]) committed the entire working tree. The branch was chosen
with if files:, and bool([]) is False, so an explicitly empty list took
the same path as an omitted argument and ran git add -A. A caller asking to
commit only what it had already staged got everything instead — including
untracked files it never named, which is how an untracked secret.txt sitting
beside the staged change ends up in the commit. Two different intents had been
collapsed into one branch by a truthiness test; the check is now
if files is None for git add -A and elif files for the named paths.
(#1203)
apply_patch was not atomic. _apply_ops wrote each operation to disk as it
iterated, so a patch whose second op failed left the first one committed. The
second half is the worse half: because the exception escapes apply_patch
before the bookkeeping at the end of the call, _record_workspace_file_writes,
_notify_memory_source_writes and _notify_bootstrap_source_writes were all
skipped — the filesystem was mutated while the runtime's view of it was not, so
artifact delivery and memory indexing silently disagreed with disk. A new
_plan_ops resolves every op and runs every hunk in memory before anything is
written, so the predictable failures — missing file, existing file, context
mismatch — are raised against a clean workspace. (#1169)
A prepend hunk landed in the wrong place. _parse_hunk_header explicitly
anticipates old_start == 0, so @@ -0,0 +1,N @@ is a supported input shape,
but _apply_hunk converted it with pos = hunk.old_start - 1 and the
resulting -1 made the splice resolve to
result[:-1] + new_lines + result[-1:] — the new lines were inserted before
the last line of the file. The two functions disagreed about the contract, and
the tool reported 1 file(s) modified and exited clean either way. pos is now
clamped with max(hunk.old_start - 1, 0). (#1166)
DiscordChannel.send_file uploaded empty files on retry. The file handle was
opened outside retry_request and the same object handed to every attempt —
but retry_request re-invokes its callable on 429, on 500/502/503/504 and on
ConnectError/TimeoutException. By then the first attempt has read the
stream to EOF, so httpx sent a 0-byte body, Discord stored an empty file, and
raise_for_status() saw the 200 for that empty upload. Nothing raised: silent
corruption, on the rate-limit path that is by far Discord's most likely retry
trigger. The body is now opened inside the retried callable, the way
SlackChannel.send_file already did it. (#1164)
The email channel addressed messages by IMAP sequence number. search, fetch
and store operate on numbers that shift whenever any other client — webmail,
a phone, another agent instance — expunges the monitored mailbox (RFC 3501
§2.3.1.2). An expunge mid-poll could make _fetch_one read the wrong message
and _mark_seen flag the wrong one, and flagging an oversized message shifted
the numbers for the rest of the batch. The poll loop now uses
uid("SEARCH", "UNSEEN") and the uid forms of fetch and store, so an
identifier stays bound to the message it named. (#1162)
Channels and shutdown that failed open
The Discord channel had bounded reconnect in its config and nowhere else.
DiscordChannelConfig declared reconnect_max_retries and
reconnect_base_delay_s, and nothing consumed them: the reconnects triggered
by ConnectionClosed, Op 7 (Reconnect) and Op 9 (Invalid Session) ran with no
exception handling and no delay, so one transient outage, bad resume URL or
handshake timeout raised out of _dispatch_loop and silently terminated
_dispatch_task. _connected was cleared only in stop(), so
is_connected() kept answering True for a channel that was completely deaf.
Both settings are now wired into _reconnect(), consecutive failures back off
as min(60.0, base_delay * 2 ** (failures - 1)) and reset on success, and a
done callback on the dispatch task marks the channel dead and cancels the
heartbeat so is_connected() reports the truth. (#1133)
Shutdown aborted partway through. try_acquire parks a bare object() in
self._tasks to make the cap check atomic — the # type: ignore[arg-type] on
that insert was the set knowingly violating its own set[asyncio.Task[Any]]
annotation — and cancel_all then called .cancel() on every member. The
token raised AttributeError: 'object' object has no attribute 'cancel'
partway through the loop, which killed both the remaining cancellations and the
gather, leaving real work running through a shutdown. _dispatch calls
try_acquire(_reservation_token) with a bare object(), so the path is live.
cancel_all now filters to isinstance(t, asyncio.Task). (#1172)
Fire-and-forget tasks could be collected mid-execution. The event loop keeps
only weak references to tasks, so anything spawned with asyncio.create_task()
and not held anywhere may be garbage collected before it finishes — including
Slack's interactive-approval dispatch in _handle_socket_frame and
_handle_webhook, which fired self._handle_slack_interactive(payload) and
dropped the handle. Each site now stores its task in a set and discards it from
a done callback, so an approval press cannot vanish between the button and the
handler. (#1033)
Telegram polling acknowledged a callback before running its handler, so a
transient handler failure permanently lost a button press. Polling now advances
the offset only after successful handling or deliberate discard, retries each
failed callback up to three times, deduplicates by callback ID, and after
exhaustion logs at error and advances so one permanently failing update cannot
stall the channel. (#1027)
Observability and performance
turn_cancellations_total never counted a cancellation. _reply_done read the
outcome as exc = t.exception() if not t.cancelled() else None — the guard is
needed, since exception() raises on a cancelled task, but it also sent a real
cancellation down the exc is None path, which emitted nothing. Delivery
exceptions were the only thing the counter ever saw. Both terminal outcomes now
record on the same counter with distinct reason labels —
reply_task_cancelled at info, reply_task_error at error with exc_info as
before — so an operator can tell an interrupted turn from a broken delivery.
The metric name is unchanged. (#1190)
_emit_metric swallowed its recording failures behind a bare pass, so a
metric that never reached the registry looked exactly like one that did — the
log line above says it was emitted either way. It is now logged at debug with
the metric name and the exception type and message. Debug rather than warning,
because this is an observability gap and must not add noise to a working turn:
record_metric already handles its own registry errors, so what actually
reaches this handler is the deferred import or the label build failing —
exactly the case where a counter silently stops and nothing says why. (#1188)
A gateway with no session backend answered NOT_FOUND. Every session handler
guarded ctx.session_manager with
raise KeyError("No session manager available"), and RpcRegistry.dispatch
maps KeyError to NOT_FOUND — a permanent, non-retryable verdict for a
condition that is neither permanent nor about the key, and indistinguishable
from a real lookup miss for any caller whose except KeyError: was written to
catch only the latter. The handlers now raise RpcUnavailableError, which is
already what rpc_chat, rpc_memory and rpc_sessions itself raise for this
shape. (#1192)
sessions.preview read every session's entire transcript to build a
120-character snippet — get_transcript(session_id, limit=-1) per session, so
previewing 50 long-running sessions deserialized 50 complete histories to look
at their tails. It now reads through get_recent_transcript: 10 entries first,
widened once to 50 when the tail is all tool traffic and holds no user or
assistant message to show, and stopped early once the window covers the whole
session. Storage without a get_recent_transcript keeps the full read rather
than losing the preview entirely, and the snippet itself is unchanged. (#1186)
Correctness
A DuckDuckGo outage read as a successful empty search. search() swallowed
every httpx.HTTPError and returned [] unless built with
diagnostics=True — and nothing set it: _search_provider_kwargs() singled
DuckDuckGo out to receive diagnostics=_active_search_diagnostics, which is
False on a default gateway, overriding the constructor default at the one
call site that mattered. Both layers move. The provider defaults to reporting
and classifies failures the way its Brave and Tavily siblings do (401/403
auth, 429 rate_limit, other statuses http, plus timeout and network,
each carrying status_code and retryable), and the tool boundary only ever
turns diagnostics on. (#1122)
bankr and openai_responses lost their OpenAI-compatible failure semantics.
Both declare failure_family="openai_compat" in provider/registry.py, but
neither appeared in the hand-kept _OPENAI_COMPAT_PROVIDERS literal in
provider/failures.py, so their 401, 402 and 429 fell through to UNKNOWN and
the runtime lost the signal it reads to choose FAIL_CONFIG or
FALLBACK_PROVIDER. Diffing the two files showed the drift was wider than the
report — six providers missing, not two — so the literal is now derived from
the registry's own failure_family field, with a test asserting the two files
agree in both directions. (#1126)
A local version label containing dev or post demoted a final release.
parse_version() captured +local into its own regex group but then
re-scanned the whole raw string for a bare .post / .dev segment, and the
optional delimiter let those patterns match inside the label: 2026.7.18+dev
parsed as .dev0 and 2026.7.18+postgres as .post0, so a released build
sorted as a pre-release and is_newer() inverted, producing spurious upgrade
notices. PEP 440 says a local label must not affect ordering. (#1130)
The router stopped matching C++, C# and .NET at sentence boundaries.
_CODE_TARGET_RE wrapped all three inside one \b(?:...)\b alternation, and
since + and # are non-word characters and .net opens with one, the group
silently required a word character on the wrong side — "to C++." never
matched, "to .NET" never matched after whitespace, while C++17 and
ASP.NET matched by accident. Inverting the assertion would only have swapped
which half broke; the three names are instead pulled out of the shared group
with a \b on their word-character edge alone. (#1198)
Rounding out: coroutines call asyncio.get_running_loop() rather than the
deprecated asyncio.get_event_loop(), which raises RuntimeError in a
background worker thread with no default loop (#1136); printed command hints
are quoted for the shell they will be pasted into, so a Windows config path
with a space stops coming back as --config 'C:\Program Files\...' that
neither cmd.exe nor PowerShell parses as one argument (#1180); RPC params are
validated without a bare assert that python -O strips out from under the
INVALID_REQUEST guarantee (#1194); events_wait re-clamps its recv_event
timeout every iteration, since two time.monotonic() reads inside one ~15ms
Windows tick round the remaining wait a hair above the cap; and
_accepts_keyword_arg — four copies giving three different answers when
inspect.signature raises — is unified in agentos.compat.inspect_utils on
the safe False default (#1201).
Install
uv tool install --python 3.12 "use-agent-os[recommended]==2026.9.9"
Full Changelog: v2026.9.7...v2026.9.9