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.pykeeps a shared_METADATA_ADDRESSESset described as the non-negotiable floor, but only the permissive guard (assert_not_metadata_endpoint, used byhttp_request) consulted it. The stricterassert_address_allowed_for_fetch— used byweb_fetch, the media image fetch, browser navigation and skill-dependency downloads — derived its coverage fromis_private/is_loopback/is_link_local/is_reservedinstead. Alibaba Cloud's100.100.100.200sits 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 invalidate_http_url_for_fetchtoo, so a resolver answeringmetadata.google.internalcannot 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.AsyncClientfromMCPServerConfig.urlwith no validation, so an MCP server entry pointed at169.254.169.254reached the cloud metadata endpoint and its instance credentials. The policy is the metadata-only floor rather than the full fetch policy, becausehttp://localhost:PORT/mcpand 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_webhooklogged a warning and carried on:event_callbackpayloads 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 — theurl_verificationhandshake 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 bypassskips 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 exportbuilt its filename by replacing:. A session id is gateway-supplied text, and every other character reachedPath()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 * 5means "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.CronFieldnow records whether a field was written as a bare*, since expanding*to the full value set made it indistinguishable from an explicit1-31/0-6at 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_requestcaught(ConnectError, ReadTimeout), butConnectTimeout,WriteTimeoutandPoolTimeoutdescend fromTimeoutException— a sibling ofConnectError, 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-Afterwas parsed with a barefloat(), so the HTTP-date form RFC 7231 permits turned a rate limit into aValueErrorinside 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 theattempt < max_retriesguard the 5xx branch already had, so an exhausted rate limit returns the response — status, headers and provider error body intact — instead of raising a bareRuntimeError. (#642, #599). Telegram's own_api()retry path gets the matching treatment forConnectTimeoutandPoolTimeout;ReadTimeoutstays out on purpose, because by then the request is in flight and re-sending agetUpdateslong poll would double-poll it. (#651) MemorySyncManagerretries a file whose indexing failed._do_file_sync()replaced_mtimeswith the fresh scan before the index loop ran, so by the timestore.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 onMEMORY.mdtherefore 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-Tois present._merge_referencesread onlyReferences, so for the second message of a thread — where most clients sendIn-Reply-Toalone — 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, andthread_key_forshares 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_folderwas handed toimaplibverbatim, andimaplibdoes not quote mailbox arguments, soSent Items— ordinary on Exchange/Outlook — went on the wire as two tokens and every poll failed with an opaqueBAD [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_depthdecrements again when tasks leave the pending queue, instead of staying stuck at the peak enqueue value. (#668)SubscriptionManager._message_subsdrops empty sets on unsubscription and connection teardown, closing a slow memory leak on long-running gateways. (#609)- Provider content-moderation blocks classify as
POLICY_REFUSALagain._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", thecontent_filtercode 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_lockit always declared. Concurrent flushes — awrite()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
Locationheader._fetch_image_urlfollows redirects itself so every hop is re-validated against the SSRF guard; a 3xx with noLocationclosed the response and fell out of the loop, surfacing as httpx's generic redirect error — or aStreamClosed— rather than the dead-end hop that actually broke. (#616) - The Environment view's path strip shortens Windows paths again.
shortPathsplit on/only, soC:\Users\<name>\.agentos\.envcounted 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_namemapped 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.