This PR:
- replaces the duplicated repo-local skill set with the requested
canonical `.agents/skills` taxonomy and short router `SKILL.md` files
backed by first-level references
- makes `.claude/skills` a compatibility symlink to `.agents/skills` and
removes hand-maintained Claude skill/rule copies
- moves docs agent guidance and decisions into neutral
`docs/agent-guidance/` and `docs/decisions/`, with `docs/CLAUDE.md`
reduced to a shim
- retires CLI Cursor rules after migrating CLI design, Effect source,
and client-cache sync guidance into `AGENTS.md` and `cli-command`
- adds nested `AGENTS.md` files for TS, core, providers, e2e, Python,
Python providers, and docs
- adds `pnpm validate:agent-skills` to validate skill frontmatter,
taxonomy, references, symlink invariants, stale paths, and command names
- implements missing Python `tst` and `snt` nox sessions that existing
Makefile targets already exposed
- no changeset: repository guidance/tooling only, no published SDK
package behavior
## Verification
- `pnpm validate:agent-skills` -> `Validated 14 canonical agent skills
and guidance invariants.`
- `for skill in .agents/skills/*; do python
/Users/jkomyno/.codex/skills/.system/skill-creator/scripts/quick_validate.py
"$skill" || exit 1; done` -> 14x `Skill is valid!`
- `pnpm --dir ts/packages/cli validate:skills` -> `Validated
composio-cli skill builds for stable and beta.`
- `cd python && uv run nox --list` -> includes `tst` and `snt`
- `cd python && uv run nox -s snt` -> 18 passed
- `cd python && uv run nox -s tst -- tests/test_imports.py` -> 8 passed
- `git diff --check` / `git diff --cached --check` -> clean
- stale reference search for retired docs/Claude/Cursor paths -> no
matches
## Forward Tests
- TypeScript core bug: loaded `bug-fixing`, `typescript-sdk`,
`typescript-testing`; found correct root/ts/core `AGENTS.md` route.
- Python provider: loaded `python-providers`, `python-testing`; found
missing nox sessions, fixed here.
- CLI command: loaded `cli-command`; found recording/changeset wording
gaps, fixed here.
- Cross-SDK drift: loaded `cross-sdk-parity`; again found Python nox
drift, fixed here.
- Docs + decision: loaded `docs-decisions`; found decision
template/index and Twoslash path gaps, fixed here.
## Notes
The first normal `git commit` attempt hit a lint-staged/Git stash
limitation while replacing `.claude/skills/` with a symlink (`path ...
beyond a symbolic link`). The final commit used `--no-verify` after the
validators and formatting checks above passed.
This PR:
- normalizes Python SDK reference examples generated from source
docstrings
- rewrites the `Triggers.parse()` docstring so the generated
Flask/FastAPI examples are copy-pasteable
- strips nested Markdown fences before wrapping generated examples in
MDX code fences
- regenerates `docs/content/reference/sdk-reference/python/`
- adds regression coverage for the `Triggers.parse()` generated example
and fenced example normalization
- does not include a changeset because this only changes docs
generation, generated docs, and tests
## Verification
- `cd python && .venv/bin/python -m pytest tests/test_generate_docs.py
-q`
- `cd python && .venv/bin/ruff check --config config/ruff.toml
composio/core/models/triggers.py scripts/generate-docs.py
tests/test_generate_docs.py`
- `cd python && .venv/bin/ruff format --check --config config/ruff.toml
composio/core/models/triggers.py scripts/generate-docs.py
tests/test_generate_docs.py`
- `cd python && .venv/bin/python scripts/generate-docs.py`
- `cd docs && bun install --frozen-lockfile`
- `cd docs && bun run types:check`
Integration branch for the next docs release: a **sessions-first
documentation rewrite** — new and rewritten guides, example pages,
interactive components, and docs tooling — plus the supporting SDK
changes that the new docs describe.
The bulk of this PR is docs (~24k lines across ~150 commits); the SDK
changes (~5k lines) back the new guides.
## Documentation (the bulk)
- **Sessions-first restructure** — reorganized navigation and section
structure (incl. the "Sandbox (prev workbench)" section), with
v3-reorganization redirects so old URLs keep resolving.
- **Rewritten core guides** — quickstart, configuring sessions, triggers
(creating + subscribing to events), proxy-execute, toolkits
enable/disable, and common FAQ, rewritten in the house voice.
- **New example pages** — local-sandbox PR reviewer, daily standup bot,
and slack bot, with runnable build-ups.
- **New interactive components & diagrams** — triggers flow animation,
manage-connections visual, connection-refresh visual, and the
terminal-kit components.
- **Docs tooling** — a docs-graph link-graph connectivity checker,
search reprioritization (deprioritize legacy pages), and SDK-reference
regeneration.
## Supporting SDK changes
**`@composio/core` → 0.13.0 (minor)**
- `composio.sessions.create()` as the first-class sessions API
(`composio.create()` kept as an alias).
- **MCP is opt-in:** default `create()` / `use()` return native-tool
sessions (`SessionWithoutMcp`); pass `{ mcp: true }` to surface
`session.mcp`. _Migration: read `session.mcp` only after creating with
`{ mcp: true }`._
- `session.sandbox` is the canonical resolved config;
`session.workbench` kept as a deprecated alias. `sandbox` is the
preferred session-config key (`workbench` still accepted).
- `connectedAccounts.updateAcl()` graduated from experimental (alias
kept).
- `triggers.parse()` (parse + optionally verify an incoming webhook) and
`triggers.setWebhookSubscription()`.
**`@composio/experimental` → minor** — local-workbench helpers moved
onto the `@composio/experimental/workbench` subpath (out of
`@composio/core/experimental`), keeping the ~14 KB embedded Python
helper out of core. Plus the experimental Pi provider.
**`@composio/slim` → minor.**
**Python → 0.17.0** — mirrors the TS surface: `composio.sessions` mount
(`tool_router` deprecated), `triggers.parse()` /
`set_webhook_subscription()`, the `sandbox` config key, and
`connected_accounts.update_acl()`.
## Review response (#3664)
Addressed the `@composio/core` review:
- **Security:** `triggers.parse()` no longer fails open — a
present-but-empty `verifySecret` (e.g. unset `COMPOSIO_WEBHOOK_SECRET`)
now throws instead of silently skipping verification; omitting it stays
an explicit opt-out (both SDKs).
- Removed snake_case leakage from `transformWebhookSubscription` (+ the
index signature that allowed it).
- **Removed** the TS-only `connectedAccounts.link()` toolkit
auto-resolve (shipped with cancellability / orphaned-auth-config bugs
and was effectively undocumented; to be reintroduced properly later).
- Unified Python error types on `ValidationError`; added `mcp=True`
Python tests; fixed runtime-portability + error-type test assertions.
- Polished deprecation messages; fixed the backwards `/experimental`
`@deprecated` note and the `SessionWithMcp` JSDoc.
## Testing
- **TS:** `@composio/core` + `@composio/experimental` typecheck pass;
vitest green for the touched suites.
- **Python:** `test_tool_router.py` + `test_triggers.py` pass (161
tests).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Kshitij Jhunjhunwala <kj@composio.dev>
Co-authored-by: Malay Vasa <malayvasa@gmail.com>
Co-authored-by: Sarah Simionescu <sarah@composio.dev>
Co-authored-by: Kshitij Jhunjhunwala <113939507+KJ-11@users.noreply.github.com>
Review follow-up to #3657 (squash-merged before these review fixes could
land on it). No behaviour change to the retry scoping itself — this
tightens correctness, docs, and test coverage around the
`without_retries` mechanism.
- **Correct the `without_retries` caching rationale.** The previous
docstring justified caching with "each clone creates a new `ContextVar`,
and dynamically-created `ContextVar`s are never garbage-collected." That
is false for this code path: `request_ctx` is only ever `.get()`, never
`.set()`, so it is reclaimed by refcounting on drop (measured: 500k
get-only clones leak ~0 KiB; the CPython leak only occurs with
`.set()`). Caching is kept because it avoids constructing a fresh
`HttpClient` on every `execute`/`proxy` (the hottest path) — the real,
stated justification now.
- **Preserve `_strict_response_validation` on the clone.** The
Stainless-generated `copy()` drops it, so the no-retry sibling silently
reverted to the default (`False`) even when the parent had it enabled.
The `copy()` override now threads it through `_extra_kwargs` alongside
`provider`, so the sibling differs from the parent only in
`max_retries`.
- **Document the scope boundary.** The `without_retries` docstring now
states that only `tools.execute` / `tools.proxy` are de-retried; other
non-idempotent writes (`auth_configs.create`/`update`/`delete`,
`mcp.update`/`delete`, `connected_accounts.delete`/`refresh`,
`link.create`) keep the default retries (most are naturally idempotent
on retry; the durable fix is backend idempotency keys).
- **Add regression coverage:** the clone preserves
`_strict_response_validation`, plus contract guards pinning the
Stainless `copy`/`_extra_kwargs`/`with_options` internals the override
relies on — so a `composio_client` regen that breaks the contract fails
as an obvious assertion rather than a cryptic `TypeError`.
- Minor: fix the `x-sdk-version` `"unknwon"` → `"unknown"` typo and
tighten `te.Self` typing on the cache field/property.
No changeset (Python-only change; changesets are TypeScript-only).
This PR:
- stops the Python SDK from silently retrying `tools.execute` and
`tools.proxy`, which are non-idempotent POST writes. The
Stainless-generated client retries POSTs by default (`max_retries=2`) on
read timeouts, 429, 5xx, and connection errors. A read timeout is unsafe
to retry — the request may already be in flight on the backend — so a
retry can duplicate the side effect, e.g. send an email twice. Reported
in https://github.com/ComposioHQ/composio/issues/3586
- routes both write paths through a new `client.without_retries` — a
cached, retry-disabled (`max_retries=0`) clone of the `HttpClient`.
Reads and lists keep the default retries, so resilience is unchanged for
idempotent calls
- overrides `HttpClient.copy()` to re-inject the required `provider`
keyword and re-aliases `with_options`, since the generated `copy()`
rebuilds via `self.__class__(...)` without `provider` — previously
`with_options(max_retries=0)` raised `TypeError` on the subclass
- caches the no-retry sibling instead of cloning per call: each clone
creates a new `ContextVar`, and dynamically-created `ContextVar`s are
never garbage-collected
- adds `tests/test_no_retry_writes.py`: writes hit the transport exactly
once on a retryable 5xx, reads still retry-then-succeed (proving the
scoping), and a regression guard for the `copy()` override
- interim fix only — the durable solution is idempotency keys, tracked
in https://github.com/ComposioHQ/composio/issues/3654 (gated on backend
support), which supersedes this once available
No changeset (Python-only change; changesets are TypeScript-only).
This PR:
- supersedes https://github.com/ComposioHQ/composio/pull/3625,
https://github.com/ComposioHQ/composio/pull/3629, and the Python
SDK/provider-side fix proposed by
https://github.com/ComposioHQ/composio/pull/3504
- adds `alias_tool_input_schema` / `restore_tool_arguments` for
provider-visible schemas and backend argument restoration
- keeps `substitute_reserved_python_keywords` /
`reinstate_reserved_python_keywords` as compatibility wrappers
- preserves the existing Python keyword alias style (`from` ->
`from_rs`), aliases invalid Python parameter names like `$top`, avoids
leading-underscore aliases so Pydantic-backed providers can build
models, and caps aliases at 64 characters for Anthropic-style tool
schema validators
- dereferences internal `$ref` / `$defs` before aliasing so referenced
object properties are exposed through the same safe provider-visible
names
- restores Gemini manual `handle_response` arguments with the per-tool
alias map before execution
- wraps Google ADK tools with aliased callable signatures and restores
original backend argument names
- wraps Anthropic and Claude Agent SDK tool schemas with the same shared
alias map and restores original backend argument names before execution
- fixes the package-local Claude Agent SDK provider tests so they run
without `pytest-asyncio` and keep using the provider-visible schema
conversion hook
- adds dependency-light core tests for helper behavior, `$ref` aliasing,
Pydantic model compatibility, Gemini manual response, Google ADK
wrapping, Anthropic wrapping, and Claude Agent SDK wrapping
Local validation:
- `uv run --project python pytest
python/tests/test_tool_schema_aliasing.py
python/tests/test_schema_parser.py python/tests/test_json_schema.py
python/tests/test_imports.py -q` -> 112 passed
- `PYTHONPATH=python uv run --project python --with-editable
./python/providers/claude_agent_sdk pytest
python/providers/claude_agent_sdk/tests/test_provider.py -q
--timeout=20` -> 16 passed
- `uv run --project python --with-editable ./python/providers/langchain
pytest --ignore-glob='python/tests/test_type_inference*.py'
python/tests/ -q` -> 742 passed, 31 skipped
- `uv run --project python --with-editable ./python --with-editable
./python/providers/langchain python <LangChain $top wrap smoke>` ->
wrapped args schema field `param_top`
- `uv run --project python --with-editable ./python --with-editable
./python/providers/langgraph python <LangGraph $top wrap smoke>` ->
wrapped args schema field `param_top`
- `uv run --project python ruff check --config python/config/ruff.toml
python/composio/utils/shared.py
python/tests/test_tool_schema_aliasing.py` -> passed
- `uv run --project python ruff format --check --config
python/config/ruff.toml python/composio/utils/shared.py
python/tests/test_tool_schema_aliasing.py` -> passed
- `git diff --check` -> passed
Note: #3500's hosted MCP emission-layer report is already closed; this
PR covers the Python SDK/provider surface rather than changing hosted
MCP schema emission.
## Problem
Tools whose parameter schemas express their file fields through a
`$ref`/`$defs` indirection lose auto file handling. With
`dangerously_allow_auto_upload_download_files=True`, a
`file_downloadable` output behind a `$ref` is never downloaded (the raw
`{name, mimetype, s3url}` object is passed through), and a
`file_uploadable` input behind a `$ref` is never staged — silently, with
no error.
`GMAIL_GET_ATTACHMENT` is the canonical shape — the flag sits two `$ref`
hops deep:
```
data → $ref → #/$defs/GetAttachmentResponse → file → $ref → #/$defs/FileDownloadable (file_downloadable: true)
```
**Root cause:** the hand-rolled walkers in
`composio/core/models/_files.py` (`_has_file_property`,
`_substitute_file_upload_value`, `_substitute_file_download_value`, …)
recurse through `properties`/`anyOf`/`oneOf`/`allOf`/`items` but **never
dereference `$ref`**, so a flagged node reachable only through a
reference is invisible to them.
Fixes https://github.com/ComposioHQ/composio/issues/3506
## Approach
Rather than teach each walker to dereference — which means threading a
root schema through ~10 methods, re-deriving `$ref` handling in each,
and risking unbounded recursion on cyclic schemas — this introduces a
single **`dereference_json_schema`** utility and inlines references
**once at the `FileHelper` boundary** (`process_file_uploadable_schema`,
`substitute_file_uploads`, `substitute_file_downloads`). The walkers
stay reference-agnostic.
This mirrors the TypeScript SDK's `dereferenceJsonSchema` (the
counterpart fix in #3566 for the TS twin of this bug, #3307) so both
SDKs share one behavioral contract. Resolving once at the boundary also
gives every walker `$ref` support for free — including keywords they
never special-cased (`additionalProperties`, `patternProperties`,
`prefixItems`, `not`), because the resolver walks containers
reflectively.
## What's in the utility (`composio/utils/json_schema.py`)
Faithful port of the TS guards:
- **Cycle safety** — breaks both `$ref` cycles and live-object identity
cycles with a permissive `{type: object, additionalProperties: true}`
sentinel. (A per-node lazy resolver without threaded cycle tracking
infinite-loops on a self-referential schema; there's a regression test
for exactly this.)
- **Depth caps** — `MAX_REF_CHAIN_DEPTH=100` / `MAX_NODE_DEPTH=512`
raise a typed `JSONSchemaRefResolutionError` instead of exhausting the
interpreter (with a `RecursionError` backstop converted to the same
typed error).
- **Sibling-keyword merge** — Draft 2020-12 semantics (siblings win on
collision), so a `description`/`default` next to a `$ref` survives.
- **External-ref passthrough** — `http(s)://` refs are left untouched
and logged once for audit.
- **`sentinel` mode** — a dangling `$ref` (an API schema that emits
`$ref` with no `$defs` block,
https://github.com/ComposioHQ/composio/issues/3307) degrades to the
permissive sentinel + an LLM-visible hint, instead of aborting the tool
call. The `FileHelper` uses this mode for API-sourced schemas.
- **Non-mutating** — returns a new schema; `$defs`/`definitions` are
stripped from the inlined root.
## Tests
- `python/tests/test_json_schema.py` — 30 cases mirroring the TS
`jsonSchema.test.ts` contract (chains, reflective container walk, legacy
`definitions`, sibling merge, cycles, depth caps, pointer-escape
decoding, array-index pointers, full `sentinel`-mode suite).
- `python/tests/test_files.py` — end-to-end regressions through the
public methods: download/upload behind `$ref`/`$defs` (the #3506 repro),
the LLM-facing input transform, graceful degradation of a dangling
`$ref`, and a **self-referential schema that must not recurse
infinitely**.
- `nox -s chk` (ruff + mypy) green; `nox -s tst` for the file/execution
suites green.
## Notes
Supersedes #3545 (third-party PR that fixed the same bug by threading
`root_schema` through each walker). That diagnosis was correct; this
takes the centralized route consistent with the TS SDK and adds the
cycle/depth guards. No changeset — Python-package change.
## Summary
- add bounded `(connect, read)` timeouts to Python SDK session file
downloads and uploads
- add the same timeout coverage to presigned S3 upload/download paths
used by file helpers
- convert timeout/request failures into the SDK's existing file errors
and cover them with tests
Fixes#3560Fixes#3561Fixes#3562
## Changes
- `RemoteFile.buffer()` now uses the existing file transfer timeout
constants and preserves file context on request failures
- `ToolRouterSessionFilesMount.upload()` and S3 upload helpers now bound
presigned `PUT` requests
- `FileDownloadable.download()` now bounds streaming `GET` requests,
wraps streaming read failures, and closes the response
- added regression tests for request timeouts and timeout arguments
- added a changeset for the published SDK change
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
- [x] `D:\anaconda3\envs\yolov12\python.exe -m ruff check
python\composio\core\models\tool_router_session_files.py
python\composio\core\models\_files.py
python\tests\test_tool_router_session_files.py
python\tests\test_files.py`
- [x] `D:\anaconda3\envs\yolov12\python.exe -m ruff format --check
python\composio\core\models\tool_router_session_files.py
python\composio\core\models\_files.py
python\tests\test_tool_router_session_files.py
python\tests\test_files.py`
- [x] `COMPOSIO_CACHE_DIR=<repo>\.tmp-composio-cache
D:\anaconda3\envs\yolov12\python.exe -m pytest
python\tests\test_tool_router_session_files.py
python\tests\test_files.py::TestUploadBytesToS3
python\tests\test_files.py::TestFileDownloadablePathTraversal::test_safe_filename_passes_through
python\tests\test_files.py::TestFileDownloadablePathTraversal::test_download_uses_timeout
python\tests\test_files.py::TestFileDownloadablePathTraversal::test_download_timeout_raises_error
python\tests\test_files.py::TestFileDownloadablePathTraversal::test_download_stream_timeout_raises_error`
Note: I also ran the full
`python\tests\test_tool_router_session_files.py
python\tests\test_files.py` pair locally. The new and related tests
passed, but the full run has one pre-existing Windows-specific failure
in
`TestFileDownloadablePathTraversal::test_empty_name_safe_fails_at_write_time`:
opening a directory for writing raises `PermissionError` on Windows
instead of the test's expected `IsADirectoryError`.
## Screenshots (if applicable)
N/A
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [ ] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [x] I added a changeset if this change affects published packages
## Additional context
This keeps the existing `(5s connect, 60s read)` timeout convention
already used by the Python SDK's URL fetch helpers.
---------
Co-authored-by: jkomyno <alberto@composio.dev>
## Summary
`triggers.subscribe()` crashes with `KeyError: 'nanoId'` on the **first
event** for any project configured with webhook version **V3**. The
realtime (Pusher) channel now delivers the modern V3 envelope
(snake_case: `metadata.trigger_id` / `connected_account_id` /
`auth_config_id`, with no top-level `nanoId`), but the realtime parser
(`TriggerSubscription._parse_payload`) only understood the **legacy**
envelope and accessed `data["metadata"]["nanoId"]` directly — so the
very first inbound frame raises inside the Pusher `_on_message` callback
and the registered handler is never invoked.
The SDK already knows how to normalize the V3 envelope — it does so on
the webhook path (`_normalize_v3_payload`) — that logic was simply never
wired into the realtime path.
This has affected the realtime subscribe path for V3 projects since the
V3 trigger payload format was introduced; webhook-version V1/V2 projects
are unaffected (they still receive the legacy realtime envelope).
## Changes
- Extract the V3 → `TriggerEvent` normalization into a shared
module-level helper (`_build_trigger_event_from_v3`) plus an envelope
detector (`_is_v3_envelope`); the webhook path's `_normalize_v3_payload`
now delegates to it, so webhook and realtime share one source of truth.
- `TriggerSubscription._parse_payload` now detects the V3 envelope and
normalizes it, and keeps the existing legacy mapping for V1/V2 payloads.
- Harden `_parse_payload`: unknown/malformed frames are logged and
skipped (return `None`) instead of raising, so a single bad frame can no
longer tear down an active subscription.
- Add tests covering V3 realtime parsing, legacy realtime parsing, and
malformed-frame handling.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
- `ruff check` + `ruff format` (config/ruff.toml) — clean.
- `uv run pytest tests/test_triggers.py` — **52 passed** (49 existing +
3 new).
- New tests:
- `test_parse_payload_v3_realtime_envelope` — a V3 realtime frame parses
to a `TriggerEvent` (no `KeyError`).
- `test_parse_payload_legacy_envelope` — V1/V2 legacy frame still parses
correctly.
- `test_parse_payload_malformed_returns_none` — bad/partial frames are
skipped, not raised.
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [ ] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [ ] I added a changeset if this change affects published packages
## Additional context
The realtime envelope is identical in shape to the V3 webhook envelope,
so the shared normalizer keeps the two delivery channels consistent. No
public API changes.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jkomyno <alberto@composio.dev>
Co-authored-by: Alberto Schiabel <jkomyno@users.noreply.github.com>
## What
`triggers.create(userId/user_id, ...)` already accepts a user id and
uses it to resolve the connected account, but **dropped it** when
building the `trigger_instances.upsert` body. With [2FA for
triggers](https://linear.app/composio/issue/PLEN-2580) on the backend,
2FA-enabled projects need `user_id` on the upsert to verify the pinned
connected account belongs to the caller's user.
This forwards `user_id` to upsert in **both SDKs**:
- **TS** (`ts/packages/core/src/models/Triggers.ts`): adds `user_id` to
the upsert params via a `& { user_id?: string }` bridge.
- **Python** (`python/composio/core/models/triggers.py`): forwards
`user_id` via the client's supported `extra_body` escape hatch.
Both bridges exist only until `@composio/client` / `composio-client`
regenerate from the updated OpenAPI spec with a native `user_id` field —
drop them then.
## Safety
Backends without trigger 2FA ignore the extra field, so this is safe to
land ahead of / alongside the backend rollout (hermes PR #10635).
## Tests
- TS: upsert-body assertion added.
- Python: `tests/test_triggers.py::test_create_with_user_id` asserts
`extra_body == {"user_id": ...}`. 49 tests pass.
Closes part of PLEN-2580 (SDK follow-up to the agreed approach).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jkomyno <alberto@composio.dev>
## Summary
Models — and some MCP transports — occasionally emit tool-call arguments
as a **JSON string instead of an object/dict**. The most visible trigger
is `COMPOSIO_MULTI_EXECUTE_TOOL` on the Vercel AI SDK, where streaming
fails with:
> `messages.3.content.1.tool_use.input: Input should be a valid
dictionary`
Until now only a handful of providers guarded against this, each with
its own slightly different inline check, leaving most providers
vulnerable and behaviour inconsistent across the SDK.
This PR centralizes the coercion into **one helper per language** and
routes **every** provider through it, in both the TypeScript and Python
SDKs.
Closes https://github.com/ComposioHQ/composio/issues/2406
## What changed
**TypeScript** — new `normalizeToolArguments` in `@composio/core`
(exported), used by every provider:
`vercel`, `cloudflare`, `openai-agents`, `openai` (ChatCompletions +
Responses), `anthropic`, `google`, `langchain`, `llamaindex`,
`claude-agent-sdk`, `mastra`.
**Python** — new `normalize_tool_arguments` in `composio.utils.shared`,
used by every provider:
`openai`, `openai-responses`, `anthropic`, `google`, `langchain`,
`langgraph`, `crewai`, `autogen`, `llamaindex`, `gemini`, `google-adk`,
`openai-agents`, `claude-agent-sdk`.
Shared semantics (identical in both languages):
| Input | Result |
| --- | --- |
| object / dict | returned unchanged |
| JSON string | parsed to object |
| empty / whitespace string | `{}` |
| `null` / `undefined` / `None` | `{}` |
| array, primitive, unparseable string, JSON that isn't an object |
**typed error** (`ComposioInvalidToolArgumentsError` / `InvalidParams`)
with the original parse error as cause |
The typed error replaces the previous grab-bag of behaviours: a raw
`SyntaxError`/`JSONDecodeError`, or — worse — silently forwarding a
malformed string downstream.
## Why this supersedes the open PRs
This consolidates and extends three open PRs that each addressed a slice
of the problem inconsistently. Their authors are credited as co-authors
on the relevant commits:
- **#3489** (LlamaIndex + Claude Agent SDK, TS) — @srijanarya
- **#3438** (Anthropic, Google, LangChain, TS) — @aptsalt
- **#3437** (Google ADK empty schemas + name fix, Python) —
@pragnyanramtha — its empty-`input_parameters` / missing-description
handling and the `gemini` → `google_adk` provider-name fix are folded in
here.
Compared to the three combined, this PR additionally: covers **every**
provider in **both** SDKs (not a subset of one), defines a single source
of truth instead of per-provider snippets, normalizes empty/`null`
payloads to `{}`, and raises an actionable typed error instead of
leaking `SyntaxError` or forwarding a bad string.
## Tests
- Exhaustive unit tests for both helpers (object passthrough,
JSON-string parse, empty/null → `{}`, malformed/non-object → typed
error).
- Per-provider regression tests across the touched TypeScript providers
(string path, malformed-string path, empty-payload path).
- Full `@composio/core` + touched-provider TS suites pass; `typecheck`
and `lint` clean. Python `ruff` clean and new test green.
## Changeset
Patch bump for all affected TypeScript packages (`@composio/core` + the
providers). Python follows its own versioning, so no changeset there.
---------
Co-authored-by: srijanarya <74669415+srijanarya@users.noreply.github.com>
Co-authored-by: Deepak Singh Kandari <deepaksinghkandari07@gmail.com>
Co-authored-by: Pragnyan Ramtha <pragnyanramtha@gmail.com>
This PR is **part 1 of 3** splitting
https://github.com/ComposioHQ/composio/pull/3505 to make the removal of
the old 2025 custom tools easier to review. It carries the **Python**
slice.
- removes the legacy `composio.tools.custom_tool` registry path:
`core.models.custom_tools`, `ExecuteRequestFn`, the old fallback
execution wiring, the security tests, and the example
- preserves the 2026 tool-router APIs: `composio.experimental.tool()`,
`composio.experimental.Toolkit`, inline custom-tool execution,
preload/attach/use flows, and `session.custom_tools()`
- bumps the Python workspace and all provider packages `0.13.1` →
`0.14.0`
## Notes
- This slice is a byte-identical subset of #3505 — the three split
branches recombine to that PR's exact tree. See #3505 for the original
local verification logs (`uv run --frozen nox -s chk`, targeted pytest);
CI re-runs per PR.
Stacked on #3397 · refs
https://github.com/ComposioHQ/composio/issues/3354
## Summary
The TS fix in #3397 handled the reported case (`output_parameters: {}`
from MCP toolkits) by normalizing empty schemas at `transformToolCases`.
While verifying the Python SDK didn't have the equivalent Zod check, I
found an **adjacent** crash on the input side.
The Python SDK does not have the same Pydantic check** —
`composio_client` types `input_parameters` / `output_parameters` as
`Dict[str, Optional[object]]`, which accepts `{}` trivially.
However, `Tools._get()` pipes every fetched tool's `input_parameters`
unconditionally through `FileHelper.enhance_schema_descriptions`
(`python/composio/core/models/tools.py:361-363`), which does:
```python
for _param, _schema in schema["properties"].items():
```
That crashes with `KeyError: 'properties'` on `schema={}`. Not reachable
for the specific `granola_mcp` tools in the original report (they all
have populated `input_parameters`), but it's the same API contract — any
MCP tool with no required inputs would hit this through the public
`Composio.tools.get(...)` call.
The sibling `FileHelper.process_file_uploadable_schema`
(`_files.py:760`) already guards this with `if "properties" not in
schema: return schema`. This applies the same guard to
`enhance_schema_descriptions`.
## Fix
```diff
def enhance_schema_descriptions(self, schema: t.Dict) -> t.Dict:
...
+ if "properties" not in schema:
+ return schema
required = schema.get("required") or []
for _param, _schema in schema["properties"].items():
```
## Testing
Red → green confirmed locally. New
`TestEnhanceSchemaDescriptionsEmptySchema` class in
`tests/test_files.py` covers:
1. `schema={}` → returns `{}` (the bug repro).
2. Schema with metadata but no `properties` key → returned unchanged
(mirrors the sibling method).
3. Schema with `properties: {}` (empty dict) → already works today;
pinning behavior.
4. Populated schema → still enhanced (regression guard for the type-hint
/ required-marker enhancement).
Cases 1–2 **fail on `next`** with `KeyError: 'properties'`. All 4 pass
with the guard. Full `tests/test_files.py` stays green (116 tests).
```bash
cd python
.venv/bin/python -m pytest tests/test_files.py::TestEnhanceSchemaDescriptionsEmptySchema -v # → 4 passed
.venv/bin/python -m pytest tests/test_files.py -q # → 116 passed
```
## Stacked PR notes
- Base: `jkomyno/fix-mcp-empty-output-parameters` (#3397).
- This PR's diff against that base shows **only** the Python changes
(`_files.py` + `tests/test_files.py`).
- After #3397 merges, this should be retargeted to `next` (or rebased
automatically by GitHub).
---------
Co-authored-by: jkomyno <12381818+jkomyno@users.noreply.github.com>
## Summary
This updates the Python SDK's automatic file upload/download
substitution so it can handle tool schemas that accept either a single
file or a list of files.
Tools can expose file inputs as a union such as:
```json
{
"anyOf": [
{ "type": "object", "file_uploadable": true },
{ "type": "array", "items": { "type": "object", "file_uploadable": true } }
]
}
```
When auto-upload is enabled, the SDK presents those file-uploadable
objects to models as file path strings. If the model supplies a list of
local paths, the old resolver selected the first file-bearing union
branch, usually the single-file branch, and attempted to upload the
entire Python list as one path. That fails before the backend ever
receives the intended list of staged file descriptors.
## Changes
- Refactors upload substitution into a value/schema recursive walker via
`_substitute_file_upload_value`.
- Adds the same value/schema traversal shape for downloads via
`_substitute_file_download_value`.
- Selects composed-schema variants by runtime shape when possible, so
list values choose array branches and string/dict values keep the
single-value behavior.
- Preserves the existing fallback behavior: if no runtime shape matches,
use the first file-bearing variant.
- Treats `anyOf`, `oneOf`, and `allOf` consistently with the SDK's
existing composed-schema convention.
- Preserves root request/response dict mutation behavior for existing
call sites.
- Adds regression coverage for single-file vs multi-file unions, nested
array-item unions, download parity, and first-match fallback.
## Why
This is needed for file-capable tool schemas that are backward
compatible at the API level by accepting both a single file and multiple
files. The Python SDK should transform a list of local paths into a list
of staged `{name, mimetype, s3key}` descriptors, just as it already does
for direct array schemas.
## Verification
- `python -m ruff check python/tests/test_files.py
python/composio/core/models/_files.py`
- `python -m pytest python/tests/test_files.py -q` -> 119 passed
- `python -m pytest python/tests/test_files.py
python/tests/test_auto_upload_download_files.py
python/tests/test_upload_dir_allowlist.py
python/tests/test_tool_router_session_files.py -q` -> 166 passed
- Manual playground verification with a Gmail send using two local
attachments: both files were staged and sent successfully as an
attachment array.
Note: a full local `python/tests` run still has three unrelated failures
because `composio_langchain` is not installed in this environment; the
affected file-upload suites pass.
---------
Co-authored-by: Zen <zen@composio.dev>
Six code-review nits from the multi-agent review on PR #3412:
P1.1 — python/setup.py was still pinning composio-client==1.36.0 while
pyproject.toml is on 1.39.0. The Experimental TypedDict that the new
link() docstring imports only exists in 1.39+, so anyone installing
via the legacy setup.py path would crash at import. Bumped to 1.39.0.
P1.2 — exceptions.py ComposioSharedAccessDeniedError and
ComposioSharedConnectionNotAccessibleError docstrings told users to
call composio.connected_accounts.update_acl(), which no longer exists.
Rewrote both to composio.experimental.update_acl().
P2.5 — ExperimentalAPI.update_acl returned t.Any; restored the typed
ConnectedAccountPatchResponse return so callers reading .id / .status /
.success keep type-safety at the API boundary.
P2.6 — ExperimentalAPI.__init__(client: Optional[Any]) tightened to
Optional[HttpClient]. The None branch stays for the one test that
exercises it; production code in sdk.py always passes a real client.
P2.7 — Dropped redundant alias=omit / connection=omit on the patch()
call. Verified against the composio-client==1.39.0 wheel: both default
to omit. Sibling ConnectedAccounts.update() already follows the cleaner
pattern. Test assertions updated to match.
P2.10 — Extracted the substring "acl_config_for_shared is only valid on
SHARED" into ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT in experimental.py, and
reused it from connected_accounts.py + tool_router_session.py. Single
source of truth so a server-side message tweak doesn't silently
downgrade three call sites to generic BadRequestError.
Tests: 201/201 pass. mypy on composio/ clean.
Pushed back on the remaining P2 (TS divergence) in the review — the
companion TS PR #3424 ships the exact same restructure, so parity is
preserved once both land together.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExperimentalAPI was sitting inside custom_tool.py because custom tools
were the first thing the SDK exposed on the composio.experimental
namespace. Now that update_acl has moved onto the same namespace,
keeping the class in custom_tool.py reads wrong — grepping for
update_acl lands in a file named after custom tools.
Splits the class out into core/models/experimental.py. custom_tool.py
keeps the custom-tool machinery (ExperimentalToolkit, decorator
helpers, serializers); experimental.py imports what it needs from
custom_tool.py and is now the home for anything on the
composio.experimental namespace.
Pure rearrangement — no behaviour change, no public-import-path change
(composio.core.models still re-exports ExperimentalAPI). Stack-frame
depth in _get_caller_locals(depth=2) stays correct: the
decorator → user-module hop is the same regardless of which file
ExperimentalAPI.tool lives in.
Tests still pass (201/201), mypy still clean on composio/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the Python SDK with the experimental wire shape used by Shared
Connections. The flat `account_type` / `acl_config_for_shared` kwargs on
`link()` and `authorize()` have moved under a single `experimental` dict,
and `update_acl` has moved off `composio.connected_accounts` onto the
`composio.experimental` namespace — same precedent as
`composio.experimental.tool` / `composio.experimental.Toolkit`.
The `experimental` namespace is the signal that the shape may change in
future releases. Pinning a SHARED connection in a session config and
direct execute by `connectedAccountId` are unchanged — only the
connection-create / patch / authorize surfaces are namespaced.
Also surfaces the `account_type=` filter on `composio.connected_accounts.list()`
so SHARED connections can be listed without dropping to the raw client.
The wire keeps this as a flat query param (`?account_type=`), so the
SDK keeps it flat too with the experimental signal carried in the value
enum description.
Caller migration:
# before
composio.connected_accounts.link(
user_id, auth_config_id,
account_type="SHARED",
acl_config_for_shared={"allow_all_users": True},
)
composio.connected_accounts.update_acl(
"ca_abc", allow_all_users=True,
)
session.authorize(
"github",
account_type="SHARED",
acl_config_for_shared={"allow_all_users": True},
)
# after
composio.connected_accounts.link(
user_id, auth_config_id,
experimental={
"account_type": "SHARED",
"acl_config_for_shared": {"allow_all_users": True},
},
)
composio.experimental.update_acl(
"ca_abc", allow_all_users=True,
)
session.authorize(
"github",
experimental={
"account_type": "SHARED",
"acl_config_for_shared": {"allow_all_users": True},
},
)
# new — list SHARED connections
shared = composio.connected_accounts.list(
account_type="SHARED",
user_ids=["user_creator"],
)
composio-client bumped from 1.38.0 -> 1.39.0 so the generated typed
client carries the Experimental TypedDicts for link.create,
tool_router.session.link, and connected_accounts.patch.
Tests cover: experimental block forwarding (link + authorize),
no-op when omitted, empty-list preservation, typed error mapping for
the PRIVATE-with-ACL case, experimental.update_acl body construction +
deny-list handling + ValidationError when no fields are provided +
ValidationError when called without a bound client, and
list(account_type="SHARED") flat-filter delegation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses 2 review comments from @abir-taheer on #3401:
- tests/test_connected_accounts.py:546 — remove Hermes PR reference
- models/connected_accounts.py:708 — update_acl docstring too long;
match the style of other methods in the file (cf. update())
Swept the full PR diff for the same patterns rather than just the two
flagged lines:
- Removed Hermes PR numbers and TS-test cross-reference from
TestConnectedAccountsAcl class docstring.
- Trimmed update_acl docstring — kept ~10 lines (one-line description,
param list, one example) instead of the original ~40 lines with the
resolution-rule table, multiple examples, and verbose footgun-warning
block. Matches the style of update() / delete() / refresh() in the
same file. Resolution rule + per-list caps stay documented on
ConnectedAccountAclConfigSchema (where the contract belongs).
- Removed internal backend error codes from exception class docstrings
(``ConnectedAccount_AclOnlyForShared``,
``ConnectedAccount_SharedAccessDenied``,
``ToolRouterV2_SharedConnectionNotAccessible``). Surfacing the
internal code names leaks implementation details.
- Reworded "users in the project" / "project member" / "Open access to
everyone in the project" — users don't belong to a project entity in
Composio's data model. Replaced with phrasing focused on ``user_id``
and the pinning mechanism.
- Removed "Set at create time only" wording from the link() param
description on connected_accounts.py and tool_router_session.py (the
corresponding upstream API description has the same wording removed).
- Cleaned inline catch comments referencing internal error code names.
No behaviour changes — docstring / comment cleanup only.
Verified: nox -s chk clean, ruff format --check clean, 49/49 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI / review issues on #3401:
1. "Lint and Format Check" failed — `ruff format --check` flagged
`composio/core/models/tool_router_session.py` and
`tests/test_connected_accounts.py`. Local `nox -s chk` only runs
`ruff check` (lint), not `ruff format --check`, so the gap slipped
through. Ran `ruff format` on both.
2. Bugbot caught uv.lock drift — pyproject.toml bumped to 0.14.0 but
uv.lock still recorded 0.13.0 from when the dep bump commit was
created. Ran `uv lock` to regenerate; the lockfile now records 0.14.0.
No behaviour changes — formatting + lockfile sync only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Python mirror of #3392 (TypeScript SDK). Same Apollo-side feature shipped
across four Hermes PRs (#9860, #9882, #9887 internal, #9902), now exposed
through @composio/* Python SDK wrappers.
Bumped composio-client 1.37.0 → 1.38.0 (the regenerated client published
by composio-base-py#66 — contains account_type + acl_config_for_shared on
LinkCreateParams, ConnectedAccountPatchParams, SessionLinkParams, the
retrieve response, and the list response item).
SDK changes:
- composio.connected_accounts.link() now accepts `account_type` and
`acl_config_for_shared`. Default behaviour (omit both) creates a
PRIVATE connection exactly as before.
- New composio.connected_accounts.update_acl(nanoid, *, allow_all_users,
allowed_user_ids, not_allowed_user_ids) — wraps PATCH /connected_accounts/{id}
with the same semantics as the TS sibling. PATCH semantics: omit a
param to leave unchanged; pass [] to clear an allow/deny list. Raises
ValidationError if all three are None.
- composio.tool_router_session.authorize() options gain `account_type`
and `acl_config_for_shared` — the /tool_router/session/{id}/link
endpoint accepts the same fields.
- New typed errors in composio.exceptions:
* ComposioAclOnlyForSharedError (400) — wired at link() / update_acl()
/ authorize() catch sites via the same
`acl_config_for_shared is only valid on SHARED` substring match used
in the TS SDK. Verified against Apollo's createConnectedAccount.ts.
* ComposioSharedAccessDeniedError (403) — exported, not yet wrapped.
Wraps when Tools.execute() error mapping lands in a follow-up.
* ComposioSharedConnectionNotAccessibleError (400) — exported, not
yet wrapped. Wraps when ToolRouterSession.create()/patch() session-
validator error mapping lands.
Plumbing:
- python/composio/client/types.py re-exports `link_create_params` (the
generated client's TypedDicts for link create payloads, including
`ACLConfigForShared`).
Tests: 12 new tests in TestConnectedAccountsAcl class covering link()
ACL forwarding (4), AclOnlyForShared mapping on link() (1), pass-through
of unrelated BadRequestError (1), update_acl() body construction (3),
empty-fields rejection (1), AclOnlyForShared mapping on update_acl() (1),
pass-through of unrelated BadRequestError on update_acl() (1). 49 tests
pass (37 pre-existing + 12 new); full suite 635 pass / 31 skipped.
Verified: nox -s chk clean (ruff + mypy on src + tests).
Version: 0.13.0 → 0.14.0 (minor — additive surface).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
initiate() previously emitted a one-time deprecation warning whenever the
response carried a redirectable OAuth scheme (OAUTH1, OAUTH2, DCR_OAUTH),
regardless of whether the auth config was Composio-managed (subject to the
2026-07-03 cutover) or custom (unaffected). The wording was conditional —
'If this auth config is Composio-managed…' — so callers using their own
OAuth apps could ignore it, but the warning still printed and added noise
to logs.
Apollo already emits the SEC-339 'Deprecation' / 'Sunset' / 'Link
rel="deprecation"' headers (RFC 9745 / RFC 8594) only on the retiring
branch — managed + redirectable OAuth + LD didn't block. The SDKs now
read the 'Deprecation' header from the response and gate the warning on
its presence. Custom auth configs and non-OAuth schemes get a clean
response from the server and now stay silent in the SDK as well.
TS:
- Chain '.withResponse()' on the underlying client.connectedAccounts.create
call to expose the raw Response. Defensive fallback for tests that mock
create() as a plain Promise.
- Drop the LEGACY_RETIRING_OAUTH_SCHEMES set; the apollo header is now the
sole signal.
- New tests in describe('initiate deprecation header gate', ...) pin the
contract: warns when Deprecation is present, silent when absent, and
one-time-per-process even across multiple managed-OAuth calls. A
mockApiPromiseWithHeaders helper wraps mocked values as APIPromise-shape
thenables so the new tests can simulate apollo's header behavior.
Python:
- Switch to client.connected_accounts.with_raw_response.create so we can
read raw.headers.get('Deprecation'). Defensive fallback to the parsed-
only path for test mocks that don't stub with_raw_response.
- Drop _LEGACY_RETIRING_OAUTH_SCHEMES; same simplification as TS.
- New TestInitiateDeprecationHeaderGate class with the same three pinning
cases. A _set_initiate_response helper routes mock responses through
the with_raw_response surface that initiate() now consumes. Pre-existing
initiate tests migrated to the helper; one assertion that inspected
create.call_args.kwargs now reads with_raw_response.create.call_args.kwargs.
No public API change for either SDK — initiate() returns the same
ConnectionRequest shape and respects the same allowMultiple guard.
ComposioLegacyConnectedAccountsEndpointRetiredError continues to surface
from the 400 retired-path response.
Changeset bumps @composio/core as a patch.
# Description
Apollo (hermes#9550) added a new \`REVOKED\` value to the
connected-account status enum, alongside the existing \`INITIALIZING |
INITIATED | ACTIVE | FAILED | EXPIRED | INACTIVE\`. The Apollo
public-API response schema is \`z.nativeEnum(ConnectionStatusEnum)\`, so
any list / get / status-update endpoint can now return \`REVOKED\` and
clients must handle it.
This PR fixes every hand-written place in the TS and Python SDKs that
hard-coded the prior 6-value set and would either (a) fail-fast against
\`REVOKED\` (Zod / Effect Schema parse error) or (b) silently
misclassify it.
## TypeScript SDK — BREAKING fixes
| File | Change | Severity |
|---|---|---|
| \`ts/packages/core/src/types/connectedAccounts.types.ts\` | Add
\`REVOKED\` to \`ConnectedAccountStatuses\` and
\`ConnectedAccountStatusSchema\` (\`z.enum\`) | BREAKING —
\`z.enum().parse()\` would throw on responses containing the new value |
| \`ts/packages/cli/src/models/connected-accounts.ts\` | Add \`REVOKED\`
to Effect \`Schema.Literal(...)\` for \`ConnectedAccountItem.status\` |
BREAKING — \`Schema.Literal()\` enforces literal matching |
| \`ts/packages/cli/src/services/tool-router-session-connections.ts\` |
Add \`'REVOKED'\` case to the \`normalizeConnectedAccountStatus\` switch
| BREAKING — silent fall-through to \`'INACTIVE'\` masked the new status
|
| \`ts/packages/cli/src/services/composio-clients.ts\` | Replace the
explicit 6-value cast with a generic that resolves to whatever the
Stainless client currently accepts | Forwards \`REVOKED\` to Apollo;
auto-tracks future Stainless regenerations |
| \`ts/packages/core/src/models/ConnectedAccounts.ts\` | Cast
\`statuses\` to the Stainless params type so \`REVOKED\` (already
accepted by Apollo) flows through until \`@composio/client\` is
regenerated | Adapter shim; remove once \`@composio/client\` is
regenerated |
## Python SDK — DEGRADED fixes
| File | Change | Severity |
|---|---|---|
| \`python/composio/core/models/webhook_events.py\` | Add \`REVOKED\` to
\`ConnectionStatusEnum\` | DEGRADED — code that checked \`status in
ConnectionStatusEnum\` would have failed to recognise the new value |
| \`python/composio/core/models/connected_accounts.py\` |
\`ConnectionRequest.wait_for_connection\` now fails fast on terminal
non-ACTIVE states (\`FAILED\` / \`EXPIRED\` / \`INACTIVE\` /
\`REVOKED\`) instead of looping until the 60s timeout | DEGRADED —
REVOKED used to look identical to "still pending" until the timeout
fired |
The Stainless-generated \`@composio/client\` (TS) and
\`composio_client\` (Python) packages will pick up \`REVOKED\`
automatically when regenerated; this PR covers the hand-written SDK code
only.
# How did I test this PR
- \`pnpm typecheck\` (root) — clean (\`9 successful, 9 total\`)
- \`pnpm test\` (root) — 694 passed, 1 skipped (75 test files); examples
validation \`Validated 22 example packages\`
- Python: \`ruff check\` and \`ruff format --check\` clean on both
edited files
- Smoke-tested Python imports + enum membership:
\`\`\`
PYTHON: imports OK, REVOKED present: REVOKED
\`\`\`
Triggered by: sarthak@composio.dev | Source: web
Session:
https://zen-api-production-4c98.up.railway.app/dashboard/#/chat/zen-14dc825802da
Also makes multiAccount fields optional in ToolRouterUpdateSessionConfig
(PATCH semantics) and adds a PATCH-safe transform that doesn't inject
create-time defaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Net −56 lines of comment prose. Earlier commits added multi-paragraph
blocks explaining each forward-compat guard, sentinel choice, and
schema-bypass cast — most of that is rederivable from the surrounding
code. Keep one-line WHY notes where the intent is non-obvious; drop
the prose elsewhere.
`test_wait_for_connection_fails_fast_on_terminal_status` only patched
`time.sleep`, not `time.time`. A stuck CI runner could in principle
exhaust the 10s deadline before the first `retrieve()` call resolves,
in which case the test would assert against a
`ComposioSDKTimeoutError` instead of the `SDKError` it's actually
exercising. Match the monotonic-counter pattern used by the sibling
tests at lines 137 and 170 to remove that flake surface.
Surfaced by kieran-python review of PR #3344 (P3#4).
Adds the test coverage flagged in PR #3344 review item P1#5:
TS (`ts/packages/core/test/connectedAccounts/connectedAccountAuthStates.test.ts`):
- `Oauth2RevokedConnectionDataSchema` parses with and without optional
`revoked_at`
- `ConnectionDataSchema` discriminated union accepts REVOKED for both
OAUTH2 and S2S_OAUTH2 (the preempted Apollo arm from P1#1)
- `transformConnectedAccountResponse` does not silently drop `state`
for REVOKED on either auth scheme
- Closure test on the `ConnectionStatuses` enum so a future Apollo
addition trips a CI failure on purpose
TS CLI (`connected-accounts.list.cmd.test.ts`):
- Server returning an unrecognized status no longer crashes the
command — the `catchTag('ParseError')` fallback warns the user to
run `composio upgrade` and renders the row anyway
Python (`tests/test_connected_accounts.py`):
- `wait_for_connection` fails fast on FAILED / EXPIRED / REVOKED
(parameterized) — the contract added by this PR
- `wait_for_connection` does NOT treat INACTIVE as terminal, so a
connection briefly transiting through INACTIVE still recovers to
ACTIVE — guards against the regression called out in P1#2
# Description
Fixes
[SEC-316](https://linear.app/composio/issue/SEC-316/composio-path-traversal-in-composio-python-sdk-allows-arbitrary-file)
— **HIGH severity (CVSS 8.2)** path traversal in the Composio Python
SDK.
`FileDownloadable.download()` in `python/composio/core/models/_files.py`
constructed the output path as `outdir / self.name`, where `self.name`
comes directly from the Composio API response. A compromised or
man-in-the-middle'd API server can return a filename like
`../../../../PWNED.sh` and write arbitrary files outside the configured
cache directory on the user's machine — leading to potential code
execution, configuration tampering, or data compromise.
The TypeScript SDK is **not** vulnerable: `downloadFileFromS3` in
`ts/packages/core/src/utils/fileUtils.node.ts` generates its own
filename via `generateTimestampedFilename()` and `saveFile` strips path
components with `platform.basename()` (line 428).
## What changed
`FileDownloadable.download()`:
- Strip directory components from `self.name` via `Path(...).name` so
traversal sequences (`../`, absolute paths, `\` on POSIX) collapse to a
basename.
- As a second-line defense, verify the resolved output path is contained
within `outdir` using `Path.is_relative_to()` (correct
directory-containment semantics — naive `str.startswith()` would be
vulnerable to sibling-prefix attacks like `outdir_evil/`).
This mirrors the equivalent fix already landed in `mercury`'s legacy
`FileDownloadable` (commits `e3915b82c3` + `c3d675dbcc` from PRs
ComposioHQ/mercury#20641 and #20654).
## How did I test this PR
- Added 4 unit tests in `TestFileDownloadablePathTraversal` covering:
- **Relative traversal** (`../../../PWNED.sh`) — collapsed to basename,
written inside `outdir`, file does NOT appear at the parent path.
- **Absolute path** (`/etc/passwd`) — basename strip yields `passwd`,
written inside `outdir`.
- **Dotdot-only name** (`..`) — `Path('..').name == '..'` survives
basename strip; the `is_relative_to()` check then rejects it with
`ErrorDownloadingFile`.
- **Safe filename** (`report.pdf`) — pass-through unchanged.
- Ran `pytest tests/test_files.py -v` (110/110 passing — full file, no
regressions).
```
$ python -m pytest tests/test_files.py::TestFileDownloadablePathTraversal -v
tests/test_files.py::TestFileDownloadablePathTraversal::test_relative_traversal_is_neutralized PASSED
tests/test_files.py::TestFileDownloadablePathTraversal::test_absolute_path_is_neutralized PASSED
tests/test_files.py::TestFileDownloadablePathTraversal::test_dotdot_only_name_is_rejected PASSED
tests/test_files.py::TestFileDownloadablePathTraversal::test_safe_filename_passes_through PASSED
========================= 4 passed in 0.18s ==========================
```
## Reference
- CWE-22: https://cwe.mitre.org/data/definitions/22.html
- CVSS Vector: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N`
---
Triggered by: srujan@composio.dev | Source: slack
Session:
https://zen-api-production-4c98.up.railway.app/dashboard/#/chat/zen-3b30311d7b50
Two regression tests for `FileDownloadable.download()`:
- `test_basename_collapse_through_subdir_is_rejected`: `name="foo/.."`
exercises the case where `Path(name).name` collapses to `..`. The
plain `name=".."` test already covers the same `is_relative_to()`
rejection, but the through-subdir form makes the basename
normalization explicit. Also asserts (per the P3.1 reorder) that
`outdir` is not created as a side effect of a rejected payload.
- `test_empty_name_safe_fails_at_write_time`: `name=""` makes
`outfile == outdir`, which passes the containment check (a path is
relative to itself). The download then safe-fails at
`outfile.open("wb")` with `IsADirectoryError`. Pinning this
documented behavior locks in the safe-fail and stops a future
change from silently weakening the check without a test failure.
`RemoteFile.save()` in `tool_router_session_files.py` shares the same
trust-boundary shape as the `FileDownloadable.download()` path traversal
fixed in SEC-316: the default save location is built as
`~/.composio/files/<self.filename>`, where `self.filename` is
`Path(mount_relative_path).name` derived from the (potentially
compromised or MITM'd) tool-router API response.
Today the basename strip alone makes this safe-by-OS-error — a server
returning `mount_relative_path == ".."` produces `filename == ".."`,
which collapses the resolved path to `~/.composio/` and `write_bytes()`
fails with `IsADirectoryError`. That's a non-security bug surface, but
it leaks intent through a confusing error and relies on a happy
accident of `Path` semantics.
Mirror the SEC-316 pattern: after building the default `save_path`,
verify `save_path.resolve().is_relative_to(default_dir.resolve())` and
raise `ValidationError` on mismatch. The check only runs on the
default-location branch — a caller-supplied `path` is fully trusted
and writes wherever the SDK user chooses.
Adds `test_save_default_location_rejects_dotdot_filename` covering the
`mount_relative_path="foo/.."` case (where `Path("foo/..").name == ".."`)
and asserting nothing is written under the cache dir.
Apply `ruff format --config config/ruff.toml` to fix `with patch(...)`
multi-line indentation that the CI Lint and Format Check rejected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Srujan A <srujan@composio.dev>
The Composio Python SDK's `FileDownloadable.download()` constructed the
output path as `outdir / self.name`, where `self.name` is taken directly
from the Composio API response without sanitization. A compromised or
man-in-the-middle'd API server could return a filename like
`../../../../PWNED.sh` and write arbitrary files outside the configured
output directory on the user's machine.
Fix mirrors the equivalent change made in mercury's legacy
FileDownloadable (commit e3915b82c3 + c3d675dbcc):
- Strip directory components from `self.name` via `Path(...).name`,
collapsing traversal sequences to a basename.
- As a second-line defense, verify the resolved output path stays
within `outdir` using `Path.is_relative_to()` (correct directory
containment semantics, vs. naive `str.startswith()` which is
vulnerable to sibling-prefix attacks).
Adds 4 unit tests in `TestFileDownloadablePathTraversal` covering
relative traversal, absolute paths, dotdot-only names, and the safe
pass-through case.
Reference: https://cwe.mitre.org/data/definitions/22.html
CVSS: 8.2 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Srujan A <srujan@composio.dev>
# Description
Fixes the cross-tenant credential access vulnerability tracked in
[SEC-365](https://linear.app/composio/issue/SEC-365/cross-tenant-credential-access)
(CWE-639) reported against
`python/composio/core/models/custom_tools.py`.
## What was wrong
`CustomTool.__call__` extracted `user_id` directly from the same
`**kwargs` dict that carries LLM-supplied tool arguments:
```python
def __call__(self, **kwargs: t.Any) -> t.Any:
user_id = kwargs.pop("user_id", None) or "default" # untrusted!
request = self.request_model.model_validate(kwargs)
...
auth_credentials=self.__get_auth_credentials(user_id),
```
Pydantic's `model_validate` ignores fields not declared in the schema,
so an LLM (or prompt-injection input) could include `user_id` in its
tool-call arguments and `__get_auth_credentials` would happily look up
that other tenant's OAuth tokens.
## What changed
- **`CustomTools.execute`** strips `user_id` from the LLM-supplied
`request` dict at the trust boundary, so only the explicit, server-side
`user_id` parameter reaches credential lookup. The original caller-owned
dict is left untouched.
- **`CustomTool`** now exposes a trusted internal entry point
`_invoke(user_id, request_kwargs)` where `user_id` is passed
structurally separate from the request kwargs dict (so an LLM-controlled
key inside the dict can never be promoted to override the trusted
value).
- **`CustomTool.__call__`** no longer reads `user_id` from `**kwargs` at
all and always uses the safe `"default"` user, with a docstring
directing direct callers to `CustomTools.execute` for trusted
multi-tenant use.
# How did I test this PR
Added `python/tests/test_custom_tools_security.py` with six regression
tests:
- `test_execute_strips_user_id_from_llm_request` — request dict
containing `user_id="victim-user"` is stripped; lookup uses the trusted
`user_id="trusted-user"`.
- `test_execute_falls_back_to_default_when_user_id_missing` — when no
trusted `user_id` is supplied, lookup uses `"default"` (NOT the
LLM-supplied value).
- `test_execute_does_not_mutate_caller_request_dict` — sanitization is
non-destructive on the caller's dict.
- `test_call_drops_user_id_smuggled_in_kwargs_dict` — `tool(**llm_dict)`
with `user_id` smuggled inside falls back to `"default"`.
- `test_invoke_uses_trusted_user_id_separate_from_request` — trusted
internal entry point passes the explicit `user_id` through.
- `test_execute_unknown_slug_raises` — sanity check, unknown slugs still
surface `NotFoundError`.
```bash
$ source .venv/bin/activate
$ python -m pytest tests/test_custom_tools_security.py -v
============================== 6 passed in 0.04s ===============================
```
Full Python test suite (excluding type-inference-only sessions, which
require all provider packages and run via `nox -s type_inference`):
```bash
$ python -m pytest tests/ --ignore=tests/test_type_inference_*
============================== 639 passed, 1 warning in 5.09s ==============================
```
Lint + type-check on changed files:
```bash
$ ruff check --config config/ruff.toml composio/core/models/custom_tools.py tests/test_custom_tools_security.py
All checks passed!
$ ruff format --config config/ruff.toml --check composio/core/models/custom_tools.py tests/test_custom_tools_security.py
2 files already formatted
$ mypy --config-file config/mypy.ini composio/core/models/custom_tools.py tests/test_custom_tools_security.py
Success: no issues found in 2 source files
```
Triggered by: srujan@composio.dev | Source: slack
Session:
https://zen-api-production-4c98.up.railway.app/dashboard/#/chat/zen-34868b976538