Skip to content

AgentOS 2026.9.3

Latest

Choose a tag to compare

@github-actions github-actions released this 02 Sep 16:25
· 38 commits to main since this release
2e4d6ac

A release about output that never reached the screen, and addresses that were never checked against the chain.

The render was the model's decision, so it did not happen

Skills could already publish an artifact — a chart, and as of this release a card grid — and the model was supposed to call publish_artifact when one was written. Live-testing the card renderer produced the same outcome seven times across two models: the script ran, the payload was written to the workspace, and the answer came back as a hand-written markdown table. ~/.agentos/media/artifacts/ stayed empty. A render that only happens when the model feels like it is not a contract.

exec_command now honours the marker itself. When a command's own output carries a line that is exactly

publish_artifact path=<file> mime=application/vnd.agentos.<x>+json

the artifact is published and the marker is replaced with a note telling the model not to publish it again. Only the application/vnd.agentos. family auto-publishes, so ordinary command output cannot push a workspace file at the user — a plain file still needs a deliberate call. The marker must own its line, so prose mentioning it is inert; at most four publish per command, with the overflow reported rather than dropped; and publish_artifact's workspace containment is unchanged. The whole path is best-effort: a shell command never fails, and never loses its output, because a publish did not work out.

Removing the publish decision was not enough on its own — the model also skipped the step that writes the payload. Documenting it as required, then folding it into a flag shown in every example, both failed the same way. So both Robinhood skills now write their card on every run (<SYMBOL>.cards.json, marker on stderr so stdout stays pure JSON, --no-cards to opt out). The only arrangement that renders reliably is the one that needs no decision from the model at all. This also fixes the existing gmgn-token and gmgn-market chart artifacts, which had the same failure mode.

Cards, for the shape a table handles badly

application/vnd.agentos.cards+json joins the chart mime as a second AgentOS-native artifact. A skill publishes JSON and the transcript renders a responsive grid of record cards — an optional logo, a colour-coded status badge, per-field copy buttons — instead of a download chip.

This is the shape a markdown table gets wrong: a 42-character contract address forces the table into a horizontal scroll, while a card gives the address its own line next to a copy button. badgeTone accepts positive/warning/danger/neutral and falls back to neutral for anything else, so a skill can introduce a new status without waiting on a frontend release. At most 24 cards render; the remainder are counted and reported under the grid rather than dropped silently.

Every payload string reaches the DOM through textContent, never innerHTML, and logo is restricted to http(s) URLs — card fields carry on-chain metadata, which is attacker-controlled on a permissionless chain.

The subject mark is a ticker monogram drawn locally, not a logo. The console's CSP is img-src 'self' data: https://raw.githubusercontent.com, so a token-list CDN image is blocked outright and the card was quietly dropping the broken img and showing nothing. Widening the CSP would also mean every card render tells that CDN which tickers the user is researching, from their IP — a real leak on a finance surface, for decoration. The logo img is still attached and still takes over, but only on a real load; an error now leaves the monogram standing instead of an empty slot.

47 addresses that had no contract behind them

robinhood-rwa-addresses decided what counted as a genuine Stock Token from a name suffix in CoinGecko's list. That was wrong in both directions.

CoinGecko caps name at 60 characters, so long listings lost the • Robinhood Token marker mid-word and were dropped entirely — --query IBM returned no matches at all, as did VTI, XLK, CTSH and CRDO. In the other direction, 47 of the 238 entries the skill reported as verified Stock Tokens (JPM, MCD, DIS, UBER, ABNB, PYPL and others) have no contract deployed at the advertised address. The skill handed them out as usable addresses, and funds sent to one would be unrecoverable.

Discovery still ranks candidates from the token list, but the answer is now settled on chain. Every genuine Stock Token is a proxy pointing at Robinhood's shared EIP-1967 beacon 0xe10b6f6b275de231345c20d14ab812db62151b00, which a permissionless impersonator cannot forge. One batched JSON-RPC round-trip (https://rpc.mainnet.chain.robinhood.com, no key, ~0.5s) classifies each match as verified, not-deployed, not-a-stock-token or unverified, and a top-level warning carries the caveat. Undeployed listings are still returned — silently dropping them reads as "the skill is broken" — but are flagged and never presented as usable addresses. Following robinhood-chain-stocks, an unreachable node yields unverified rather than a negative verdict: a network fault is never reported as evidence that a token is fake.

Guards that stopped at the wrong boundary

  • The strict SSRF guard was weaker than the permissive one, for exactly one address. ssrf.py keeps a shared _METADATA_ADDRESSES set described as the non-negotiable floor, but only the permissive guard (assert_not_metadata_endpoint, used by http_request) consulted it. The stricter assert_address_allowed_for_fetch — used by web_fetch, the media image fetch, browser navigation and skill-dependency downloads — derived its coverage from is_private / is_loopback / is_link_local / is_reserved instead. Alibaba Cloud's 100.100.100.200 sits in CGNAT space (100.64.0.0/10), which Python classifies as none of those, so the strict guard allowed it while the permissive one blocked it: on an Alibaba ECS deployment, a URL the agent could be steered to fetch — directly, or by prompt injection from page content it reads — returned the instance RAM role credentials into the transcript. The metadata hostname check now runs in validate_http_url_for_fetch too, so a resolver answering metadata.google.internal cannot launder the request through a public-looking address. Fetch policy is a strict superset of the metadata-only policy again, and a parametrized test asserts that for every entry in the shared set — the invariant that was missing, rather than the single address that happened to break it.
  • MCP's HTTP transports had no SSRF guard at all. The SSE and Streamable HTTP transports built a bare httpx.AsyncClient from MCPServerConfig.url with no validation, so an MCP server entry pointed at 169.254.169.254 reached the cloud metadata endpoint and its instance credentials. The policy is the metadata-only floor rather than the full fetch policy, because http://localhost:PORT/mcp and LAN-hosted MCP servers are the normal configuration. The guard is installed as a connect-time network backend, not run once against the URL text, so the address that gets validated is the address that gets dialed — checking a URL and then handing it to a plain client leaves httpx to resolve the name a second time, which a short-TTL rebinding name answers differently. (#662)
  • Unsigned Slack webhooks were ingested. With no signing secret configured, _handle_webhook logged a warning and carried on: event_callback payloads were ingested and slash commands were enqueued, so any unauthenticated POST to the Events API endpoint could inject messages and commands into a session. Only interactive form payloads were turned away. It now fails closed — the url_verification handshake is still answered, since it only echoes a challenge and has no side effects, so an operator can pass Slack's endpoint check while wiring the secret up, and everything else gets a 401. (#674)
  • Slack signatures were verified over decoded text. The base string was assembled as f"v0:{timestamp}:{body.decode()}" and re-encoded, so any body whose bytes do not survive a UTF-8 round-trip was checked against a different byte sequence than the one Slack signed — and a body that fails to decode at all raised inside the verifier. The HMAC is now computed over the raw bytes, which are never decoded. (#680)
  • rm -rf / carried no sensitive prefix. The destructive-intent hard block matched a denylist of sensitive path prefixes, and the filesystem root matched none of them, so a whole-host wipe fell through to the ordinary approval flow — which /elevated bypass skips outright. Every spelling that resolves to or sweeps the top level is now covered: /, //, /., /.., /*, /*/*, /**, /?*, /.* and /[a-z]*. Globs naming a subset (/tmp*) are untouched, and root counts as sensitive only in the delete-intent scan — reading or listing / stays ordinary work. (#563)
  • agentos sessions export built its filename by replacing :. A session id is gateway-supplied text, and every other character reached Path() untouched — a / or a .. segment among them — so the export could land outside the directory the command ran in. It now goes through the shared _safe_archive_part, which also learned to strip leading dots so an id sanitizing to .. cannot name the parent. (#678)

Fixes

  • Cron schedules that restrict both day fields follow the POSIX OR rule. 0 0 1,15 * 5 means "the 1st, the 15th, or any Friday" — as it does in cron, croniter, and every scheduler users compare against — where AgentOS required a date to be both a 1st/15th and a Friday, silently killing such schedules for virtually the whole month. CronField now records whether a field was written as a bare *, since expanding * to the full value set made it indistinguishable from an explicit 1-31/0-6 at match time, and the rule applies only when neither day field is a wildcard. This also restores parity with the web UI's own "next runs" preview, which has always applied the OR rule — so the times it showed disagreed with when the job actually fired. (#660)
  • Channel HTTP retries cover every transient timeout. retry_request caught (ConnectError, ReadTimeout), but ConnectTimeout, WriteTimeout and PoolTimeout descend from TimeoutException — a sibling of ConnectError, not a subclass — so a DNS, TLS-handshake, upload or connection-pool stall on any Slack/Discord/Telegram/webhook call escaped the backoff and crashed the caller on the first attempt. Retry-After was parsed with a bare float(), so the HTTP-date form RFC 7231 permits turned a rate limit into a ValueError inside the retry loop; it now resolves as delay-seconds or HTTP-date, falls back to the computed backoff when unparseable, and is clamped to 300s so a provider cannot park a send for hours. The 429 branch also gained the attempt < max_retries guard the 5xx branch already had, so an exhausted rate limit returns the response — status, headers and provider error body intact — instead of raising a bare RuntimeError. (#642, #599). Telegram's own _api() retry path gets the matching treatment for ConnectTimeout and PoolTimeout; ReadTimeout stays out on purpose, because by then the request is in flight and re-sending a getUpdates long poll would double-poll it. (#651)
  • MemorySyncManager retries a file whose indexing failed. _do_file_sync() replaced _mtimes with the fresh scan before the index loop ran, so by the time store.index_file() raised, the failing path was already recorded as seen — the next watcher tick compared equal and the retry its docstring promised never happened. A transient SQLite lock or provider timeout on MEMORY.md therefore left searches running against a stale index until the file was modified again or the process restarted. Index failures now come back alongside the existing delete failures and are re-enqueued. (#638)
  • Email replies keep the thread root when only In-Reply-To is present. _merge_references read only References, so for the second message of a thread — where most clients send In-Reply-To alone — the parent id was discarded and the reply referenced only itself, breaking the conversation apart in Gmail, Outlook and Thunderbird. Both threading headers are now read by one parser that drops comments and accepts ids with or without angle brackets, and thread_key_for shares it, so the cache key and the reference chain cannot disagree about which message is the root. (#620)
  • IMAP folders with spaces can be polled. imap_folder was handed to imaplib verbatim, and imaplib does not quote mailbox arguments, so Sent Items — ordinary on Exchange/Outlook — went on the wire as two tokens and every poll failed with an opaque BAD [CLIENTBUG] Invalid syntax. The name is now emitted as an RFC 3501 quoted-string, and a name carrying a control character (a CR or LF would have ended the command line and run its tail as a second IMAP command) is refused at channel start rather than at poll time. (#618)
  • agentos_queue_depth decrements again when tasks leave the pending queue, instead of staying stuck at the peak enqueue value. (#668)
  • SubscriptionManager._message_subs drops empty sets on unsubscription and connection teardown, closing a slow memory leak on long-running gateways. (#609)
  • Provider content-moderation blocks classify as POLICY_REFUSAL again. _is_policy_refusal() held only generic phrasing, so the wording providers actually emit went unmatched: Azure OpenAI's canonical "triggering Azure OpenAI's content management policy" does not contain the adjacent words "content policy", the content_filter code and finish reason matched nothing, and Gemini's "blocked by safety" is not "safety policy". A refusal and a malformed request map to different recovery actions, so real policy blocks were sent down the wrong path. (#629)
  • OtlpTraceSink.flush() acquires the _flush_lock it always declared. Concurrent flushes — a write() batch trigger racing the periodic task — could post to the collector simultaneously, delivering spans out of order and, on failure, re-queueing the same events twice. An empty-queue fast path before the lock keeps the uncontended case allocation-free. (#672)
  • The image tool names a redirect that carries no Location header. _fetch_image_url follows redirects itself so every hop is re-validated against the SSRF guard; a 3xx with no Location closed the response and fell out of the loop, surfacing as httpx's generic redirect error — or a StreamClosed — rather than the dead-end hop that actually broke. (#616)
  • The Environment view's path strip shortens Windows paths again. shortPath split on / only, so C:\Users\<name>\.agentos\.env counted as a single segment and rendered untrimmed, overflowing the header strip it was written to keep short. (#590)
  • HTTP chat errors name the provider that failed. _provider_display_name mapped only a handful of kinds, so Azure, Bailian, Mistral, Groq, SiliconFlow, AIHubMix, MiniMax, BytePlus, Bankr, vLLM, LM Studio and OVMS all surfaced as a generic "Provider".

Thanks to @keyKQ, @iamhaniofficial, @Carlys17, @Tiktokaiagent, @kyveni and @bukeeastrey.