mirror of
https://github.com/ComposioHQ/composio.git
synced 2026-09-22 11:46:35 +08:00
feat(sdk): default-off auto file upload/download; add dangerouslyAllowAutoUploadDownloadFiles (#3260)
## Summary
Automatic tool file upload/download for `file_uploadable` fields is
**off by default** in TypeScript and Python. Callers must explicitly opt
in, and uploads from local paths are constrained by a fail-closed
allowlist.
## Changes
- **Removed (breaking):** `autoUploadDownloadFiles` (TS) /
`auto_upload_download_files` (Python) — the legacy default-on flag is
gone, not just deprecated.
- **New opt-in:** `dangerouslyAllowAutoUploadDownloadFiles` (TS) /
`dangerously_allow_auto_upload_download_files` (Python). When `true`,
`tools.get(...)` collapses `file_uploadable` schemas to `{ type:
'string', format: 'path' }` and the SDK stages local paths/URLs at
execute time.
- **New:** `fileUploadDirs?: string[] | false` — fail-closed allowlist
for local upload paths. `undefined` → `[<home>/.composio/temp]`; `false`
→ reject all local paths (URLs / `File` objects unaffected); explicit
`string[]` replaces the default. Components are matched on a path
boundary after `realpath`.
- **New:** `fileDownloadDir?: string` — directory where
`file_downloadable` results are staged.
- **New:** `beforeFileUpload` hook receives `source: 'path' | 'url' |
'file'` (TS) / `'path' | 'url'` (Python) so it can branch on input type.
- **New (TS):** when auto-upload is **off** and an LLM-driven
`tools.execute` is called against a tool with `file_uploadable` inputs,
the SDK emits a one-shot warning per tool slug pointing at
`composio.files.upload()` for manual staging.
## Migration
To restore previous behavior:
```ts
new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
// Optional: tighten the allowlist beyond the default ~/.composio/temp
fileUploadDirs: ['/srv/uploads'],
});
```
```python
Composio(api_key="...", dangerously_allow_auto_upload_download_files=True)
```
If you previously passed the legacy flag, remove it. There is no
transitional warning — TS and Python both reject the unknown property at
the type/keyword-arg level.
## Versioning
| Package | Bump |
| ------- | ---- |
| `@composio/core` | minor |
| `composio` (Python) | minor |
| Other `@composio/*` packages | patch (via changesets
`updateInternalDependencies: "patch"`) |
See
`docs/content/changelog/04-24-26-legacy-auto-upload-config-removal.mdx`
for the full migration writeup.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
---
|
||||
'@composio/core': minor
|
||||
---
|
||||
|
||||
Make automatic file upload/download opt-in and add scoped allowlist controls.
|
||||
|
||||
- **Removed:** the legacy `autoUploadDownloadFiles` constructor option. Code that sets it must migrate to the new opt-in flag.
|
||||
- **Added:** `dangerouslyAllowAutoUploadDownloadFiles` (default `false`). When `true`, the SDK collapses `file_uploadable` schemas to `{ type: 'string', format: 'path' }` for the model and stages local paths/URLs at execute time.
|
||||
- **Added:** `fileUploadDirs?: string[] | false` — fail-closed allowlist for local upload paths. `undefined` defaults to `[<home>/.composio/temp]`; `false` rejects all local paths; an explicit `string[]` replaces the default.
|
||||
- **Added:** `fileDownloadDir?: string` — directory used to stage S3 downloads on `file_downloadable` results.
|
||||
- **Added:** `beforeFileUpload` modifier now receives `source: 'path' | 'url' | 'file'` so hooks can branch on the original input type.
|
||||
- **Added:** when auto-upload is **off** but a tool with `file_uploadable` inputs is executed, the SDK emits a one-shot warning per tool slug pointing at `composio.files.upload()` for manual staging.
|
||||
|
||||
See the changelog entry at [`docs/content/changelog/04-24-26-legacy-auto-upload-config-removal.mdx`](https://github.com/ComposioHQ/composio/blob/master/docs/content/changelog/04-24-26-legacy-auto-upload-config-removal.mdx) for migration steps.
|
||||
|
||||
Provider packages that depend on `@composio/core` receive automatic patch bumps in the same release train via the changesets `updateInternalDependencies: "patch"` setting — no public-API change in those packages.
|
||||
@@ -242,6 +242,10 @@ Changed the `autoUploadDownloadFiles` default configuration for the Cloudflare W
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
**Removed in a later release.** The `autoUploadDownloadFiles` flag described above was removed in the v0.7.0 / Python v0.12.0 release train. Automatic file handling is now off by default on every runtime, and you opt in with `dangerouslyAllowAutoUploadDownloadFiles` instead. See [SDKs remove legacy automatic file handling config](/docs/changelog/2026/04/24) for the full migration.
|
||||
</Callout>
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- **Zero config** for common use cases
|
||||
|
||||
@@ -23,6 +23,7 @@ Custom tools support three patterns — standalone tools for internal logic, ext
|
||||
<Tab value="TypeScript">
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, experimental_createTool, experimental_createToolkit } from "@composio/core";
|
||||
import { z } from "zod/v3";
|
||||
// ---cut---
|
||||
|
||||
@@ -29,6 +29,7 @@ See the [importing connections guide](/docs/importing-existing-connections) for
|
||||
New `PATCH /api/v3/connected_accounts/{nanoid}` endpoint and SDK method — update credentials and alias on existing connections without recreating them.
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your-api-key' });
|
||||
// ---cut---
|
||||
|
||||
@@ -11,6 +11,7 @@ This release adds two features for managing multiple accounts per toolkit.
|
||||
Sessions now support a `multiAccount` config that lets users connect and use multiple accounts for the same toolkit (e.g., work and personal Gmail) within a single session.
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -30,6 +31,7 @@ By default, multi-account mode is disabled and each session uses one account per
|
||||
Connected accounts can now have human-readable aliases (e.g., `"work-gmail"`, `"personal-github"`). Aliases can be set during connection or updated after.
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const session = await composio.create('user_123');
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: 'SDKs remove legacy automatic file handling config'
|
||||
description: 'SDKs remove deprecated `autoUploadDownloadFiles` / `auto_upload_download_files`. Automatic file handling is off unless you set `dangerouslyAllowAutoUploadDownloadFiles` / `dangerously_allow_auto_upload_download_files`. Minor releases.'
|
||||
date: '2026-04-24'
|
||||
---
|
||||
|
||||
The Composio SDKs no longer accept the legacy flags that previously controlled automatic file upload and download during tool execution. Automatic handling of `file_uploadable` fields stays **off by default**; you opt in with the explicit `dangerously*` option only.
|
||||
|
||||
<Callout type="warn">
|
||||
**Breaking change**
|
||||
|
||||
Those legacy properties are removed from the public constructors and config types. Code that still passes them must migrate. Anyone who **relied on the old default-on behavior** (no file flag in the constructor) must now **explicitly** set the `dangerously*` flag to `true` to keep automatic path/URL file handling.
|
||||
</Callout>
|
||||
|
||||
### SDK versions (minor releases)
|
||||
|
||||
| Package | Previous | This release |
|
||||
| ------- | -------- | ------------ |
|
||||
| TypeScript `@composio/core` | v0.6.11 | **v0.7.0** |
|
||||
| Python `composio` | v0.11.6 | **v0.12.0** |
|
||||
|
||||
Other `@composio/*` npm packages that depend on core are published as **patch** bumps in the same release train (via the changesets `updateInternalDependencies: "patch"` setting) so they pick up the updated `ComposioConfig` surface — they have no public-API changes of their own.
|
||||
|
||||
### Migration
|
||||
|
||||
Earlier SDK versions treated automatic file upload/download for `file_uploadable` tool fields as **on by default** (TypeScript: `autoUploadDownloadFiles` defaulted to `true` when unset; Python followed the same idea for `auto_upload_download_files` in prior releases). **That default is now off.** If you still want that behavior, you must **explicitly** set `dangerouslyAllowAutoUploadDownloadFiles: true` (TypeScript) or `dangerously_allow_auto_upload_download_files=True` (Python).
|
||||
|
||||
**TypeScript — before (legacy):**
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
// If you omitted autoUploadDownloadFiles, it used to default to true
|
||||
new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
});
|
||||
|
||||
// Equivalent when you set it explicitly:
|
||||
new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
autoUploadDownloadFiles: true,
|
||||
});
|
||||
```
|
||||
|
||||
**TypeScript — going forward** (set `true` to match either legacy pattern above):
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
});
|
||||
```
|
||||
|
||||
To leave automatic file handling **off** (new default), omit `dangerouslyAllowAutoUploadDownloadFiles` or set it to `false`.
|
||||
|
||||
**Python — before (legacy):**
|
||||
|
||||
```python
|
||||
# If you omitted auto_upload_download_files, it used to default to True (prior releases)
|
||||
Composio(api_key="...")
|
||||
|
||||
# Equivalent when you set it explicitly:
|
||||
Composio(api_key="...", auto_upload_download_files=True)
|
||||
```
|
||||
|
||||
**Python — going forward** (set `True` to match either legacy pattern above):
|
||||
|
||||
```python
|
||||
Composio(api_key="...", dangerously_allow_auto_upload_download_files=True)
|
||||
```
|
||||
|
||||
If you construct `ToolRouterSession` directly (unusual), the keyword argument is now `dangerously_allow_auto_upload_download_files` instead of `auto_upload_download_files`.
|
||||
|
||||
### Local paths must live inside an allowlisted directory
|
||||
|
||||
Even with `dangerouslyAllowAutoUploadDownloadFiles` enabled, the SDK will **only** auto-upload local file paths that resolve inside an allowlisted directory. Paths outside the allowlist are rejected with `ComposioFileUploadPathNotAllowed` (TypeScript) / `FileUploadPathNotAllowed` (Python) before any file is read or sent.
|
||||
|
||||
This is a behavior change. Earlier SDK versions had no allowlist: any local path the legacy default-on flag saw was uploaded.
|
||||
|
||||
**Default allowlist:**
|
||||
|
||||
| `fileUploadDirs` value | Effective allowlist |
|
||||
| ---------------------- | ------------------- |
|
||||
| omitted / `undefined` | `[ <home>/.composio/temp ]` (default staging dir) |
|
||||
| `false` | `[]` — **all** local paths rejected (URLs and `File` objects still work) |
|
||||
| `[]` | `[]` — same as `false`; explicit "no local paths" |
|
||||
| `[<dir1>, <dir2>, …]` | exactly those directories (replaces the default; `~` is expanded, paths are `realpath`-resolved, comparison is on a path-component boundary) |
|
||||
|
||||
URLs (`http://…` / `https://…`) and JavaScript `File` objects are **not** subject to the allowlist — only local string paths are.
|
||||
|
||||
**TypeScript:**
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
// Match the *broadest* legacy behavior: auto-upload on, allowlist widened to
|
||||
// the directories your code actually reads from. Set this scope as tightly
|
||||
// as you can — every path inside these dirs becomes uploadable to Composio.
|
||||
new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
fileUploadDirs: ['/srv/uploads', '~/data/inbox'],
|
||||
});
|
||||
|
||||
// Disallow local-path uploads entirely; the SDK will only stage URLs and
|
||||
// File objects, and reject string paths outright.
|
||||
new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
fileUploadDirs: false,
|
||||
});
|
||||
```
|
||||
|
||||
**Python:**
|
||||
|
||||
```python
|
||||
# Equivalent of the TypeScript example above.
|
||||
Composio(
|
||||
api_key="...",
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
file_upload_dirs=["/srv/uploads", "~/data/inbox"],
|
||||
)
|
||||
|
||||
# Reject all local paths.
|
||||
Composio(
|
||||
api_key="...",
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
file_upload_dirs=False,
|
||||
)
|
||||
```
|
||||
|
||||
**Manual staging (TypeScript only):**
|
||||
|
||||
If you don't want to widen the allowlist, the TypeScript SDK lets you stage files yourself via `composio.files.upload(...)`. That call **bypasses the allowlist** by design (the caller is expected to control the path), and the returned `{ name, mimetype, s3key }` descriptor can be passed straight into `tools.execute`:
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
const staged = await composio.files.upload({
|
||||
file: '/anywhere/on/disk/report.pdf',
|
||||
toolSlug: 'GOOGLEDRIVE_UPLOAD_FILE',
|
||||
toolkitSlug: 'googledrive',
|
||||
});
|
||||
|
||||
await composio.tools.execute('GOOGLEDRIVE_UPLOAD_FILE', {
|
||||
userId: 'user-1',
|
||||
arguments: { file_to_upload: staged },
|
||||
});
|
||||
```
|
||||
|
||||
The Python SDK does not currently expose a top-level manual-staging API; opt in with `dangerously_allow_auto_upload_download_files=True` and configure `file_upload_dirs` to cover the directories your code reads from.
|
||||
|
||||
### What else changed
|
||||
|
||||
- TypeScript: `resolveAutoUploadDownloadFilesEnabled` only considers `dangerouslyAllowAutoUploadDownloadFiles`; the old deprecation warning for the legacy property is removed.
|
||||
- Python: the small `composio.utils.auto_upload_download` helper that merged legacy + new flags is removed; `Tools` and `ToolRouter` take only the explicit opt-in boolean.
|
||||
|
||||
Documentation for [executing tools](/docs/tools-direct/executing-tools) and the TypeScript [Composio reference](/reference/sdk-reference/typescript/composio) is updated to describe defaults and the opt-in flag.
|
||||
@@ -14,6 +14,7 @@ session = composio.create(user_id="user_123")
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -48,6 +49,7 @@ session = composio.create(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -79,6 +81,7 @@ session = composio.create(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -107,6 +110,7 @@ session = composio.create(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -140,6 +144,7 @@ session = composio.create(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -182,6 +187,7 @@ session = composio.create(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -213,6 +219,7 @@ mcp_url = session.mcp.url
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const session = await composio.create("user_123");
|
||||
@@ -237,6 +244,7 @@ tools = session.tools()
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const session = await composio.create("user_123");
|
||||
@@ -262,6 +270,7 @@ connected_account = connection_request.wait_for_connection()
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const session = await composio.create("user_123");
|
||||
@@ -295,6 +304,7 @@ for toolkit in toolkits.items:
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const session = await composio.create("user_123");
|
||||
|
||||
@@ -52,6 +52,7 @@ print(f"Connected: {connection.id}")
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, AuthScheme } from '@composio/core';
|
||||
|
||||
const composio = new Composio({ apiKey: 'your-api-key' });
|
||||
@@ -102,6 +103,7 @@ print(f"Connected: {connection.id}")
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, AuthScheme } from '@composio/core';
|
||||
|
||||
const composio = new Composio({ apiKey: 'your-api-key' });
|
||||
@@ -147,6 +149,7 @@ print(f"Connected: {connection.id}")
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, AuthScheme } from '@composio/core';
|
||||
|
||||
const composio = new Composio({ apiKey: 'your-api-key' });
|
||||
@@ -190,6 +193,7 @@ composio.connected_accounts.update(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your-api-key' });
|
||||
// ---cut---
|
||||
@@ -230,6 +234,7 @@ composio.connected_accounts.update(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your-api-key' });
|
||||
// ---cut---
|
||||
@@ -270,6 +275,7 @@ composio.connected_accounts.update(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your-api-key' });
|
||||
// ---cut---
|
||||
@@ -311,6 +317,7 @@ session = composio.create(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your-api-key' });
|
||||
// ---cut---
|
||||
|
||||
@@ -25,6 +25,7 @@ session = composio.create(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -71,6 +72,7 @@ personal_connection = personal_auth.wait_for_connection()
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -128,6 +130,7 @@ connection_request = composio.connected_accounts.link(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const session = await composio.create("user_123");
|
||||
@@ -166,6 +169,7 @@ composio.connected_accounts.update("ca_abc123", alias="")
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -196,6 +200,7 @@ session = composio.create(
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
// ---cut---
|
||||
@@ -225,6 +230,7 @@ for toolkit in toolkits.items:
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const session = await composio.create("user_123");
|
||||
|
||||
@@ -49,6 +49,7 @@ pip install composio
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from "@composio/core";
|
||||
|
||||
const composio = new Composio({ apiKey: "your_api_key" });
|
||||
@@ -72,6 +73,7 @@ A standalone tool handles internal app logic that doesn't need Composio auth. `c
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, experimental_createTool } from "@composio/core";
|
||||
import { z } from "zod/v3";
|
||||
// ---cut---
|
||||
@@ -132,6 +134,7 @@ Pass custom tools via the `experimental` option. `session.tools()` returns both
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, experimental_createTool } from "@composio/core";
|
||||
import { z } from "zod/v3";
|
||||
|
||||
@@ -194,6 +197,7 @@ pip install composio
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from "@composio/core";
|
||||
|
||||
const composio = new Composio({ apiKey: "your_api_key" });
|
||||
@@ -219,6 +223,7 @@ Prefer relative `endpoint` values in proxy calls. They resolve against the toolk
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, experimental_createTool } from "@composio/core";
|
||||
import { z } from "zod/v3";
|
||||
// ---cut---
|
||||
@@ -298,6 +303,7 @@ Pass custom tools via the `experimental` option. Extension tools inherit auth fr
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, experimental_createTool } from "@composio/core";
|
||||
import { z } from "zod/v3";
|
||||
|
||||
@@ -362,6 +368,7 @@ pip install composio
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from "@composio/core";
|
||||
|
||||
const composio = new Composio({ apiKey: "your_api_key" });
|
||||
@@ -385,6 +392,7 @@ A custom toolkit groups related standalone tools under a namespace. Tools inside
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, experimental_createTool, experimental_createToolkit } from "@composio/core";
|
||||
import { z } from "zod/v3";
|
||||
// ---cut---
|
||||
@@ -443,6 +451,7 @@ Pass custom toolkits via the `experimental` option. `session.tools()` returns bo
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio, experimental_createToolkit } from "@composio/core";
|
||||
import { z } from "zod/v3";
|
||||
|
||||
@@ -531,6 +540,7 @@ Use these methods to list registered tools and toolkits. Slugs include their fin
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from "@composio/core";
|
||||
const composio = new Composio({ apiKey: "your_api_key" });
|
||||
const session = await composio.create("user_1");
|
||||
@@ -554,6 +564,7 @@ Use `session.execute()` to run custom tools directly, outside of an agent loop.
|
||||
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from "@composio/core";
|
||||
const composio = new Composio({ apiKey: "your_api_key" });
|
||||
const session = await composio.create("user_1");
|
||||
|
||||
@@ -312,7 +312,7 @@ Need an API that isn't supported by any Composio toolkit, or want to extend an e
|
||||
|
||||
## Automatic File Handling
|
||||
|
||||
Composio handles file operations automatically. Pass file paths to tools that need them, and get local file paths back from tools that return files.
|
||||
Automatic file handling is **off by default**. Enable it with `dangerouslyAllowAutoUploadDownloadFiles: true` (TypeScript) or `dangerously_allow_auto_upload_download_files=True` (Python). Once enabled, pass file paths to tools that accept files and get local paths back from tools that return files.
|
||||
|
||||
### File Upload
|
||||
|
||||
@@ -321,6 +321,11 @@ Pass local file paths, URLs, or File objects to tools that accept files:
|
||||
<Tabs groupId="language" items={['Python', 'TypeScript']} persist>
|
||||
<Tab value="Python">
|
||||
```python
|
||||
composio = Composio(
|
||||
api_key="your_composio_key",
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
)
|
||||
|
||||
# Upload a local file to Google Drive
|
||||
result = composio.tools.execute(
|
||||
slug="GOOGLEDRIVE_UPLOAD_FILE",
|
||||
@@ -333,9 +338,13 @@ print(result) # Print Google Drive file details
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
import path from 'path';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const composio = new Composio({
|
||||
apiKey: 'your_api_key',
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
});
|
||||
// ---cut---
|
||||
// Upload a local file to Google Drive
|
||||
const result = await composio.tools.execute('GOOGLEDRIVE_UPLOAD_FILE', {
|
||||
@@ -359,7 +368,8 @@ When tools return files, Composio downloads them to the local directory and prov
|
||||
```python
|
||||
composio = Composio(
|
||||
api_key="your_composio_key",
|
||||
file_download_dir="./downloads" # Optional: Specify download directory
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
file_download_dir="./downloads", # Optional: Specify download directory
|
||||
)
|
||||
|
||||
result = composio.tools.execute(
|
||||
@@ -374,8 +384,12 @@ print(result)
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const composio = new Composio({
|
||||
apiKey: 'your_api_key',
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
});
|
||||
// ---cut---
|
||||
// Download a file from Google Drive
|
||||
const result = await composio.tools.execute('GOOGLEDRIVE_DOWNLOAD_FILE', {
|
||||
@@ -438,7 +452,13 @@ composio = Composio(
|
||||
import { Composio, type beforeFileUploadModifier } from '@composio/core';
|
||||
import path from 'node:path';
|
||||
const composio = new Composio({ apiKey: 'your_api_key' });
|
||||
const beforeFileUpload: beforeFileUploadModifier = async ctx => ctx.path;
|
||||
// ctx.source is 'path' | 'url' | 'file' — useful if you only want to audit
|
||||
// local paths and let URLs / File objects pass through unchanged.
|
||||
const beforeFileUpload: beforeFileUploadModifier = async ctx => {
|
||||
if (ctx.source !== 'path') return ctx.path;
|
||||
// ...audit ctx.path...
|
||||
return ctx.path;
|
||||
};
|
||||
// ---cut---
|
||||
await composio.tools.execute(
|
||||
'GOOGLEDRIVE_UPLOAD_FILE',
|
||||
@@ -456,9 +476,14 @@ await composio.tools.execute(
|
||||
import os
|
||||
from composio import Composio, before_file_upload
|
||||
|
||||
# New form: take a single ``context`` arg; ``context["source"]`` is "path"
|
||||
# or "url". The legacy ``(path, tool, toolkit)`` 3-arg form is still accepted.
|
||||
@before_file_upload(tools=["GOOGLEDRIVE_UPLOAD_FILE"])
|
||||
def audit_path(path: str, tool: str, toolkit: str) -> str | bool:
|
||||
return path
|
||||
def audit_path(ctx) -> str | bool:
|
||||
if ctx["source"] != "path":
|
||||
return ctx["path"]
|
||||
# ...audit ctx["path"]...
|
||||
return ctx["path"]
|
||||
|
||||
composio = Composio()
|
||||
|
||||
@@ -474,9 +499,40 @@ composio.tools.execute(
|
||||
|
||||
**Errors:** `ComposioSensitiveFilePathBlockedError` / `SensitiveFilePathBlockedError` when a path is blocked; `ComposioFileUploadAbortedError` / `FileUploadAbortedError` when the hook returns `false` (or equivalent abort).
|
||||
|
||||
### Disabling Auto File Handling
|
||||
### Automatic file handling (off by default)
|
||||
|
||||
You can disable automatic file handling when initializing the TypeScript SDK. When disabled, handle file uploads and downloads manually using `files.upload` and `files.download`:
|
||||
Both SDKs ship with automatic file handling **off**. Opt in with `dangerouslyAllowAutoUploadDownloadFiles: true` (TypeScript) or `dangerously_allow_auto_upload_download_files=True` (Python). Even with the flag enabled, **local paths must resolve inside an allowlisted directory** — see the matrix in the [legacy auto-upload removal changelog](/docs/changelog/2026/04/24) for the full `fileUploadDirs` / `file_upload_dirs` semantics.
|
||||
|
||||
When auto-handling is off, handle the staging yourself.
|
||||
|
||||
<Tabs groupId="language" items={['Python', 'TypeScript']} persist>
|
||||
<Tab value="Python">
|
||||
The Python SDK does not expose a top-level manual-staging API. Either:
|
||||
|
||||
1. Enable auto-handling and configure `file_upload_dirs` to cover the directories your code reads from:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio(
|
||||
api_key="your_composio_key",
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
file_upload_dirs=["/srv/agent/uploads", "~/.composio/temp"],
|
||||
)
|
||||
```
|
||||
|
||||
2. Or keep auto-handling off and pass an already-staged `{ "name", "mimetype", "s3key" }` dict directly into `tools.execute`. Pre-stage your files via your own pipeline (or an upstream tool) and inject the descriptor:
|
||||
|
||||
```python
|
||||
result = composio.tools.execute(
|
||||
"GOOGLEDRIVE_UPLOAD_FILE",
|
||||
user_id="user-1",
|
||||
arguments={"file_to_upload": prestaged_descriptor},
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
With `dangerouslyAllowAutoUploadDownloadFiles: false` (the default), call `composio.files.upload(...)` to stage paths/URLs explicitly. That call bypasses `fileUploadDirs` by design — the caller controls the path:
|
||||
|
||||
```typescript
|
||||
import { Composio } from '@composio/core';
|
||||
@@ -484,16 +540,24 @@ import path from 'path';
|
||||
// ---cut---
|
||||
const composio = new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY,
|
||||
autoUploadDownloadFiles: false
|
||||
// Default: dangerouslyAllowAutoUploadDownloadFiles is false; set true only to opt in
|
||||
});
|
||||
|
||||
// Now you need to handle files manually using composio.files API
|
||||
// Stage manually using composio.files API
|
||||
const fileData = await composio.files.upload({
|
||||
file: path.join(__dirname, 'document.pdf'),
|
||||
toolSlug: 'GOOGLEDRIVE_UPLOAD_FILE',
|
||||
toolkitSlug: 'googledrive'
|
||||
});
|
||||
|
||||
// Then pass the { name, mimetype, s3key } descriptor into tools.execute
|
||||
await composio.tools.execute('GOOGLEDRIVE_UPLOAD_FILE', {
|
||||
userId: 'user-1',
|
||||
arguments: { file_to_upload: fileData },
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## What to read next
|
||||
|
||||
|
||||
@@ -184,13 +184,21 @@ For tools with `file_uploadable` parameters, the SDK can read a local path and u
|
||||
|
||||
See [Executing tools: security for local file paths](/docs/tools-direct/executing-tools#security-for-local-file-paths) for constructor options, errors, and a full tabbed example.
|
||||
|
||||
The hook takes either a single `context` argument (preferred) or the legacy
|
||||
3-arg `(path, tool, toolkit)` form. The context object exposes
|
||||
`context["source"]` — `"path"` for local filesystem inputs and `"url"` for
|
||||
`http(s)://...` inputs — so you can scope audits to just local paths:
|
||||
|
||||
```python
|
||||
from composio import Composio, before_file_upload
|
||||
|
||||
@before_file_upload(tools=["GOOGLEDRIVE_UPLOAD_FILE"])
|
||||
def audit_path(path: str, tool: str, toolkit: str) -> str | bool:
|
||||
def audit_path(ctx) -> str | bool:
|
||||
# ctx = {"path": ..., "source": "path" | "url", "tool": ..., "toolkit": ...}
|
||||
if ctx["source"] != "path":
|
||||
return ctx["path"] # let URLs through untouched
|
||||
# return a new path string, or False to abort the upload
|
||||
return path
|
||||
return ctx["path"]
|
||||
|
||||
composio = Composio()
|
||||
composio.tools.execute(
|
||||
|
||||
@@ -15,3 +15,95 @@ description: "Composio SDK for Python. Generic parameters: TTool: The individua
|
||||
| [`mcp`](/reference/sdk-reference/python/mcp) | `MCP` |
|
||||
|
||||
[View source](https://github.com/composiohq/composio/blob/next/python/composio/sdk.py#L46)
|
||||
|
||||
---
|
||||
|
||||
## Constructor
|
||||
|
||||
```python
|
||||
Composio(
|
||||
provider: BaseProvider | None = None,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
timeout: int | None = None,
|
||||
max_retries: int | None = None,
|
||||
toolkit_versions: dict[str, str] | str | None = None,
|
||||
dangerously_allow_auto_upload_download_files: bool = False,
|
||||
sensitive_file_upload_protection: bool = True,
|
||||
file_upload_path_deny_segments: Sequence[str] | None = None,
|
||||
file_upload_dirs: Sequence[str] | Literal[False] | None = None,
|
||||
file_download_dir: str | None = None,
|
||||
allow_tracking: bool = True,
|
||||
environment: str = "production",
|
||||
)
|
||||
```
|
||||
|
||||
`api_key` falls back to `os.environ["COMPOSIO_API_KEY"]` when not passed.
|
||||
|
||||
### Common options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `provider` | `BaseProvider` | `OpenAIProvider()` | Provider used to wrap tools (Anthropic, LangChain, Gemini, etc.). |
|
||||
| `api_key` | `str` | `$COMPOSIO_API_KEY` | API key. Required. |
|
||||
| `base_url` | `str` | Composio default | Override the API base URL. |
|
||||
| `timeout` | `int` | client default | Per-request timeout in seconds. |
|
||||
| `max_retries` | `int` | `3` | HTTP client retry count. |
|
||||
| `toolkit_versions` | `dict \| str \| None` | `'latest'` | Pin toolkit versions globally (string) or per-toolkit (dict). |
|
||||
|
||||
---
|
||||
|
||||
## File upload security
|
||||
|
||||
Automatic file handling for `file_uploadable` tool fields is **off by default**. Set `dangerously_allow_auto_upload_download_files=True` to opt in. Once on, the SDK can read local paths and stage them to S3 on your behalf, but only from an allowlisted set of directories.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `dangerously_allow_auto_upload_download_files` | `bool` | `False` | Master opt-in. When `False`, paths and URLs in `file_uploadable` arguments are forwarded as-is — the backend will reject anything that isn't already a staged `{name, mimetype, s3key}` descriptor. Set to `True` to let the SDK stage local paths and URLs on your behalf at execute time. |
|
||||
| `sensitive_file_upload_protection` | `bool` | `True` | Block local paths matching a built-in denylist of segments (`.ssh`, `.aws`, etc.) and credential-like file names. Disable only if you accept the tradeoff. |
|
||||
| `file_upload_path_deny_segments` | `Sequence[str] \| None` | `None` | Extra single path components merged with the built-in denylist. |
|
||||
| `file_upload_dirs` | `Sequence[str] \| Literal[False] \| None` | `[~/.composio/temp]` | Allowlist of directories the SDK may read during **automatic** upload. Pass `False` (or `[]`) to reject every local path — URLs still work. Providing a list **replaces** the default; include `~/.composio/temp` explicitly if you want the default staging dir to keep working. Comparison is on a path-component boundary after `os.path.realpath`. On Windows, entries are compared case-insensitively. |
|
||||
| `file_download_dir` | `str \| None` | `~/.composio/files` | Directory where files from tool responses marked `file_downloadable` are streamed. |
|
||||
|
||||
Per-execution hooks live on the `modifiers=[...]` argument to `composio.tools.execute`, not on the constructor. Use the `@before_file_upload` decorator from `composio.core.models._modifiers` to inspect or rewrite each path before it is read. The hook context includes a `source` field (`'path' | 'url'`) so you can branch on local paths vs URLs.
|
||||
|
||||
**Related errors:** `FileUploadPathNotAllowed`, `SensitiveFilePathBlocked`, `FileUploadAbortedError`, `SDKFileNotFoundError` (all from `composio.exceptions`).
|
||||
|
||||
### Restricting automatic uploads to specific directories
|
||||
|
||||
When `dangerously_allow_auto_upload_download_files=True`, the SDK only reads local files from directories listed in `file_upload_dirs`. This stacks with (it does NOT replace) the sensitive-path denylist.
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio(
|
||||
api_key="your_composio_key",
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
# Replaces the default `[~/.composio/temp]`. List every directory you want
|
||||
# the SDK to read from during tool execution.
|
||||
file_upload_dirs=["/srv/agent/uploads", "~/.composio/temp"],
|
||||
)
|
||||
```
|
||||
|
||||
Pass `file_upload_dirs=False` (or `[]`) to reject every filesystem path; URLs and in-memory bytes still upload normally:
|
||||
|
||||
```python
|
||||
Composio(
|
||||
api_key="your_composio_key",
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
file_upload_dirs=False,
|
||||
)
|
||||
```
|
||||
|
||||
### Tool-Router session files
|
||||
|
||||
The Tool-Router session API exposes a separate files surface (`session.files.upload(...)` / `session.files.download(...)`) for streaming files into and out of a long-running router session. Those calls are unrelated to the `file_uploadable` schema flow on individual tool execution and are not gated by `dangerously_allow_auto_upload_download_files` or `file_upload_dirs`. See [`tool-router-session`](/reference/sdk-reference/python/tool-router-session) for usage.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [Executing tools](/docs/tools-direct/executing-tools) — automatic vs. manual file handling, before-execute modifiers.
|
||||
- [Before-execution modifiers](/docs/tools-direct/modify-tool-behavior/before-execution-modifiers#before-file-upload-python) — `@before_file_upload` decorator and the context-form hook signature.
|
||||
- [Legacy auto-upload config removal changelog](/docs/changelog/2026/04/24) — migration from the removed `auto_upload_download_files` flag and the allowlist semantics matrix.
|
||||
|
||||
@@ -180,15 +180,39 @@ getConfig(): ComposioConfig
|
||||
|
||||
## File upload security
|
||||
|
||||
When `autoUploadDownloadFiles` is true (default), the SDK can read local file paths and upload them before tool execution. The constructor accepts options that work together with the optional `beforeFileUpload` modifier on `tools.execute` (see [Executing tools: security](/docs/tools-direct/executing-tools#security-for-local-file-paths)).
|
||||
When `dangerouslyAllowAutoUploadDownloadFiles` is true, the SDK can read local file paths and upload them before tool execution (this behavior is **off** by default). The constructor accepts options that work together with the optional `beforeFileUpload` modifier on `tools.execute` (see [Executing tools: security](/docs/tools-direct/executing-tools#security-for-local-file-paths)).
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `sensitiveFileUploadProtection` | `boolean` | `true` | When true, local path strings for auto-upload and `files.upload` are checked against a built-in denylist of path segments (for example `.ssh`, `.aws`) and credential-like file names. Set to `false` only if you accept the security tradeoff. |
|
||||
| `fileUploadPathDenySegments` | `string[]` | `undefined` | Additional single path *components* (directory or file names) merged with the built-in list; matched anywhere in the resolved path. |
|
||||
| `fileUploadDirs` | `string[] \| false` | `[~/.composio/temp]` | Allowlist of directories the SDK may read during **automatic** upload (when `dangerouslyAllowAutoUploadDownloadFiles: true`). Pass `false` (or `[]`) to reject every local path; URLs and `File`/`Blob` objects still work. Providing a list **replaces** the default — include `~/.composio/temp` explicitly if you want the default staging dir to keep working. Does **not** affect manual `composio.files.upload()` calls. |
|
||||
| `fileDownloadDir` | `string` | `~/.composio/files` | Directory where files from tool responses marked `file_downloadable` are streamed. |
|
||||
|
||||
Pass **`beforeFileUpload`** on the **third argument** to `composio.tools.execute` (with `beforeExecute` / `afterExecute`), not on the constructor. The hook runs for each `file_uploadable` value before read/upload: return a path string, `false` to abort, or throw.
|
||||
Pass **`beforeFileUpload`** on the **third argument** to `composio.tools.execute` (with `beforeExecute` / `afterExecute`), not on the constructor. The hook runs for each `file_uploadable` value before read/upload: return a path string, `false` to abort, or throw. The hook's context includes a `source` field (`'path' | 'url' | 'file'`) so it can scope its logic to local paths vs URLs vs `File` objects.
|
||||
|
||||
**Related errors:** `ComposioSensitiveFilePathBlockedError`, `ComposioFileUploadAbortedError` (from `@composio/core`).
|
||||
**Related errors:** `ComposioSensitiveFilePathBlockedError`, `ComposioFileUploadAbortedError`, `ComposioFileUploadPathNotAllowedError`, `ComposioFileNotFoundError` (from `@composio/core`).
|
||||
|
||||
### Restricting automatic uploads to specific directories
|
||||
|
||||
When `dangerouslyAllowAutoUploadDownloadFiles: true`, the SDK only reads local
|
||||
files from directories in `fileUploadDirs`. This stacks with (it does NOT
|
||||
replace) the sensitive-path denylist.
|
||||
|
||||
```typescript
|
||||
// @noErrors
|
||||
import { Composio } from '@composio/core';
|
||||
|
||||
const composio = new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
// Replaces the default `[~/.composio/temp]`. List every directory you want
|
||||
// the SDK to read from during tool execution.
|
||||
fileUploadDirs: ['/srv/agent/uploads', '~/.composio/temp'],
|
||||
});
|
||||
```
|
||||
|
||||
Pass `fileUploadDirs: false` (or `[]`) to reject every filesystem path; URLs
|
||||
and in-memory `File`/`Blob` objects still upload normally.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @errors: 2307
|
||||
// @noErrors
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @noErrors
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -26,10 +26,14 @@ from composio.utils import mimetypes
|
||||
from composio.utils.sensitive_file_upload_paths import (
|
||||
assert_safe_local_file_upload_path,
|
||||
)
|
||||
from composio.utils.upload_dir_allowlist import (
|
||||
assert_path_inside_upload_dirs,
|
||||
)
|
||||
from composio.utils.logging import WithLogger
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from .tools import ToolExecutionResponse
|
||||
from ._modifiers import BeforeFileUploadContextCallable # noqa: F401
|
||||
|
||||
_DEFAULT_CHUNK_SIZE = 1024 * 1024
|
||||
_FILE_UPLOAD = "/api/v3/files/upload/request"
|
||||
@@ -86,9 +90,12 @@ except OSError as e:
|
||||
) from e
|
||||
|
||||
|
||||
LOCAL_OUTPUT_FILE_DIRECTORY = LOCAL_CACHE_DIRECTORY / "outputs"
|
||||
LOCAL_OUTPUT_FILE_DIRECTORY = LOCAL_CACHE_DIRECTORY / "files"
|
||||
"""
|
||||
Local output file directory name for composio tools
|
||||
Default local directory into which files downloaded during tool execution are
|
||||
written. Previously ``<cache>/outputs``; now ``<cache>/files`` for parity with
|
||||
the TypeScript SDK. Override by passing ``file_download_dir=...`` to Composio,
|
||||
or by setting ``outdir`` on ``FileHelper`` directly.
|
||||
"""
|
||||
|
||||
|
||||
@@ -106,7 +113,11 @@ def get_md5(file: Path) -> str:
|
||||
Returns:
|
||||
Hexadecimal MD5 hash string
|
||||
"""
|
||||
obj = hashlib.md5()
|
||||
# `usedforsecurity=False` lets this run on FIPS-mode systems, where
|
||||
# `hashlib.md5()` without the flag raises `ValueError: [digital envelope
|
||||
# routines] unsupported`. We're hashing for integrity / deduplication,
|
||||
# not security — the API just needs the digest for upload verification.
|
||||
obj = hashlib.md5(usedforsecurity=False)
|
||||
with file.open("rb") as fp:
|
||||
while True:
|
||||
line = fp.read(_DEFAULT_CHUNK_SIZE)
|
||||
@@ -345,7 +356,7 @@ def _upload_bytes_to_s3(
|
||||
toolkit: str,
|
||||
) -> str:
|
||||
"""Upload bytes content to S3 and return the S3 key."""
|
||||
md5_hash = hashlib.md5(content).hexdigest()
|
||||
md5_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()
|
||||
|
||||
s3meta = client.post(
|
||||
path=_FILE_UPLOAD,
|
||||
@@ -423,9 +434,8 @@ class FileUploadable(BaseModel):
|
||||
*,
|
||||
sensitive_file_upload_protection: bool = True,
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
before_file_upload: t.Optional[
|
||||
t.Callable[[str, str, str], t.Union[str, bool]]
|
||||
] = None,
|
||||
file_upload_allowlist: t.Optional[t.Sequence[Path]] = None,
|
||||
before_file_upload: t.Optional["BeforeFileUploadContextCallable"] = None,
|
||||
) -> te.Self:
|
||||
"""Create a FileUploadable from a local file path or public URL.
|
||||
|
||||
@@ -439,27 +449,34 @@ class FileUploadable(BaseModel):
|
||||
:param toolkit: The toolkit slug
|
||||
:param sensitive_file_upload_protection: When True, block paths on the built-in denylist.
|
||||
:param file_upload_path_deny_segments: Extra path segment names to merge with the built-in list.
|
||||
:param before_file_upload: Optional ``(path, tool, toolkit) -> str | bool``; return a new path string, or False to abort.
|
||||
:param file_upload_allowlist: When provided (not None), local paths must
|
||||
resolve inside one of these directories on a component boundary.
|
||||
Pass ``None`` to skip the check (e.g. manual upload APIs). URLs are
|
||||
never checked against the allowlist. An empty sequence means
|
||||
"no paths are allowed" (fail-closed).
|
||||
:param before_file_upload: Optional context-form hook produced by
|
||||
:func:`composio.core.models._modifiers.merge_before_file_upload`.
|
||||
Receives ``{"path", "source", "tool", "toolkit"}`` where ``source``
|
||||
is ``"url"`` for ``http(s)://...`` inputs and ``"path"`` for local
|
||||
filesystem paths. Return a new string to substitute, or ``False``
|
||||
to abort.
|
||||
:return: FileUploadable instance with S3 key
|
||||
"""
|
||||
file_str = str(file) if isinstance(file, Path) else file
|
||||
|
||||
# Check if it's a URL
|
||||
if isinstance(file_str, str) and _is_url(file_str):
|
||||
path_in = file_str
|
||||
if before_file_upload is not None:
|
||||
out = before_file_upload(path_in, tool, toolkit)
|
||||
if out is False:
|
||||
raise FileUploadAbortedError(
|
||||
"File upload was aborted because before_file_upload returned False."
|
||||
)
|
||||
if isinstance(out, str):
|
||||
path_in = out
|
||||
return cls.from_url(client=client, url=path_in, tool=tool, toolkit=toolkit)
|
||||
|
||||
path_in = file_str
|
||||
source: t.Literal["url", "path"] = (
|
||||
"url" if isinstance(file_str, str) and _is_url(file_str) else "path"
|
||||
)
|
||||
|
||||
if before_file_upload is not None:
|
||||
out = before_file_upload(path_in, tool, toolkit)
|
||||
out = before_file_upload(
|
||||
{
|
||||
"path": path_in,
|
||||
"source": source,
|
||||
"tool": tool,
|
||||
"toolkit": toolkit,
|
||||
}
|
||||
)
|
||||
if out is False:
|
||||
raise FileUploadAbortedError(
|
||||
"File upload was aborted because before_file_upload returned False."
|
||||
@@ -467,6 +484,21 @@ class FileUploadable(BaseModel):
|
||||
if isinstance(out, str):
|
||||
path_in = out
|
||||
|
||||
# Re-decide routing on the post-hook value: a URL-source hook may return
|
||||
# a local path (and vice versa). Re-checking with `_is_url` keeps the
|
||||
# URL fetch path and the local-file path properly separated, so a hook
|
||||
# cannot, for example, smuggle `/etc/passwd` past the URL branch's
|
||||
# missing allowlist/denylist by rewriting the URL into a path.
|
||||
if isinstance(path_in, str) and _is_url(path_in):
|
||||
return cls.from_url(client=client, url=path_in, tool=tool, toolkit=toolkit)
|
||||
|
||||
# Allowlist check runs BEFORE the denylist / existence checks when enabled,
|
||||
# so the "configure file_upload_dirs" hint fires first for the common case
|
||||
# (user has auto-upload on but hasn't configured dirs). Caller passes
|
||||
# ``None`` to bypass the allowlist (manual upload APIs).
|
||||
if file_upload_allowlist is not None:
|
||||
assert_path_inside_upload_dirs(path_in, file_upload_allowlist)
|
||||
|
||||
assert_safe_local_file_upload_path(
|
||||
path_in,
|
||||
enabled=sensitive_file_upload_protection,
|
||||
@@ -527,7 +559,11 @@ class FileDownloadable(BaseModel):
|
||||
return outfile
|
||||
|
||||
|
||||
BeforeFileUpload = t.Callable[[str, str, str], t.Union[str, bool]]
|
||||
# Internal alias — ``FileHelper`` receives the already-adapted context-form
|
||||
# callable produced by :func:`merge_before_file_upload`.
|
||||
from composio.core.models._modifiers import ( # noqa: E402
|
||||
BeforeFileUploadContextCallable as BeforeFileUpload,
|
||||
)
|
||||
|
||||
|
||||
class FileHelper(WithLogger):
|
||||
@@ -538,12 +574,24 @@ class FileHelper(WithLogger):
|
||||
*,
|
||||
sensitive_file_upload_protection: bool = True,
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
file_upload_allowlist: t.Optional[t.Sequence[Path]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
:param outdir: Where files downloaded during tool execution are written.
|
||||
Defaults to ``~/.composio/files``.
|
||||
:param file_upload_allowlist: Directories from which local files may be
|
||||
auto-uploaded. ``None`` disables the allowlist check (used when
|
||||
auto-upload is off, or for manual upload APIs). An empty list means
|
||||
"no paths allowed" (fail-closed).
|
||||
"""
|
||||
super().__init__()
|
||||
self._client = client
|
||||
self._outdir = Path(outdir or LOCAL_OUTPUT_FILE_DIRECTORY)
|
||||
self._outdir = Path(outdir) if outdir else LOCAL_OUTPUT_FILE_DIRECTORY
|
||||
self._sensitive_file_upload_protection = sensitive_file_upload_protection
|
||||
self._file_upload_path_deny_segments = file_upload_path_deny_segments
|
||||
self._file_upload_allowlist: t.Optional[t.Sequence[Path]] = (
|
||||
list(file_upload_allowlist) if file_upload_allowlist is not None else None
|
||||
)
|
||||
|
||||
def _has_file_property(
|
||||
self, schema: t.Dict, property_name: str = "file_uploadable"
|
||||
@@ -672,7 +720,7 @@ class FileHelper(WithLogger):
|
||||
- Required notes ("This parameter is required.")
|
||||
|
||||
This is separate from file processing and should always run
|
||||
regardless of the auto_upload_download_files setting.
|
||||
regardless of `dangerously_allow_auto_upload_download_files`.
|
||||
"""
|
||||
required = schema.get("required") or []
|
||||
for _param, _schema in schema["properties"].items():
|
||||
@@ -694,7 +742,8 @@ class FileHelper(WithLogger):
|
||||
"""Process file_uploadable fields in schema.
|
||||
|
||||
This method converts file_uploadable fields to path format.
|
||||
Should only be called when auto_upload_download_files is True.
|
||||
Should only be called when the caller opted in via
|
||||
`dangerously_allow_auto_upload_download_files=True`.
|
||||
Recursively handles anyOf, oneOf, allOf, nested properties, and array items.
|
||||
"""
|
||||
if "properties" not in schema:
|
||||
@@ -770,6 +819,7 @@ class FileHelper(WithLogger):
|
||||
toolkit=tool.toolkit.slug,
|
||||
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
|
||||
file_upload_allowlist=self._file_upload_allowlist,
|
||||
before_file_upload=before_file_upload,
|
||||
).model_dump()
|
||||
continue
|
||||
@@ -790,6 +840,7 @@ class FileHelper(WithLogger):
|
||||
toolkit=tool.toolkit.slug,
|
||||
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
|
||||
file_upload_allowlist=self._file_upload_allowlist,
|
||||
before_file_upload=before_file_upload,
|
||||
).model_dump()
|
||||
continue
|
||||
@@ -841,6 +892,7 @@ class FileHelper(WithLogger):
|
||||
toolkit=tool.toolkit.slug,
|
||||
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
|
||||
file_upload_allowlist=self._file_upload_allowlist,
|
||||
before_file_upload=before_file_upload,
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
@@ -66,7 +66,12 @@ class SchemaModifier(t.Protocol):
|
||||
|
||||
|
||||
class BeforeFileUploadCallable(t.Protocol):
|
||||
"""Called for each local file path before read/upload (``file_uploadable``)."""
|
||||
"""Legacy positional form of the ``before_file_upload`` hook.
|
||||
|
||||
``(path, tool, toolkit) -> str | bool``. Still supported for back-compat,
|
||||
but new code should use :class:`BeforeFileUploadContextCallable` so it can
|
||||
discriminate local paths from URLs via ``context["source"]``.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
@@ -76,6 +81,83 @@ class BeforeFileUploadCallable(t.Protocol):
|
||||
) -> t.Union[str, bool]: ...
|
||||
|
||||
|
||||
class BeforeFileUploadContext(te.TypedDict):
|
||||
"""Context passed to the new-form ``before_file_upload`` hook.
|
||||
|
||||
- ``path``: the local filesystem path for ``source="path"``, or the URL
|
||||
string for ``source="url"``.
|
||||
- ``source``: discriminator — ``"path"`` for local paths, ``"url"`` for
|
||||
``http(s)://...`` inputs. Mirrors the TypeScript SDK's ``source`` field
|
||||
(TS additionally emits ``"file"`` for ``File`` objects; Python has no
|
||||
equivalent runtime type).
|
||||
- ``tool`` / ``toolkit``: slugs of the tool being executed.
|
||||
"""
|
||||
|
||||
path: str
|
||||
source: te.Literal["path", "url"]
|
||||
tool: str
|
||||
toolkit: str
|
||||
|
||||
|
||||
class BeforeFileUploadContextCallable(t.Protocol):
|
||||
"""Preferred form of the ``before_file_upload`` hook.
|
||||
|
||||
Takes a single :class:`BeforeFileUploadContext` argument and returns either
|
||||
a new path/URL string, or ``False`` to abort the upload.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
context: BeforeFileUploadContext,
|
||||
) -> t.Union[str, bool]: ...
|
||||
|
||||
|
||||
BeforeFileUploadLike = t.Union[
|
||||
BeforeFileUploadCallable,
|
||||
BeforeFileUploadContextCallable,
|
||||
]
|
||||
"""Either form of ``before_file_upload``. Adapted internally."""
|
||||
|
||||
|
||||
def _count_positional_params(fn: t.Callable) -> int:
|
||||
"""Return the number of positional (or positional-or-keyword) params, or
|
||||
-1 if the signature can't be introspected (e.g. builtins)."""
|
||||
import inspect
|
||||
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
return sum(
|
||||
1
|
||||
for p in sig.parameters.values()
|
||||
if p.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _adapt_before_file_upload(
|
||||
hook: BeforeFileUploadLike,
|
||||
) -> BeforeFileUploadContextCallable:
|
||||
"""Normalise a user-supplied hook to the context-object form.
|
||||
|
||||
A hook declared with exactly 3 positional parameters is treated as the
|
||||
legacy ``(path, tool, toolkit)`` form; anything else (typically a single
|
||||
positional ``context`` parameter) is treated as the new form.
|
||||
"""
|
||||
if _count_positional_params(hook) == 3:
|
||||
legacy = t.cast(BeforeFileUploadCallable, hook)
|
||||
|
||||
def wrap(context: BeforeFileUploadContext) -> t.Union[str, bool]:
|
||||
return legacy(context["path"], context["tool"], context["toolkit"])
|
||||
|
||||
return wrap
|
||||
return t.cast(BeforeFileUploadContextCallable, hook)
|
||||
|
||||
|
||||
ModifierSlug: t.TypeAlias = str
|
||||
AfterExecuteModifierL: t.TypeAlias = t.Literal["after_execute"]
|
||||
BeforeExecuteModifierL: t.TypeAlias = t.Literal["before_execute"]
|
||||
@@ -93,6 +175,7 @@ class Modifier:
|
||||
| BeforeExecuteMeta
|
||||
| AfterExecuteMeta
|
||||
| BeforeFileUploadCallable
|
||||
| BeforeFileUploadContextCallable
|
||||
],
|
||||
type_: (
|
||||
AfterExecuteModifierL
|
||||
@@ -222,7 +305,7 @@ def before_execute(
|
||||
|
||||
|
||||
@t.overload
|
||||
def before_file_upload(modifier: t.Optional[BeforeFileUploadCallable]) -> Modifier: ...
|
||||
def before_file_upload(modifier: t.Optional[BeforeFileUploadLike]) -> Modifier: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
@@ -230,19 +313,30 @@ def before_file_upload(
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> t.Callable[[BeforeFileUploadCallable], Modifier]: ...
|
||||
) -> t.Callable[[BeforeFileUploadLike], Modifier]: ...
|
||||
|
||||
|
||||
def before_file_upload(
|
||||
modifier: t.Optional[BeforeFileUploadCallable] = None,
|
||||
modifier: t.Optional[BeforeFileUploadLike] = None,
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> Modifier | t.Callable[[BeforeFileUploadCallable], Modifier]:
|
||||
) -> Modifier | t.Callable[[BeforeFileUploadLike], Modifier]:
|
||||
"""
|
||||
Build a ``Modifier`` for the file-upload hook (same scoping pattern as
|
||||
:func:`before_execute`).
|
||||
|
||||
Your callable may take **either**:
|
||||
|
||||
- a single ``context`` argument (:class:`BeforeFileUploadContext`) — the
|
||||
preferred form, exposes ``context["source"]`` (``"path"`` or ``"url"``),
|
||||
or
|
||||
- three positional arguments ``(path, tool, toolkit)`` — legacy form, kept
|
||||
for back-compat.
|
||||
|
||||
Return a new path/URL string to substitute, or ``False`` to abort the
|
||||
upload (raises :class:`~composio.exceptions.FileUploadAbortedError`).
|
||||
|
||||
Pass the returned ``Modifier`` in ``modifiers=[...]`` on
|
||||
:meth:`composio.core.models.tools.Tools.execute` or ``tools.get``. Multiple
|
||||
such modifiers are composed in list order.
|
||||
@@ -257,7 +351,7 @@ def before_file_upload(
|
||||
|
||||
if tools is not None or toolkits is not None:
|
||||
return t.cast(
|
||||
t.Callable[[BeforeFileUploadCallable], Modifier],
|
||||
t.Callable[[BeforeFileUploadLike], Modifier],
|
||||
functools.partial(
|
||||
before_file_upload,
|
||||
tools=tools or [],
|
||||
@@ -392,10 +486,15 @@ def merge_before_file_upload(
|
||||
modifiers: t.Optional[Modifiers],
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
) -> t.Optional[BeforeFileUploadCallable]:
|
||||
) -> t.Optional[BeforeFileUploadContextCallable]:
|
||||
"""Compose ``before_file_upload``-type :class:`Modifier`\\ s for this *tool* / *toolkit*.
|
||||
|
||||
Scoping matches :class:`Modifier` (empty ``tools`` and ``toolkits`` = all tools).
|
||||
|
||||
Each user-supplied hook is adapted to the context form by
|
||||
:func:`_adapt_before_file_upload`, so legacy 3-arg callables keep working
|
||||
while new-form callables receive the full :class:`BeforeFileUploadContext`
|
||||
(including ``source``).
|
||||
"""
|
||||
to_chain = [
|
||||
m
|
||||
@@ -410,12 +509,25 @@ def merge_before_file_upload(
|
||||
return True
|
||||
return tool in m.tools or toolkit in m.toolkits
|
||||
|
||||
def combined(path: str, tool: str, toolkit: str) -> t.Union[str, bool]:
|
||||
p: str = path
|
||||
for m in to_chain:
|
||||
adapted_chain = [
|
||||
(m, _adapt_before_file_upload(t.cast(BeforeFileUploadLike, m.modifier)))
|
||||
for m in to_chain
|
||||
]
|
||||
|
||||
def combined(context: BeforeFileUploadContext) -> t.Union[str, bool]:
|
||||
p: str = context["path"]
|
||||
for m, hook in adapted_chain:
|
||||
if not _applies(m):
|
||||
continue
|
||||
out = t.cast(BeforeFileUploadCallable, m.modifier)(p, tool, toolkit)
|
||||
# Preserve source/tool/toolkit while forwarding the (possibly
|
||||
# rewritten) path to the next hook.
|
||||
next_ctx: BeforeFileUploadContext = {
|
||||
"path": p,
|
||||
"source": context["source"],
|
||||
"tool": context["tool"],
|
||||
"toolkit": context["toolkit"],
|
||||
}
|
||||
out = hook(next_ctx)
|
||||
if out is False:
|
||||
return False
|
||||
if isinstance(out, str):
|
||||
|
||||
@@ -360,24 +360,29 @@ class ToolRouter(Resource, t.Generic[TTool, TToolCollection]):
|
||||
self,
|
||||
client: HttpClient,
|
||||
provider: t.Optional["BaseProvider[TTool, TToolCollection]"] = None,
|
||||
auto_upload_download_files: bool = True,
|
||||
dangerously_allow_auto_upload_download_files: bool = False,
|
||||
sensitive_file_upload_protection: bool = True,
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
|
||||
):
|
||||
"""
|
||||
Initialize ToolRouter instance.
|
||||
|
||||
:param client: HTTP client for API calls
|
||||
:param provider: Optional provider for tool wrapping
|
||||
:param auto_upload_download_files: Whether to automatically upload and download files. Defaults to True.
|
||||
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file upload/download. Defaults to False.
|
||||
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload.
|
||||
:param file_upload_path_deny_segments: Extra path segment names to merge with the built-in denylist.
|
||||
:param file_upload_dirs: Allowlist of directories for auto-upload. See ``Composio`` for details.
|
||||
"""
|
||||
super().__init__(client)
|
||||
self._provider = provider
|
||||
self._auto_upload_download_files = auto_upload_download_files
|
||||
self._sensitive_file_upload_protection = sensitive_file_upload_protection
|
||||
self._file_upload_path_deny_segments = file_upload_path_deny_segments
|
||||
self._file_upload_dirs = file_upload_dirs
|
||||
self._auto_upload_download_files = bool(
|
||||
dangerously_allow_auto_upload_download_files
|
||||
)
|
||||
|
||||
def _create_mcp_server_config(
|
||||
self,
|
||||
@@ -809,9 +814,10 @@ class ToolRouter(Resource, t.Generic[TTool, TToolCollection]):
|
||||
return ToolRouterSession(
|
||||
client=self._client,
|
||||
provider=self._provider,
|
||||
auto_upload_download_files=self._auto_upload_download_files,
|
||||
dangerously_allow_auto_upload_download_files=self._auto_upload_download_files,
|
||||
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
|
||||
file_upload_dirs=self._file_upload_dirs,
|
||||
session_id=session.session_id,
|
||||
mcp=self._create_mcp_server_config(
|
||||
mcp_type=ToolRouterMCPServerType(session.mcp.type.lower()),
|
||||
@@ -864,9 +870,10 @@ class ToolRouter(Resource, t.Generic[TTool, TToolCollection]):
|
||||
return ToolRouterSession(
|
||||
client=self._client,
|
||||
provider=self._provider,
|
||||
auto_upload_download_files=self._auto_upload_download_files,
|
||||
dangerously_allow_auto_upload_download_files=self._auto_upload_download_files,
|
||||
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
|
||||
file_upload_dirs=self._file_upload_dirs,
|
||||
session_id=session.session_id,
|
||||
mcp=self._create_mcp_server_config(
|
||||
mcp_type=ToolRouterMCPServerType(session.mcp.type.lower()),
|
||||
|
||||
@@ -76,9 +76,10 @@ class ToolRouterSession(t.Generic[TTool, TToolCollection]):
|
||||
*,
|
||||
client: HttpClient,
|
||||
provider: t.Optional[BaseProvider[t.Any, t.Any]],
|
||||
auto_upload_download_files: bool,
|
||||
dangerously_allow_auto_upload_download_files: bool,
|
||||
sensitive_file_upload_protection: bool = True,
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
|
||||
session_id: str,
|
||||
mcp: t.Any,
|
||||
experimental: "ToolRouterSessionExperimental",
|
||||
@@ -87,9 +88,10 @@ class ToolRouterSession(t.Generic[TTool, TToolCollection]):
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._provider = provider
|
||||
self._auto_upload_download_files = auto_upload_download_files
|
||||
self._auto_upload_download_files = dangerously_allow_auto_upload_download_files
|
||||
self._sensitive_file_upload_protection = sensitive_file_upload_protection
|
||||
self._file_upload_path_deny_segments = file_upload_path_deny_segments
|
||||
self._file_upload_dirs = file_upload_dirs
|
||||
self.session_id = session_id
|
||||
self.mcp = mcp
|
||||
self.experimental = experimental
|
||||
@@ -146,9 +148,10 @@ class ToolRouterSession(t.Generic[TTool, TToolCollection]):
|
||||
tools_model = ToolsModel(
|
||||
client=self._client,
|
||||
provider=self._provider,
|
||||
auto_upload_download_files=self._auto_upload_download_files,
|
||||
dangerously_allow_auto_upload_download_files=self._auto_upload_download_files,
|
||||
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
|
||||
file_upload_dirs=self._file_upload_dirs,
|
||||
)
|
||||
|
||||
router_tools = tools_model.get_raw_tool_router_meta_tools(
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
import typing_extensions as te
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
@@ -26,6 +27,7 @@ from composio.core.types import ToolkitVersionParam
|
||||
from composio.exceptions import InvalidParams, NotFoundError, ToolVersionRequiredError
|
||||
from composio.utils.pydantic import none_to_omit
|
||||
from composio.utils.toolkit_version import get_toolkit_version
|
||||
from composio.utils.upload_dir_allowlist import resolve_effective_upload_allowlist
|
||||
|
||||
from ._modifiers import (
|
||||
Modifiers,
|
||||
@@ -102,9 +104,10 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]):
|
||||
provider: BaseProvider[TTool, TToolCollection],
|
||||
file_download_dir: t.Optional[str] = None,
|
||||
toolkit_versions: t.Optional[ToolkitVersionParam] = None,
|
||||
auto_upload_download_files: bool = True,
|
||||
dangerously_allow_auto_upload_download_files: bool = False,
|
||||
sensitive_file_upload_protection: bool = True,
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the tools resource.
|
||||
@@ -113,10 +116,27 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]):
|
||||
:param provider: The provider to use for the tools resource.
|
||||
:param file_download_dir: Output directory for downloadable files
|
||||
:param toolkit_versions: The versions of the toolkits to use. Defaults to 'latest' if not provided.
|
||||
:param auto_upload_download_files: Whether to automatically upload and download files. Defaults to True.
|
||||
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file upload/download. Defaults to False.
|
||||
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload.
|
||||
:param file_upload_path_deny_segments: Extra path segment names to merge with the built-in denylist.
|
||||
:param file_upload_dirs: Allowlist of directories from which local files may
|
||||
be auto-uploaded. Only consulted when
|
||||
``dangerously_allow_auto_upload_download_files=True``. See Composio docs
|
||||
for details.
|
||||
"""
|
||||
self._auto_upload_download_files = bool(
|
||||
dangerously_allow_auto_upload_download_files
|
||||
)
|
||||
# Resolve allowlist once, but only if auto-upload is enabled. When it's
|
||||
# disabled there's no auto-upload code path, so we pass ``None`` and
|
||||
# FileHelper won't run the allowlist check either way. Manual APIs (if
|
||||
# ever added in the future) should also pass ``None``.
|
||||
resolved_allowlist: t.Optional[t.List[Path]] = (
|
||||
resolve_effective_upload_allowlist(file_upload_dirs)
|
||||
if self._auto_upload_download_files
|
||||
else None
|
||||
)
|
||||
|
||||
self._client = client
|
||||
self._custom_tools = CustomTools(client)
|
||||
self._tool_schemas: t.Dict[str, Tool] = {}
|
||||
@@ -125,9 +145,9 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]):
|
||||
outdir=file_download_dir,
|
||||
sensitive_file_upload_protection=sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=file_upload_path_deny_segments,
|
||||
file_upload_allowlist=resolved_allowlist,
|
||||
)
|
||||
self._toolkit_versions = toolkit_versions
|
||||
self._auto_upload_download_files = auto_upload_download_files
|
||||
|
||||
self.custom_tool = self._custom_tools.register
|
||||
self.provider = provider
|
||||
@@ -314,13 +334,14 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]):
|
||||
)
|
||||
|
||||
# Always enhance schema descriptions (type hints and required notes)
|
||||
# regardless of auto_upload_download_files setting
|
||||
# regardless of dangerously_allow_auto_upload_download_files
|
||||
for tool in tools_list:
|
||||
tool.input_parameters = self._file_helper.enhance_schema_descriptions(
|
||||
schema=tool.input_parameters,
|
||||
)
|
||||
|
||||
# Only process file_uploadable schemas when auto_upload_download_files is True
|
||||
# Only process file_uploadable schemas when the caller opted in via
|
||||
# `dangerously_allow_auto_upload_download_files=True`.
|
||||
if self._auto_upload_download_files:
|
||||
for tool in tools_list:
|
||||
tool.input_parameters = (
|
||||
|
||||
@@ -172,6 +172,17 @@ class SensitiveFilePathBlockedError(FileError):
|
||||
"""Raised when a local file path is refused before upload (sensitive directory or credential-like name)."""
|
||||
|
||||
|
||||
class FileUploadPathNotAllowedError(FileError):
|
||||
"""
|
||||
Raised when automatic file upload during tool execution is attempted from a
|
||||
path outside the configured ``file_upload_dirs`` allowlist.
|
||||
|
||||
Only fires for auto-upload (enabled via
|
||||
``dangerously_allow_auto_upload_download_files=True``). Manual upload APIs
|
||||
are not subject to this check.
|
||||
"""
|
||||
|
||||
|
||||
class FileUploadAbortedError(FileError):
|
||||
"""Raised when a ``before_file_upload`` hook returns ``False``."""
|
||||
|
||||
|
||||
+30
-4
@@ -40,9 +40,10 @@ class SDKConfig(te.TypedDict):
|
||||
allow_tracking: te.NotRequired[bool]
|
||||
file_download_dir: te.NotRequired[str]
|
||||
toolkit_versions: te.NotRequired[ToolkitVersionParam]
|
||||
auto_upload_download_files: te.NotRequired[bool]
|
||||
dangerously_allow_auto_upload_download_files: te.NotRequired[bool]
|
||||
sensitive_file_upload_protection: te.NotRequired[bool]
|
||||
file_upload_path_deny_segments: te.NotRequired[t.Sequence[str]]
|
||||
file_upload_dirs: te.NotRequired[t.Union[t.Sequence[str], t.Literal[False]]]
|
||||
|
||||
|
||||
class Composio(t.Generic[TTool, TToolCollection], WithLogger):
|
||||
@@ -105,9 +106,25 @@ class Composio(t.Generic[TTool, TToolCollection], WithLogger):
|
||||
- A dictionary mapping toolkit names to specific versions
|
||||
- A string (e.g., 'latest', '20250906_01') to use the same version for all toolkits
|
||||
- None or omitted to use 'latest' as default
|
||||
:param auto_upload_download_files: Whether to automatically upload and download files. Defaults to True.
|
||||
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file
|
||||
upload and download during tool execution. Defaults to False.
|
||||
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload. Defaults to True.
|
||||
:param file_upload_path_deny_segments: Extra path segment names merged with the built-in denylist.
|
||||
:param file_upload_dirs: Allowlist of directories from which the SDK is allowed
|
||||
to read local files during **automatic** file upload (the flow gated by
|
||||
``dangerously_allow_auto_upload_download_files=True``).
|
||||
|
||||
- ``None`` (default) -> ``[~/.composio/temp]``.
|
||||
- ``False`` -> reject every local path during auto-upload. URLs and
|
||||
in-memory bytes still work because they aren't path-checked.
|
||||
- ``Sequence[str]`` (non-empty) -> use as the allowlist. A file is accepted
|
||||
iff its symlink-resolved absolute path is inside one of these directories
|
||||
on a path-component boundary (``/tmp/foo`` allows ``/tmp/foo/bar`` but
|
||||
NOT ``/tmp/foo-bar``).
|
||||
- ``[]`` -> behaves like ``False`` (kept as an alias; prefer ``False``).
|
||||
- Providing a value REPLACES the default. Include ``~/.composio/temp`` in
|
||||
your list if you want the default staging dir to keep working.
|
||||
- On Windows, entries are compared case-insensitively.
|
||||
"""
|
||||
WithLogger.__init__(self)
|
||||
api_key = kwargs.get("api_key", os.environ.get("COMPOSIO_API_KEY"))
|
||||
@@ -140,14 +157,20 @@ class Composio(t.Generic[TTool, TToolCollection], WithLogger):
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = kwargs.get(
|
||||
"file_upload_path_deny_segments"
|
||||
)
|
||||
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = kwargs.get(
|
||||
"file_upload_dirs"
|
||||
)
|
||||
self.tools = Tools(
|
||||
client=self._client,
|
||||
provider=actual_provider,
|
||||
file_download_dir=kwargs.get("file_download_dir"),
|
||||
toolkit_versions=toolkit_versions,
|
||||
auto_upload_download_files=kwargs.get("auto_upload_download_files", True),
|
||||
dangerously_allow_auto_upload_download_files=kwargs.get(
|
||||
"dangerously_allow_auto_upload_download_files", False
|
||||
),
|
||||
sensitive_file_upload_protection=sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=file_upload_path_deny_segments,
|
||||
file_upload_dirs=file_upload_dirs,
|
||||
)
|
||||
|
||||
self.toolkits = Toolkits(client=self._client)
|
||||
@@ -165,9 +188,12 @@ class Composio(t.Generic[TTool, TToolCollection], WithLogger):
|
||||
self.tool_router = ToolRouter(
|
||||
client=self._client,
|
||||
provider=actual_provider,
|
||||
auto_upload_download_files=kwargs.get("auto_upload_download_files", True),
|
||||
dangerously_allow_auto_upload_download_files=kwargs.get(
|
||||
"dangerously_allow_auto_upload_download_files", False
|
||||
),
|
||||
sensitive_file_upload_protection=sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=file_upload_path_deny_segments,
|
||||
file_upload_dirs=file_upload_dirs,
|
||||
)
|
||||
self.create = self.tool_router.create
|
||||
self.use = self.tool_router.use
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Allowlist enforcement for automatic file upload during tool execution.
|
||||
|
||||
This is only consulted when
|
||||
``dangerously_allow_auto_upload_download_files=True``. Manual upload APIs are
|
||||
not subject to the allowlist (parity with the TypeScript SDK).
|
||||
|
||||
- A local path is accepted iff its symlink-resolved absolute path is inside one
|
||||
of the allowed directories on a path-component boundary. ``/tmp/foo`` allows
|
||||
``/tmp/foo/bar`` but NOT ``/tmp/foo-bar``.
|
||||
- URLs never hit this check.
|
||||
- User-provided ``file_upload_dirs`` fully REPLACES the default
|
||||
``[~/.composio/temp]``.
|
||||
- On Windows, path comparisons are case-insensitive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
from composio.exceptions import (
|
||||
FileUploadPathNotAllowedError,
|
||||
SDKFileNotFoundError,
|
||||
)
|
||||
|
||||
UPLOAD_TEMP_DIRECTORY_NAME = "temp"
|
||||
|
||||
|
||||
def get_default_upload_dir() -> t.Optional[Path]:
|
||||
"""Absolute path of the default upload staging directory
|
||||
(``<home>/.composio/temp``), or None when a home directory cannot be
|
||||
determined."""
|
||||
try:
|
||||
home = Path.home()
|
||||
except (RuntimeError, OSError):
|
||||
return None
|
||||
return (home / ".composio" / UPLOAD_TEMP_DIRECTORY_NAME).resolve()
|
||||
|
||||
|
||||
def ensure_dir_exists(p: Path) -> None:
|
||||
"""Best-effort directory creation. Allowlist enforcement still works if
|
||||
creation fails (e.g. read-only filesystem)."""
|
||||
try:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
FileUploadDirs = t.Union[t.Sequence[str], t.Literal[False], None]
|
||||
"""User-facing type for ``file_upload_dirs``.
|
||||
|
||||
- ``None`` (default) -> use ``[<home>/.composio/temp]``.
|
||||
- ``False`` -> reject every local path during auto-upload (URLs / bytes still
|
||||
work).
|
||||
- ``[]`` -> same as ``False`` (kept as an alias; prefer ``False``).
|
||||
- ``Sequence[str]`` (non-empty) -> allowlist replacing the default.
|
||||
"""
|
||||
|
||||
|
||||
def resolve_effective_upload_allowlist(
|
||||
user_dirs: FileUploadDirs,
|
||||
) -> t.List[Path]:
|
||||
"""Resolve the effective allowlist.
|
||||
|
||||
- ``None`` -> ``[<default>]`` when available, else ``[]`` (fail-closed).
|
||||
- ``False`` -> ``[]`` (explicit "reject all local paths"). URLs and
|
||||
in-memory bytes still work because they aren't path-checked.
|
||||
- ``Sequence[str]`` (including ``[]``) REPLACES the default and is returned
|
||||
verbatim after ``~``-expansion and ``resolve()``. ``[]`` is treated as
|
||||
equivalent to ``False``.
|
||||
- Empty / blank / non-string entries are skipped.
|
||||
- Duplicates are removed (case-insensitive on Windows).
|
||||
"""
|
||||
if user_dirs is None:
|
||||
default = get_default_upload_dir()
|
||||
return [default] if default is not None else []
|
||||
if user_dirs is False:
|
||||
return []
|
||||
|
||||
seen: t.Set[str] = set()
|
||||
out: t.List[Path] = []
|
||||
for raw in user_dirs:
|
||||
if not isinstance(raw, (str, os.PathLike)):
|
||||
continue
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
continue
|
||||
abs_path = Path(s).expanduser().resolve()
|
||||
key = str(abs_path).lower() if sys.platform == "win32" else str(abs_path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(abs_path)
|
||||
return out
|
||||
|
||||
|
||||
def _is_inside_dir(child: Path, parent: Path) -> bool:
|
||||
"""True iff ``child`` equals ``parent`` or is nested inside on a component
|
||||
boundary. Assumes both are absolute and normalized."""
|
||||
try:
|
||||
child_str = str(child)
|
||||
parent_str = str(parent)
|
||||
if sys.platform == "win32":
|
||||
child_str = child_str.lower()
|
||||
parent_str = parent_str.lower()
|
||||
if child_str == parent_str:
|
||||
return True
|
||||
sep = os.sep
|
||||
parent_with_sep = parent_str if parent_str.endswith(sep) else parent_str + sep
|
||||
return child_str.startswith(parent_with_sep)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _format_allowlist(dirs: t.Sequence[Path]) -> str:
|
||||
if not dirs:
|
||||
return " (none configured — all local paths are blocked)"
|
||||
return "\n".join(f" - {d}" for d in dirs)
|
||||
|
||||
|
||||
def _build_help_footer(allowlist: t.Sequence[Path]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"",
|
||||
"Allowed upload directories (file_upload_dirs):",
|
||||
_format_allowlist(allowlist),
|
||||
"",
|
||||
"Fix one of the following:",
|
||||
"",
|
||||
" 1. Recommended — move the file under an allowed directory, or add your",
|
||||
" directory to the allowlist:",
|
||||
" Composio(",
|
||||
" file_upload_dirs=['/abs/path/to/uploads', '~/.composio/temp'],",
|
||||
" )",
|
||||
" Note: user-provided `file_upload_dirs` REPLACES the default",
|
||||
" `~/.composio/temp`. Include it explicitly if you want staged uploads",
|
||||
" to keep working.",
|
||||
"",
|
||||
" 2. Pass the file by URL (http://... / https://...) instead of a",
|
||||
" filesystem path. URLs are never path-checked.",
|
||||
"",
|
||||
" 3. (Dangerous) Bypass the allowlist entirely by opting into automatic",
|
||||
" file upload/download from any readable path:",
|
||||
" Composio(dangerously_allow_auto_upload_download_files=True, ...)",
|
||||
" WARNING: This lets a prompt-injected or misbehaving tool read ANY",
|
||||
" file the process can access. Only enable it in trusted, sandboxed",
|
||||
" environments. The built-in sensitive-path denylist (.ssh, .aws,",
|
||||
" .env, private SSH keys, etc.) still applies unless you also set",
|
||||
" `sensitive_file_upload_protection=False`.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def assert_path_inside_upload_dirs(
|
||||
file_path: t.Union[str, Path],
|
||||
allowlist: t.Sequence[Path],
|
||||
) -> None:
|
||||
"""Raise an elaborate error if the path is missing or outside the allowlist.
|
||||
|
||||
:raises SDKFileNotFoundError: when ``file_path`` does not exist on disk.
|
||||
:raises FileUploadPathNotAllowedError: when resolved path is outside every
|
||||
entry of ``allowlist`` (including when allowlist is empty).
|
||||
"""
|
||||
attempted = str(file_path)
|
||||
abs_path = Path(attempted).expanduser()
|
||||
try:
|
||||
abs_path = abs_path.resolve(strict=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not abs_path.exists():
|
||||
cwd = Path.cwd()
|
||||
parent = abs_path.parent
|
||||
parent_exists = parent.exists()
|
||||
raise SDKFileNotFoundError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": the file does not exist on disk.',
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {abs_path}",
|
||||
f"Process cwd: {cwd}",
|
||||
f"Parent exists: {'yes (' + str(parent) + ')' if parent_exists else 'no (' + str(parent) + ')'}",
|
||||
"",
|
||||
"Common causes:",
|
||||
" - Typo in the filename passed to the tool.",
|
||||
" - Relative path resolved against the wrong working directory",
|
||||
" (relative paths use os.getcwd() at the moment of upload).",
|
||||
" - File was deleted between the tool being called and the upload starting.",
|
||||
"",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
real_path = abs_path.resolve(strict=True)
|
||||
except OSError:
|
||||
real_path = abs_path
|
||||
|
||||
if not allowlist:
|
||||
raise FileUploadPathNotAllowedError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": no upload directories are configured.',
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {real_path}",
|
||||
"",
|
||||
"Automatic file upload during tool execution is locked down by default",
|
||||
"to prevent a prompt-injected tool from exfiltrating server files",
|
||||
"(source code, .env, SSH keys, etc.).",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
for dir_entry in allowlist:
|
||||
try:
|
||||
real_dir = Path(dir_entry).expanduser().resolve(strict=False)
|
||||
except OSError:
|
||||
real_dir = Path(dir_entry)
|
||||
if _is_inside_dir(real_path, real_dir):
|
||||
return
|
||||
|
||||
raise FileUploadPathNotAllowedError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": resolved path is not inside any',
|
||||
"directory in the configured `file_upload_dirs` allowlist.",
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {real_path}",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -75,7 +75,7 @@ class TestAutoUploadDownloadFilesEnabled:
|
||||
tools = Tools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=True,
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
toolkit_versions={"test_toolkit": "20251201_01"},
|
||||
)
|
||||
|
||||
@@ -119,7 +119,7 @@ class TestAutoUploadDownloadFilesEnabled:
|
||||
tools = Tools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=True,
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
toolkit_versions={"test_toolkit": "20251201_01"},
|
||||
)
|
||||
|
||||
@@ -182,7 +182,7 @@ class TestAutoUploadDownloadFilesEnabled:
|
||||
tools = Tools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=True,
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
toolkit_versions={"test_toolkit": "20251201_01"},
|
||||
)
|
||||
|
||||
@@ -243,7 +243,7 @@ class TestAutoUploadDownloadFilesEnabled:
|
||||
tools = Tools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=True,
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
toolkit_versions={"test_toolkit": "20251201_01"},
|
||||
)
|
||||
|
||||
@@ -314,7 +314,6 @@ class TestAutoUploadDownloadFilesDisabled:
|
||||
tools = Tools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=False,
|
||||
toolkit_versions={"test_toolkit": "20251201_01"},
|
||||
)
|
||||
|
||||
@@ -363,7 +362,6 @@ class TestAutoUploadDownloadFilesDisabled:
|
||||
tools = Tools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=False,
|
||||
toolkit_versions={"test_toolkit": "20251201_01"},
|
||||
)
|
||||
|
||||
@@ -419,7 +417,6 @@ class TestAutoUploadDownloadFilesDisabled:
|
||||
tools = Tools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=False,
|
||||
toolkit_versions={"test_toolkit": "20251201_01"},
|
||||
)
|
||||
|
||||
@@ -464,7 +461,6 @@ class TestAutoUploadDownloadFilesDisabled:
|
||||
tools = Tools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=False,
|
||||
toolkit_versions={"test_toolkit": "20251201_01"},
|
||||
)
|
||||
|
||||
@@ -518,28 +514,8 @@ class TestAutoUploadDownloadFilesDisabled:
|
||||
class TestAutoUploadDownloadFilesWithSDK:
|
||||
"""Test cases for auto_upload_download_files with SDK initialization."""
|
||||
|
||||
def test_sdk_passes_auto_upload_download_files_to_tools(self):
|
||||
"""Test that Composio SDK passes auto_upload_download_files to Tools."""
|
||||
from composio.sdk import Composio
|
||||
|
||||
with patch("composio.sdk.HttpClient"):
|
||||
# Test with default (True)
|
||||
with patch.object(Tools, "__init__", return_value=None) as mock_init:
|
||||
mock_provider = Mock()
|
||||
mock_provider.name = "test"
|
||||
|
||||
Composio(
|
||||
provider=mock_provider,
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
# Should have passed auto_upload_download_files=True (default)
|
||||
mock_init.assert_called()
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert call_kwargs.get("auto_upload_download_files", True) is True
|
||||
|
||||
def test_sdk_passes_false_to_tools(self):
|
||||
"""Test that Composio SDK correctly passes auto_upload_download_files=False."""
|
||||
def test_sdk_passes_auto_upload_download_off_to_tools_by_default(self):
|
||||
"""Test that Composio SDK disables automatic file upload/download by default."""
|
||||
from composio.sdk import Composio
|
||||
|
||||
with patch("composio.sdk.HttpClient"):
|
||||
@@ -550,9 +526,33 @@ class TestAutoUploadDownloadFilesWithSDK:
|
||||
Composio(
|
||||
provider=mock_provider,
|
||||
api_key="test-key",
|
||||
auto_upload_download_files=False,
|
||||
)
|
||||
|
||||
mock_init.assert_called()
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert call_kwargs.get("auto_upload_download_files") is False
|
||||
assert (
|
||||
call_kwargs.get("dangerously_allow_auto_upload_download_files")
|
||||
is False
|
||||
)
|
||||
|
||||
def test_sdk_passes_true_when_dangerously_enabled(self):
|
||||
"""Test that Composio SDK passes through dangerously_allow_auto_upload_download_files."""
|
||||
from composio.sdk import Composio
|
||||
|
||||
with patch("composio.sdk.HttpClient"):
|
||||
with patch.object(Tools, "__init__", return_value=None) as mock_init:
|
||||
mock_provider = Mock()
|
||||
mock_provider.name = "test"
|
||||
|
||||
Composio(
|
||||
provider=mock_provider,
|
||||
api_key="test-key",
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
)
|
||||
|
||||
mock_init.assert_called()
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert (
|
||||
call_kwargs.get("dangerously_allow_auto_upload_download_files")
|
||||
is True
|
||||
)
|
||||
|
||||
@@ -576,7 +576,7 @@ def _session(deps, **overrides):
|
||||
kwargs = dict(
|
||||
client=deps["client"],
|
||||
provider=deps["provider"],
|
||||
auto_upload_download_files=True,
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
session_id="s",
|
||||
mcp=MagicMock(),
|
||||
experimental=deps["experimental"],
|
||||
@@ -644,7 +644,7 @@ class TestToolRouterSessionCustomTools:
|
||||
s = ToolRouterSession(
|
||||
client=MagicMock(),
|
||||
provider=MagicMock(),
|
||||
auto_upload_download_files=True,
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
session_id="s",
|
||||
mcp=MagicMock(),
|
||||
experimental=MagicMock(),
|
||||
@@ -663,7 +663,7 @@ class TestMultiExecuteRouting:
|
||||
return ToolRouterSession(
|
||||
client=MagicMock(),
|
||||
provider=MagicMock(),
|
||||
auto_upload_download_files=True,
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
session_id="s",
|
||||
mcp=MagicMock(),
|
||||
experimental=MagicMock(),
|
||||
|
||||
@@ -1322,6 +1322,123 @@ class TestFileUploadableFromUrl:
|
||||
|
||||
assert "Fetch failed" in str(exc_info.value)
|
||||
|
||||
def test_before_file_upload_hook_receives_source_url(self):
|
||||
"""from_path emits ``source="url"`` to the hook for http(s) inputs.
|
||||
|
||||
We abort from the hook to avoid the downstream network path; the
|
||||
pre-abort capture is what we're asserting on.
|
||||
"""
|
||||
from composio.exceptions import FileUploadAbortedError
|
||||
|
||||
mock_client = MagicMock()
|
||||
seen = {}
|
||||
|
||||
def hook(ctx):
|
||||
seen.update(ctx)
|
||||
return False
|
||||
|
||||
with pytest.raises(FileUploadAbortedError):
|
||||
FileUploadable.from_path(
|
||||
client=mock_client,
|
||||
file="https://example.com/photo.png",
|
||||
tool="SEND_EMAIL",
|
||||
toolkit="gmail",
|
||||
before_file_upload=hook,
|
||||
)
|
||||
|
||||
assert seen == {
|
||||
"path": "https://example.com/photo.png",
|
||||
"source": "url",
|
||||
"tool": "SEND_EMAIL",
|
||||
"toolkit": "gmail",
|
||||
}
|
||||
|
||||
def test_before_file_upload_hook_receives_source_path(self, tmp_path):
|
||||
"""from_path emits ``source="path"`` to the hook for local inputs."""
|
||||
from composio.exceptions import FileUploadAbortedError
|
||||
|
||||
f = tmp_path / "doc.txt"
|
||||
f.write_text("hello")
|
||||
mock_client = MagicMock()
|
||||
seen = {}
|
||||
|
||||
def hook(ctx):
|
||||
seen.update(ctx)
|
||||
return False
|
||||
|
||||
with pytest.raises(FileUploadAbortedError):
|
||||
FileUploadable.from_path(
|
||||
client=mock_client,
|
||||
file=str(f),
|
||||
tool="MY_TOOL",
|
||||
toolkit="my_toolkit",
|
||||
before_file_upload=hook,
|
||||
)
|
||||
|
||||
assert seen == {
|
||||
"path": str(f),
|
||||
"source": "path",
|
||||
"tool": "MY_TOOL",
|
||||
"toolkit": "my_toolkit",
|
||||
}
|
||||
|
||||
def test_url_hook_returning_local_path_routes_through_path_branch(self, tmp_path):
|
||||
"""A hook that rewrites a URL into a local path must NOT be fed to
|
||||
``from_url``. It has to route back into the local-file branch so the
|
||||
allowlist / denylist / existence checks all run."""
|
||||
from composio.exceptions import SDKFileNotFoundError
|
||||
|
||||
# A path that's syntactically a path but does not exist — if routing
|
||||
# is correct, we'll get SDKFileNotFoundError from the local branch.
|
||||
# If the bug is still there, we'd hit `from_url` and the URL fetch
|
||||
# would explode (or worse, succeed) instead.
|
||||
rewritten = str(tmp_path / "does-not-exist.txt")
|
||||
|
||||
def hook(ctx):
|
||||
assert ctx["source"] == "url"
|
||||
return rewritten
|
||||
|
||||
mock_client = MagicMock()
|
||||
|
||||
with pytest.raises(SDKFileNotFoundError):
|
||||
FileUploadable.from_path(
|
||||
client=mock_client,
|
||||
file="https://example.com/photo.png",
|
||||
tool="T",
|
||||
toolkit="tk",
|
||||
before_file_upload=hook,
|
||||
)
|
||||
|
||||
@patch("composio.core.models._files._fetch_file_from_url")
|
||||
@patch("composio.core.models._files._upload_bytes_to_s3")
|
||||
def test_path_hook_returning_url_routes_through_url_branch(
|
||||
self, mock_upload, mock_fetch, tmp_path
|
||||
):
|
||||
"""Inverse of the above: a hook on a local path that returns a URL
|
||||
must route through ``from_url``, not stat the URL string as a file."""
|
||||
f = tmp_path / "local.txt"
|
||||
f.write_text("hi")
|
||||
|
||||
mock_fetch.return_value = ("photo.png", b"x", "image/png")
|
||||
mock_upload.return_value = "s3-key"
|
||||
|
||||
def hook(ctx):
|
||||
assert ctx["source"] == "path"
|
||||
return "https://example.com/photo.png"
|
||||
|
||||
mock_client = MagicMock()
|
||||
result = FileUploadable.from_path(
|
||||
client=mock_client,
|
||||
file=str(f),
|
||||
tool="T",
|
||||
toolkit="tk",
|
||||
before_file_upload=hook,
|
||||
)
|
||||
|
||||
# If routing worked, the URL fetch path was taken.
|
||||
mock_fetch.assert_called_once_with("https://example.com/photo.png")
|
||||
assert result.s3key == "s3-key"
|
||||
|
||||
|
||||
class TestFileHelperWithUrls:
|
||||
"""Test cases for FileHelper handling URLs in file uploads."""
|
||||
|
||||
@@ -56,9 +56,10 @@ class TestComposioSDK:
|
||||
"allow_tracking",
|
||||
"file_download_dir",
|
||||
"toolkit_versions",
|
||||
"auto_upload_download_files",
|
||||
"dangerously_allow_auto_upload_download_files",
|
||||
"sensitive_file_upload_protection",
|
||||
"file_upload_path_deny_segments",
|
||||
"file_upload_dirs",
|
||||
}
|
||||
assert set(SDKConfig.__annotations__.keys()) == expected_fields
|
||||
|
||||
|
||||
@@ -855,12 +855,17 @@ class TestToolExecution:
|
||||
scoped = before_file_upload(tools=["OTHER_TOOL"])(lambda p, t, k: p + "_X")
|
||||
fn = merge_before_file_upload([scoped], tool="MY", toolkit="gh")
|
||||
assert fn is not None
|
||||
assert fn("/a", "MY", "gh") == "/a"
|
||||
assert (
|
||||
fn({"path": "/a", "source": "path", "tool": "MY", "toolkit": "gh"}) == "/a"
|
||||
)
|
||||
|
||||
all_tools = before_file_upload(lambda p, t, k: p + "_Y")
|
||||
fn2 = merge_before_file_upload([all_tools], tool="MY", toolkit="gh")
|
||||
assert fn2 is not None
|
||||
assert fn2("/a", "MY", "gh") == "/a_Y"
|
||||
assert (
|
||||
fn2({"path": "/a", "source": "path", "tool": "MY", "toolkit": "gh"})
|
||||
== "/a_Y"
|
||||
)
|
||||
|
||||
def test_merge_before_file_upload_chains_modifiers(self):
|
||||
from composio.core.models._modifiers import (
|
||||
@@ -872,7 +877,103 @@ class TestToolExecution:
|
||||
m2 = before_file_upload(lambda p, t, k: f"{p}|2")
|
||||
fn = merge_before_file_upload([m1, m2], tool="T", toolkit="k")
|
||||
assert fn is not None
|
||||
assert fn("p", "T", "k") == "p|1|2"
|
||||
assert (
|
||||
fn({"path": "p", "source": "path", "tool": "T", "toolkit": "k"}) == "p|1|2"
|
||||
)
|
||||
|
||||
def test_before_file_upload_context_form_receives_source(self):
|
||||
"""New-form hooks (single ``context`` arg) see ``source`` in the context."""
|
||||
from composio.core.models._modifiers import (
|
||||
before_file_upload,
|
||||
merge_before_file_upload,
|
||||
)
|
||||
|
||||
seen: t.Dict[str, t.Any] = {}
|
||||
|
||||
def hook(ctx):
|
||||
seen.update(ctx)
|
||||
return ctx["path"] + "!"
|
||||
|
||||
m = before_file_upload(hook)
|
||||
fn = merge_before_file_upload([m], tool="T", toolkit="k")
|
||||
assert fn is not None
|
||||
|
||||
out = fn({"path": "/x", "source": "path", "tool": "T", "toolkit": "k"})
|
||||
assert out == "/x!"
|
||||
assert seen == {"path": "/x", "source": "path", "tool": "T", "toolkit": "k"}
|
||||
|
||||
seen.clear()
|
||||
out = fn(
|
||||
{
|
||||
"path": "https://example.com/a.pdf",
|
||||
"source": "url",
|
||||
"tool": "T",
|
||||
"toolkit": "k",
|
||||
}
|
||||
)
|
||||
assert out == "https://example.com/a.pdf!"
|
||||
assert seen["source"] == "url"
|
||||
|
||||
def test_before_file_upload_legacy_3arg_form_still_works(self):
|
||||
"""Hooks declared as ``(path, tool, toolkit)`` keep working unchanged."""
|
||||
from composio.core.models._modifiers import (
|
||||
before_file_upload,
|
||||
merge_before_file_upload,
|
||||
)
|
||||
|
||||
def legacy(path, tool, toolkit):
|
||||
return f"{path}@{tool}/{toolkit}"
|
||||
|
||||
m = before_file_upload(legacy)
|
||||
fn = merge_before_file_upload([m], tool="T", toolkit="k")
|
||||
assert fn is not None
|
||||
# Driver always passes context form; adapter unwraps to positional args.
|
||||
assert (
|
||||
fn({"path": "/x", "source": "path", "tool": "T", "toolkit": "k"})
|
||||
== "/x@T/k"
|
||||
)
|
||||
|
||||
def test_before_file_upload_chain_preserves_source(self):
|
||||
"""``source`` stays stable as the ``path`` is rewritten down the chain."""
|
||||
from composio.core.models._modifiers import (
|
||||
before_file_upload,
|
||||
merge_before_file_upload,
|
||||
)
|
||||
|
||||
seen_sources: t.List[str] = []
|
||||
|
||||
def h1(ctx):
|
||||
seen_sources.append(ctx["source"])
|
||||
return ctx["path"] + "|1"
|
||||
|
||||
def h2(ctx):
|
||||
seen_sources.append(ctx["source"])
|
||||
return ctx["path"] + "|2"
|
||||
|
||||
fn = merge_before_file_upload(
|
||||
[before_file_upload(h1), before_file_upload(h2)], tool="T", toolkit="k"
|
||||
)
|
||||
assert fn is not None
|
||||
out = fn({"path": "p", "source": "url", "tool": "T", "toolkit": "k"})
|
||||
assert out == "p|1|2"
|
||||
assert seen_sources == ["url", "url"]
|
||||
|
||||
def test_before_file_upload_abort_propagates_as_false(self):
|
||||
from composio.core.models._modifiers import (
|
||||
before_file_upload,
|
||||
merge_before_file_upload,
|
||||
)
|
||||
|
||||
def abort(ctx):
|
||||
return False
|
||||
|
||||
fn = merge_before_file_upload(
|
||||
[before_file_upload(abort)], tool="T", toolkit="k"
|
||||
)
|
||||
assert fn is not None
|
||||
assert (
|
||||
fn({"path": "/x", "source": "path", "tool": "T", "toolkit": "k"}) is False
|
||||
)
|
||||
|
||||
def test_execute_includes_before_file_upload_modifier_in_compose(
|
||||
self,
|
||||
@@ -888,6 +989,7 @@ class TestToolExecution:
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
toolkit_versions={"github": "20251201_01"},
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
)
|
||||
|
||||
github_tool = self.create_mock_tool("GITHUB_GET_REPOS", "github")
|
||||
@@ -923,7 +1025,17 @@ class TestToolExecution:
|
||||
|
||||
bfu = cap.get("bfu")
|
||||
assert bfu is not None
|
||||
assert bfu("/p", "GITHUB_GET_REPOS", "github") == "/p>"
|
||||
assert (
|
||||
bfu(
|
||||
{
|
||||
"path": "/p",
|
||||
"source": "path",
|
||||
"tool": "GITHUB_GET_REPOS",
|
||||
"toolkit": "github",
|
||||
}
|
||||
)
|
||||
== "/p>"
|
||||
)
|
||||
|
||||
def test_execute_with_environment_variable_toolkit_version(self):
|
||||
"""Test that execute uses environment variable for toolkit version."""
|
||||
|
||||
@@ -966,13 +966,14 @@ class TestToolRouter:
|
||||
session = tool_router.create(user_id="user_123")
|
||||
result = session.tools()
|
||||
|
||||
# Verify Tools was instantiated
|
||||
# Verify Tools was instantiated (resolved flag; default ToolRouter is off)
|
||||
mock_tools_class.assert_called_once_with(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=True,
|
||||
dangerously_allow_auto_upload_download_files=False,
|
||||
sensitive_file_upload_protection=True,
|
||||
file_upload_path_deny_segments=None,
|
||||
file_upload_dirs=None,
|
||||
)
|
||||
|
||||
# Verify get_raw_tool_router_meta_tools was called
|
||||
@@ -1267,7 +1268,7 @@ class TestToolRouterExecution:
|
||||
real_tools = RealTools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=False,
|
||||
dangerously_allow_auto_upload_download_files=False,
|
||||
)
|
||||
execute_fn = real_tools._wrap_execute_tool_for_tool_router(
|
||||
session_id="session_123"
|
||||
@@ -1318,7 +1319,7 @@ class TestToolRouterExecution:
|
||||
real_tools = RealTools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=False,
|
||||
dangerously_allow_auto_upload_download_files=False,
|
||||
)
|
||||
|
||||
modifiers = [
|
||||
@@ -1361,7 +1362,7 @@ class TestToolRouterExecution:
|
||||
real_tools = RealTools(
|
||||
client=mock_client,
|
||||
provider=mock_provider,
|
||||
auto_upload_download_files=False,
|
||||
dangerously_allow_auto_upload_download_files=False,
|
||||
)
|
||||
execute_fn = real_tools._wrap_execute_tool_for_tool_router(
|
||||
session_id="session_123"
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for the auto-upload directory allowlist (parity with TS sdk)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.core.models.base import allow_tracking
|
||||
from composio.exceptions import (
|
||||
FileUploadPathNotAllowedError,
|
||||
SDKFileNotFoundError,
|
||||
)
|
||||
from composio.utils.upload_dir_allowlist import (
|
||||
assert_path_inside_upload_dirs,
|
||||
get_default_upload_dir,
|
||||
resolve_effective_upload_allowlist,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_telemetry():
|
||||
token = allow_tracking.set(False)
|
||||
yield
|
||||
allow_tracking.reset(token)
|
||||
|
||||
|
||||
class TestResolveEffectiveUploadAllowlist:
|
||||
def test_none_returns_default(self):
|
||||
result = resolve_effective_upload_allowlist(None)
|
||||
default = get_default_upload_dir()
|
||||
assert default is not None, "Expected a default upload dir in this env."
|
||||
assert result == [default]
|
||||
|
||||
def test_empty_list_replaces_default(self):
|
||||
assert resolve_effective_upload_allowlist([]) == []
|
||||
|
||||
def test_false_means_no_local_paths(self):
|
||||
# Explicit "reject all local paths during auto-upload". Equivalent to []
|
||||
# but reads better at call sites.
|
||||
assert resolve_effective_upload_allowlist(False) == []
|
||||
|
||||
def test_explicit_list_replaces_default(self, tmp_path: Path):
|
||||
result = resolve_effective_upload_allowlist([str(tmp_path)])
|
||||
assert result == [tmp_path.resolve()]
|
||||
|
||||
def test_tilde_is_expanded(self):
|
||||
result = resolve_effective_upload_allowlist(["~"])
|
||||
assert result == [Path.home().resolve()]
|
||||
|
||||
def test_skips_blank_and_non_string_entries(self, tmp_path: Path):
|
||||
result = resolve_effective_upload_allowlist([str(tmp_path), "", " "])
|
||||
assert result == [tmp_path.resolve()]
|
||||
|
||||
def test_deduplicates_entries(self, tmp_path: Path):
|
||||
result = resolve_effective_upload_allowlist([str(tmp_path), str(tmp_path)])
|
||||
assert result == [tmp_path.resolve()]
|
||||
|
||||
|
||||
class TestAssertPathInsideUploadDirs:
|
||||
def test_accepts_direct_child(self, tmp_path: Path):
|
||||
allowed = tmp_path
|
||||
f = allowed / "a.txt"
|
||||
f.write_text("hi")
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
|
||||
def test_accepts_nested_child(self, tmp_path: Path):
|
||||
allowed = tmp_path
|
||||
nested = allowed / "sub" / "deeper"
|
||||
nested.mkdir(parents=True)
|
||||
f = nested / "a.txt"
|
||||
f.write_text("hi")
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
|
||||
def test_rejects_path_outside(self, tmp_path: Path):
|
||||
allowed = tmp_path / "inside"
|
||||
outside = tmp_path / "outside"
|
||||
allowed.mkdir()
|
||||
outside.mkdir()
|
||||
f = outside / "a.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises(FileUploadPathNotAllowedError):
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
|
||||
def test_enforces_component_boundary(self, tmp_path: Path):
|
||||
# /tmp/foo must NOT accept /tmp/foo-bar/x.
|
||||
allowed = tmp_path / "foo"
|
||||
sibling = tmp_path / "foo-bar"
|
||||
allowed.mkdir()
|
||||
sibling.mkdir()
|
||||
f = sibling / "x.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises(FileUploadPathNotAllowedError):
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
|
||||
def test_raises_not_found_when_missing(self, tmp_path: Path):
|
||||
allowed = tmp_path
|
||||
missing = tmp_path / "does_not_exist.txt"
|
||||
with pytest.raises(SDKFileNotFoundError) as excinfo:
|
||||
assert_path_inside_upload_dirs(str(missing), [allowed])
|
||||
assert "does not exist on disk" in str(excinfo.value)
|
||||
assert "file_upload_dirs" in str(excinfo.value)
|
||||
|
||||
def test_empty_allowlist_fails_closed(self, tmp_path: Path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises(FileUploadPathNotAllowedError) as excinfo:
|
||||
assert_path_inside_upload_dirs(str(f), [])
|
||||
msg = str(excinfo.value)
|
||||
assert "no upload directories are configured" in msg
|
||||
assert "dangerously_allow_auto_upload_download_files" in msg
|
||||
|
||||
def test_error_message_lists_allowlist(self, tmp_path: Path):
|
||||
allowed = tmp_path / "inside"
|
||||
outside = tmp_path / "outside"
|
||||
allowed.mkdir()
|
||||
outside.mkdir()
|
||||
f = outside / "a.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises(FileUploadPathNotAllowedError) as excinfo:
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
msg = str(excinfo.value)
|
||||
assert str(allowed) in msg
|
||||
assert "file_upload_dirs" in msg
|
||||
assert "dangerously_allow_auto_upload_download_files" in msg
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only symlink semantics")
|
||||
def test_rejects_symlink_pointing_outside(self, tmp_path: Path):
|
||||
allowed = tmp_path / "inside"
|
||||
outside = tmp_path / "outside"
|
||||
allowed.mkdir()
|
||||
outside.mkdir()
|
||||
secret = outside / "secret.txt"
|
||||
secret.write_text("pw")
|
||||
link = allowed / "link.txt"
|
||||
os.symlink(secret, link)
|
||||
|
||||
with pytest.raises(FileUploadPathNotAllowedError):
|
||||
assert_path_inside_upload_dirs(str(link), [allowed])
|
||||
|
||||
|
||||
class TestToolsWiresAllowlistCorrectly:
|
||||
"""Verifies ``Tools`` forwards the right allowlist to ``FileHelper`` given
|
||||
the full flag/value matrix."""
|
||||
|
||||
@staticmethod
|
||||
def _make_tools(
|
||||
dangerously_allow_auto_upload_download_files: bool,
|
||||
file_upload_dirs,
|
||||
):
|
||||
from composio.core.models.tools import Tools
|
||||
from unittest.mock import Mock
|
||||
|
||||
return Tools(
|
||||
client=Mock(),
|
||||
provider=Mock(name="provider"),
|
||||
dangerously_allow_auto_upload_download_files=dangerously_allow_auto_upload_download_files,
|
||||
file_upload_dirs=file_upload_dirs,
|
||||
)
|
||||
|
||||
def test_flag_off_means_no_allowlist_is_ever_built(self):
|
||||
# Flag off ⇒ auto-upload code path is never run. We pass ``None`` to
|
||||
# FileHelper so manual/no-op paths don't accidentally enforce anything.
|
||||
t = self._make_tools(False, None)
|
||||
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
|
||||
|
||||
def test_flag_off_ignores_file_upload_dirs_entirely(self):
|
||||
t = self._make_tools(False, ["/tmp/whatever"])
|
||||
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
|
||||
|
||||
t = self._make_tools(False, False)
|
||||
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
|
||||
|
||||
t = self._make_tools(False, [])
|
||||
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
|
||||
|
||||
def test_flag_on_none_uses_default(self):
|
||||
t = self._make_tools(True, None)
|
||||
assert t._file_helper._file_upload_allowlist == [ # noqa: SLF001
|
||||
get_default_upload_dir()
|
||||
]
|
||||
|
||||
def test_flag_on_false_means_empty_allowlist(self):
|
||||
t = self._make_tools(True, False)
|
||||
assert t._file_helper._file_upload_allowlist == [] # noqa: SLF001
|
||||
|
||||
def test_flag_on_empty_list_means_empty_allowlist(self):
|
||||
t = self._make_tools(True, [])
|
||||
assert t._file_helper._file_upload_allowlist == [] # noqa: SLF001
|
||||
|
||||
def test_flag_on_explicit_dirs_replace_default(self, tmp_path: Path):
|
||||
t = self._make_tools(True, [str(tmp_path)])
|
||||
assert t._file_helper._file_upload_allowlist == [ # noqa: SLF001
|
||||
tmp_path.resolve()
|
||||
]
|
||||
|
||||
|
||||
class TestFileUploadableAllowlistWiring:
|
||||
"""Sanity: FileUploadable.from_path must honor the allowlist param."""
|
||||
|
||||
def test_from_path_with_allowlist_blocks_outside(self, tmp_path: Path):
|
||||
from composio.core.models._files import FileUploadable
|
||||
from unittest.mock import Mock
|
||||
|
||||
inside = tmp_path / "inside"
|
||||
outside = tmp_path / "outside"
|
||||
inside.mkdir()
|
||||
outside.mkdir()
|
||||
f = outside / "a.txt"
|
||||
f.write_text("hi")
|
||||
|
||||
with pytest.raises(FileUploadPathNotAllowedError):
|
||||
FileUploadable.from_path(
|
||||
client=Mock(),
|
||||
file=str(f),
|
||||
tool="T",
|
||||
toolkit="tk",
|
||||
file_upload_allowlist=[inside],
|
||||
)
|
||||
|
||||
def test_from_path_without_allowlist_skips_check(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Passing ``file_upload_allowlist=None`` must NOT trigger the check.
|
||||
|
||||
This simulates manual upload paths and the no-auto-upload default.
|
||||
"""
|
||||
from composio.core.models import _files as files_mod
|
||||
from composio.core.models._files import FileUploadable
|
||||
from unittest.mock import Mock
|
||||
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hi")
|
||||
|
||||
# Short-circuit the network-bound parts.
|
||||
mock_client = Mock()
|
||||
mock_client.post.return_value = Mock(
|
||||
new_presigned_url="https://example/upload", key="k"
|
||||
)
|
||||
monkeypatch.setattr(files_mod, "upload", lambda url, file: True)
|
||||
|
||||
result = FileUploadable.from_path(
|
||||
client=mock_client,
|
||||
file=str(f),
|
||||
tool="T",
|
||||
toolkit="tk",
|
||||
file_upload_allowlist=None,
|
||||
)
|
||||
assert result.s3key == "k"
|
||||
@@ -6,10 +6,11 @@ Composio SDK includes an automatic file handling system that manages file upload
|
||||
|
||||
The file handling system in Composio SDK is:
|
||||
|
||||
- **Opt-out**: Enabled by default but can be disabled
|
||||
- **Automatic**: Handles file uploads and downloads transparently
|
||||
- **Configurable**: Can be disabled for manual file handling
|
||||
- **Sensitive path protection (Node)**: Local string paths used for upload are checked by default against a denylist of path segments and file names (see below)
|
||||
- **Opt-in**: Disabled by default. Set `dangerouslyAllowAutoUploadDownloadFiles: true` to enable.
|
||||
- **Automatic** (when enabled): Handles file uploads and downloads transparently.
|
||||
- **Allowlisted**: Even when enabled, local upload paths must resolve inside one of the directories listed in `fileUploadDirs` (default `[~/.composio/temp]`). See [Restricting upload paths with `fileUploadDirs`](#restricting-upload-paths-with-fileuploaddirs) below.
|
||||
- **Configurable**: Manual handling via `composio.files.upload()` / `composio.files.download()` is always available and is **not** subject to `fileUploadDirs`.
|
||||
- **Sensitive path protection (Node)**: Local string paths used for upload are checked by default against a denylist of path segments and file names (see below).
|
||||
|
||||
## Security: sensitive local paths
|
||||
|
||||
@@ -67,10 +68,10 @@ const toolSchema = {
|
||||
}
|
||||
```
|
||||
|
||||
When executing a tool with file upload:
|
||||
When executing a tool with file upload (auto-handling enabled):
|
||||
|
||||
```typescript
|
||||
// With auto upload enabled (default)
|
||||
// Requires `dangerouslyAllowAutoUploadDownloadFiles: true` AND a path inside `fileUploadDirs`
|
||||
const result = await composio.tools.execute('your-tool', {
|
||||
arguments: {
|
||||
file: '/path/to/local/file.txt' // Local file path
|
||||
@@ -124,22 +125,48 @@ Example tool response with file download:
|
||||
}
|
||||
```
|
||||
|
||||
## Disabling Auto File Handling
|
||||
## Default: auto file handling is off
|
||||
|
||||
You can disable automatic file handling when initializing the SDK:
|
||||
Automatic file upload/download during tool execution is **disabled** unless you pass `dangerouslyAllowAutoUploadDownloadFiles: true` to the `Composio` constructor.
|
||||
|
||||
## Restricting upload paths with `fileUploadDirs`
|
||||
|
||||
When auto-handling is on, the SDK only reads local files from directories you have explicitly allowlisted via `fileUploadDirs`. Anything outside the list is rejected with `ComposioFileUploadPathNotAllowedError` before the file is read.
|
||||
|
||||
| `fileUploadDirs` value | Effective allowlist |
|
||||
| ---------------------- | ------------------- |
|
||||
| omitted / `undefined` | `[~/.composio/temp]` (default staging dir) |
|
||||
| `false` | `[]` — **all** local paths rejected (URLs and `File`/`Blob` still work) |
|
||||
| `[]` | `[]` — same as `false`; explicit "no local paths" |
|
||||
| `[<dir1>, <dir2>, …]` | exactly those directories. `~` is expanded; entries are `realpath`-resolved; comparison is on a path-component boundary (`/tmp/foo` allows `/tmp/foo/bar` but **not** `/tmp/foo-bar`) |
|
||||
|
||||
URLs (`http://…` / `https://…`) and JavaScript `File` / `Blob` objects are not subject to the allowlist — only local string paths are. The denylist (`sensitiveFileUploadProtection`) still runs in addition to the allowlist; both must accept the path.
|
||||
|
||||
```typescript
|
||||
import { Composio } from '@composio/core';
|
||||
|
||||
// Widen the allowlist to the directories your code actually reads from.
|
||||
const composio = new Composio({
|
||||
apiKey: 'your-api-key',
|
||||
autoUploadDownloadFiles: false
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
// Replaces the default `[~/.composio/temp]`. Include it explicitly if you
|
||||
// still want the default staging dir to keep working.
|
||||
fileUploadDirs: ['/srv/agent/uploads', '~/.composio/temp'],
|
||||
});
|
||||
|
||||
// Reject every local path; only URLs and File/Blob objects work.
|
||||
const composioStrict = new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
fileUploadDirs: false,
|
||||
});
|
||||
```
|
||||
|
||||
If you want to upload from arbitrary paths without widening the allowlist, use the manual `composio.files.upload(...)` API below — it bypasses `fileUploadDirs` by design (the caller is expected to control the path).
|
||||
|
||||
## Manual File Handling
|
||||
|
||||
When auto file handling is disabled, you'll need to handle the file operations yourself using the `composio.files` API:
|
||||
When auto file handling is not enabled, you'll need to handle the file operations yourself using the `composio.files` API:
|
||||
|
||||
### Manual Upload
|
||||
|
||||
@@ -193,12 +220,13 @@ if (result.data.file?.s3url) {
|
||||
|
||||
## File Storage Location
|
||||
|
||||
Downloaded files are stored in a temporary directory:
|
||||
Downloaded files are stored in:
|
||||
|
||||
- Location: `~/.composio/files/` (user's home directory)
|
||||
- Files are named using the pattern: `{toolSlug}_{timestamp}.{extension}`
|
||||
- The directory is created automatically when needed
|
||||
- File extensions are derived from the MIME type
|
||||
- **Default**: `~/.composio/files/` (user's home directory).
|
||||
- **Custom**: pass `fileDownloadDir: '/some/abs/path'` to the `Composio` constructor.
|
||||
- Files are named using the pattern: `{toolSlug}_{timestamp}.{extension}` (server-synthesized; the S3 URL never affects the local filename).
|
||||
- The directory is created automatically when needed.
|
||||
- File extensions are derived from the MIME type.
|
||||
|
||||
## Error Handling
|
||||
|
||||
@@ -209,6 +237,7 @@ import {
|
||||
ComposioFileUploadError,
|
||||
ComposioSensitiveFilePathBlockedError,
|
||||
ComposioFileUploadAbortedError,
|
||||
ComposioFileUploadPathNotAllowedError,
|
||||
} from '@composio/core';
|
||||
|
||||
try {
|
||||
@@ -220,7 +249,9 @@ try {
|
||||
dangerouslySkipVersionCheck: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ComposioSensitiveFilePathBlockedError) {
|
||||
if (error instanceof ComposioFileUploadPathNotAllowedError) {
|
||||
console.error('Path is outside fileUploadDirs:', error.message);
|
||||
} else if (error instanceof ComposioSensitiveFilePathBlockedError) {
|
||||
console.error('Blocked sensitive path:', error.message);
|
||||
} else if (error instanceof ComposioFileUploadAbortedError) {
|
||||
console.error('Upload aborted by beforeFileUpload:', error.message);
|
||||
|
||||
+106
-5
@@ -11,9 +11,11 @@ const composio = new Composio({
|
||||
apiKey: 'your-api-key',
|
||||
baseURL: 'https://api.composio.dev', // Optional: Custom API endpoint
|
||||
allowTracking: true, // Optional: Enable/disable telemetry
|
||||
autoUploadDownloadFiles: true, // Optional: Enable/disable automatic file handling
|
||||
dangerouslyAllowAutoUploadDownloadFiles: false, // Optional: set to true to opt in to automatic file handling (default: false)
|
||||
sensitiveFileUploadProtection: true, // Optional: block uploads from sensitive paths (Node; default true)
|
||||
fileUploadPathDenySegments: undefined, // Optional: extra path component denylist
|
||||
fileUploadDirs: undefined, // Optional: allowlist for automatic upload (defaults to [~/.composio/temp])
|
||||
fileDownloadDir: undefined, // Optional: where auto-downloaded files are written (defaults to ~/.composio/files)
|
||||
provider: new OpenAIProvider(), // Optional: Custom provider
|
||||
});
|
||||
```
|
||||
@@ -27,9 +29,11 @@ The `Composio` constructor accepts a configuration object with the following pro
|
||||
| `apiKey` | string | Yes | - | Your Composio API key |
|
||||
| `baseURL` | string | No | `https://api.composio.dev` | The base URL for the Composio API |
|
||||
| `allowTracking` | boolean | No | `true` | Whether to allow analytics/tracking |
|
||||
| `autoUploadDownloadFiles`| boolean | No | `true` | Whether to automatically handle file operations |
|
||||
| `dangerouslyAllowAutoUploadDownloadFiles` | boolean | No | `false` | Opt in to automatic file upload/download during tool execution |
|
||||
| `sensitiveFileUploadProtection` | `boolean` | No | `true` (Node) | When true, local upload paths are checked against a denylist of sensitive segments and credential-like names before read/upload. |
|
||||
| `fileUploadPathDenySegments` | `string[]` | No | `undefined` | Additional path *components* merged with the built-in denylist. |
|
||||
| `fileUploadDirs` | `string[] \| false` | No | `[~/.composio/temp]` | Allowlist of directories from which the SDK may read local files during **automatic** upload (when `dangerouslyAllowAutoUploadDownloadFiles: true`). Pass `false` (or `[]`) to reject every local path; URLs and `File`/`Blob` objects still work. Providing a list **replaces** the default — include `~/.composio/temp` explicitly if you want the default staging dir to keep working. Does **not** affect manual `composio.files.upload()` calls. |
|
||||
| `fileDownloadDir` | `string` | No | `~/.composio/files` | Directory where files downloaded during tool execution (and `composio.files.download()`) are written. Relative paths resolve against `process.cwd()` at SDK-init time. |
|
||||
| `provider` | `BaseComposioProvider` | No | `new OpenAIProvider()` | The provider to use for this Composio instance |
|
||||
|
||||
## Properties
|
||||
@@ -97,14 +101,49 @@ const composio = new Composio({
|
||||
});
|
||||
```
|
||||
|
||||
### Disable Automatic File Handling
|
||||
### Automatic file handling (opt-in)
|
||||
|
||||
Automatic upload/download of file-marked tool fields is **off** by default. Set `dangerouslyAllowAutoUploadDownloadFiles: true` only if you intend to use that behavior:
|
||||
|
||||
```typescript
|
||||
import { Composio } from '@composio/core';
|
||||
|
||||
const composio = new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY,
|
||||
autoUploadDownloadFiles: false,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### What the LLM sees vs. what the SDK does
|
||||
|
||||
The flag is a **contract between the SDK and the model**. The schema handed
|
||||
to the LLM always reflects what will actually happen at runtime:
|
||||
|
||||
- **Flag `true`** — `composio.tools.get(...)` rewrites `file_uploadable`
|
||||
inputs down to `{ type: 'string', format: 'path' }`. The model passes a
|
||||
path or URL, the SDK stages it, the backend receives a proper
|
||||
`{ name, mimetype, s3key }` object. Works end-to-end.
|
||||
- **Flag `false`** (or omitted) — the raw backend shape
|
||||
(`{ name, mimetype, s3key }`) is preserved. You are expected to stage
|
||||
files yourself via `composio.files.upload(...)` and inject the returned
|
||||
descriptor into the tool arguments before calling `tools.execute`. Handing
|
||||
this shape directly to an LLM is not recommended — the model cannot
|
||||
produce a valid `s3key`. On first execution of a file-uploadable tool in
|
||||
this mode, the SDK emits a single warning per tool slug nudging you
|
||||
toward either enabling the flag or staging manually.
|
||||
|
||||
Manual staging example (flag off):
|
||||
|
||||
```typescript
|
||||
const staged = await composio.files.upload({
|
||||
file: '/tmp/report.pdf',
|
||||
toolSlug: 'SOME_FILE_TOOL',
|
||||
toolkitSlug: 'some-toolkit',
|
||||
});
|
||||
|
||||
await composio.tools.execute('SOME_FILE_TOOL', {
|
||||
userId: 'u',
|
||||
arguments: { file: staged }, // { name, mimetype, s3key }
|
||||
});
|
||||
```
|
||||
|
||||
@@ -121,7 +160,12 @@ await composio.tools.execute(
|
||||
'SOME_FILE_TOOL',
|
||||
{ userId: 'u', arguments: { file: '/tmp/report.pdf' }, dangerouslySkipVersionCheck: true },
|
||||
{
|
||||
beforeFileUpload: async ({ path, toolSlug, toolkitSlug }) => {
|
||||
beforeFileUpload: async ({ path, source, toolSlug, toolkitSlug }) => {
|
||||
// `source` discriminates the input:
|
||||
// 'path' — a local filesystem path
|
||||
// 'url' — an http(s):// URL
|
||||
// 'file' — a File object; `path` is `file.name` (filename only)
|
||||
if (source !== 'path') return path; // let URLs / File objects through
|
||||
// return path, a different path, or false to abort
|
||||
return path;
|
||||
},
|
||||
@@ -130,3 +174,60 @@ await composio.tools.execute(
|
||||
```
|
||||
|
||||
See [Auto upload and download](./advanced/auto-upload-download.md#security-sensitive-local-paths) for the security model and error types.
|
||||
|
||||
### Restricting automatic uploads to specific directories
|
||||
|
||||
When `dangerouslyAllowAutoUploadDownloadFiles: true`, the SDK only reads local
|
||||
files from directories in `fileUploadDirs`. This stacks with (it does NOT
|
||||
replace) the sensitive-path denylist.
|
||||
|
||||
```typescript
|
||||
import { Composio } from '@composio/core';
|
||||
|
||||
const composio = new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
// User-provided list REPLACES the default `[~/.composio/temp]`.
|
||||
// Include it explicitly if you want staged uploads to keep working.
|
||||
fileUploadDirs: ['/srv/agent/uploads', '~/.composio/temp'],
|
||||
});
|
||||
```
|
||||
|
||||
A local path is accepted if its symlink-resolved absolute path is inside one
|
||||
of these directories on a path-component boundary. Paths outside the allowlist
|
||||
throw `ComposioFileUploadPathNotAllowedError`. Missing files throw
|
||||
`ComposioFileNotFoundError`. URLs (`http(s)://...`) and `File`/`Blob` objects
|
||||
are not path-checked.
|
||||
|
||||
**Manual upload API (`composio.files.upload(...)`) is _not_ subject to this
|
||||
allowlist** — it bypasses the allowlist check entirely. Use the allowlist to
|
||||
constrain what models/agents can ask the SDK to upload during tool execution.
|
||||
|
||||
#### Blocking local paths entirely
|
||||
|
||||
Pass `false` to allow only URLs and in-memory `File`/`Blob` objects during
|
||||
automatic upload:
|
||||
|
||||
```typescript
|
||||
const composio = new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
fileUploadDirs: false, // reject every filesystem path for auto-upload
|
||||
});
|
||||
```
|
||||
|
||||
`fileUploadDirs: []` behaves identically; prefer `false` for readability.
|
||||
|
||||
### Changing the download directory
|
||||
|
||||
```typescript
|
||||
import { Composio } from '@composio/core';
|
||||
|
||||
const composio = new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY!,
|
||||
fileDownloadDir: '/var/app/composio-downloads',
|
||||
});
|
||||
```
|
||||
|
||||
Files returned by tools as `s3url` are streamed into this directory. The
|
||||
default is `~/.composio/files`.
|
||||
|
||||
@@ -7,9 +7,9 @@ This package tests `@composio/core` file operations in Cloudflare Workers enviro
|
||||
Verifies that:
|
||||
1. `composio.files.upload()` throws the expected "not supported in Cloudflare Workers" error
|
||||
2. `composio.files.download()` throws the expected "not supported in Cloudflare Workers" error
|
||||
3. The `FileToolModifier.workerd.ts` is correctly loaded and provides the expected error message when `autoUploadDownloadFiles` is explicitly enabled
|
||||
4. Composio initializes correctly with the default configuration (`autoUploadDownloadFiles: false` in workerd runtime)
|
||||
5. Composio can be explicitly initialized with `autoUploadDownloadFiles: false`
|
||||
3. The `FileToolModifier.workerd.ts` is correctly loaded and provides the expected error message when `dangerouslyAllowAutoUploadDownloadFiles` is explicitly enabled
|
||||
4. Composio initializes correctly with the default configuration (`dangerouslyAllowAutoUploadDownloadFiles: false` in workerd runtime)
|
||||
5. Composio can be explicitly initialized with `dangerouslyAllowAutoUploadDownloadFiles: false`
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -18,8 +18,8 @@ Verifies that:
|
||||
| `GET /` | Lists all available test endpoints |
|
||||
| `GET /test/files/upload` | Tests that `files.upload()` throws the expected error |
|
||||
| `GET /test/files/download` | Tests that `files.download()` throws the expected error |
|
||||
| `GET /test/file-modifier/error-message` | Tests the error when `autoUploadDownloadFiles: true` is explicitly set |
|
||||
| `GET /test/auto-upload-disabled` | Tests Composio initialization with explicit `autoUploadDownloadFiles: false` |
|
||||
| `GET /test/file-modifier/error-message` | Tests the error when `dangerouslyAllowAutoUploadDownloadFiles: true` is explicitly set |
|
||||
| `GET /test/auto-upload-disabled` | Tests Composio initialization with explicit `dangerouslyAllowAutoUploadDownloadFiles: false` |
|
||||
| `GET /test/default-config` | Tests Composio initialization with default configuration (no explicit setting) |
|
||||
|
||||
## Running Tests
|
||||
@@ -44,7 +44,7 @@ Note: For file operation tests, a dummy API key works since the operations throw
|
||||
|
||||
## Default Configuration
|
||||
|
||||
**In Cloudflare Workers (workerd runtime), `autoUploadDownloadFiles` defaults to `false`.**
|
||||
**In Cloudflare Workers (workerd runtime), `dangerouslyAllowAutoUploadDownloadFiles` defaults to `false`.**
|
||||
|
||||
This means you can initialize Composio without any special configuration:
|
||||
|
||||
@@ -52,26 +52,26 @@ This means you can initialize Composio without any special configuration:
|
||||
const composio = new Composio({
|
||||
apiKey: 'your-key',
|
||||
});
|
||||
// autoUploadDownloadFiles is false by default in workerd
|
||||
// dangerouslyAllowAutoUploadDownloadFiles is false by default in workerd
|
||||
```
|
||||
|
||||
## FileToolModifier Error
|
||||
|
||||
If you explicitly set `autoUploadDownloadFiles: true` and execute a tool with file properties in Cloudflare Workers, the following error is thrown:
|
||||
If you explicitly set `dangerouslyAllowAutoUploadDownloadFiles: true` and execute a tool with file properties in Cloudflare Workers, the following error is thrown:
|
||||
|
||||
```
|
||||
File upload/download modifiers are not available on edge runtimes yet.
|
||||
Please set `autoUploadDownloadFiles: false` or run Composio in another JS runtime (Node.js / Bun).
|
||||
Please set `dangerouslyAllowAutoUploadDownloadFiles: false` (or unset it; it defaults to false) or run Composio in another JS runtime (Node.js / Bun).
|
||||
```
|
||||
|
||||
### Best Practice
|
||||
|
||||
For edge runtimes, use the default configuration or explicitly set `autoUploadDownloadFiles: false`:
|
||||
For edge runtimes, use the default configuration or explicitly set `dangerouslyAllowAutoUploadDownloadFiles: false`:
|
||||
|
||||
```typescript
|
||||
const composio = new Composio({
|
||||
apiKey: 'your-key',
|
||||
autoUploadDownloadFiles: false, // This is the default for workerd
|
||||
dangerouslyAllowAutoUploadDownloadFiles: false, // This is the default for workerd
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -94,10 +94,10 @@ app.get('/test/files/download', async c => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: FileToolModifier behavior (triggers the autoUploadDownloadFiles error)
|
||||
* Test: FileToolModifier behavior (triggers the auto-upload/download error)
|
||||
*
|
||||
* The FileToolModifier.workerd.ts throws an error when autoUploadDownloadFiles is enabled.
|
||||
* In workerd runtime, the default is false, so we explicitly enable it here to test the error.
|
||||
* The FileToolModifier.workerd.ts throws an error when automatic file upload/download is enabled.
|
||||
* In workerd runtime, the default is off, so we explicitly opt in here to test the error.
|
||||
*
|
||||
* This endpoint actually executes a tool to trigger the real error from FileToolModifier.workerd.ts.
|
||||
*/
|
||||
@@ -105,11 +105,11 @@ app.get('/test/file-modifier/error-message', async c => {
|
||||
try {
|
||||
const composio = new Composio({
|
||||
apiKey: c.env.COMPOSIO_API_KEY,
|
||||
autoUploadDownloadFiles: true,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
});
|
||||
|
||||
// Use a local custom tool to avoid depending on remote Composio tool registry.
|
||||
// Any execute() call with autoUploadDownloadFiles enabled will trigger FileToolModifier
|
||||
// Any execute() with automatic file handling enabled will trigger FileToolModifier
|
||||
// in the workerd runtime.
|
||||
await composio.tools.createCustomTool({
|
||||
slug: 'CUSTOM_FILE_TOOL',
|
||||
@@ -149,24 +149,24 @@ app.get('/test/file-modifier/error-message', async c => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Composio initialization with autoUploadDownloadFiles disabled
|
||||
*
|
||||
* In workerd runtime (Cloudflare Workers), autoUploadDownloadFiles defaults to false.
|
||||
* Test: Composio initialization with automatic file upload/download disabled
|
||||
*
|
||||
* In workerd runtime (Cloudflare Workers), dangerouslyAllowAutoUploadDownloadFiles defaults to false.
|
||||
* This test verifies that the default configuration works correctly and that
|
||||
* Composio can be initialized successfully without file upload/download support.
|
||||
*/
|
||||
app.get('/test/auto-upload-disabled', async c => {
|
||||
try {
|
||||
// Explicitly set autoUploadDownloadFiles: false (which is the default for workerd)
|
||||
// This is the recommended configuration for edge runtimes
|
||||
// Explicitly keep automatic file handling off (default everywhere)
|
||||
const composio = new Composio({
|
||||
apiKey: c.env.COMPOSIO_API_KEY,
|
||||
autoUploadDownloadFiles: false,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: false,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Composio initialized successfully with autoUploadDownloadFiles: false',
|
||||
message:
|
||||
'Composio initialized successfully with dangerouslyAllowAutoUploadDownloadFiles: false',
|
||||
hasProvider: typeof composio.provider !== 'undefined',
|
||||
hasTools: typeof composio.tools !== 'undefined',
|
||||
hasFiles: typeof composio.files !== 'undefined',
|
||||
@@ -189,12 +189,12 @@ app.get('/test/auto-upload-disabled', async c => {
|
||||
* Test: Composio initialization with default configuration
|
||||
*
|
||||
* This test verifies that Composio initializes correctly with the default
|
||||
* configuration for workerd runtime (autoUploadDownloadFiles: false by default).
|
||||
* configuration for workerd runtime (dangerouslyAllowAutoUploadDownloadFiles: false by default).
|
||||
* No explicit configuration is provided, relying on the runtime defaults.
|
||||
*/
|
||||
app.get('/test/default-config', async c => {
|
||||
try {
|
||||
// Initialize Composio without explicitly setting autoUploadDownloadFiles
|
||||
// Initialize Composio without explicitly setting dangerouslyAllowAutoUploadDownloadFiles
|
||||
// Should use the workerd default (false)
|
||||
const composio = new Composio({
|
||||
apiKey: c.env.COMPOSIO_API_KEY,
|
||||
@@ -206,7 +206,7 @@ app.get('/test/default-config', async c => {
|
||||
hasProvider: typeof composio.provider !== 'undefined',
|
||||
hasTools: typeof composio.tools !== 'undefined',
|
||||
hasFiles: typeof composio.files !== 'undefined',
|
||||
note: 'In workerd runtime, autoUploadDownloadFiles defaults to false',
|
||||
note: 'In workerd runtime, dangerouslyAllowAutoUploadDownloadFiles defaults to false',
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('@composio/core Files - Cloudflare Workers compatibility', () => {
|
||||
expect(body.error).toContain('not available on edge runtimes');
|
||||
});
|
||||
|
||||
it('should successfully initialize Composio with autoUploadDownloadFiles disabled', async () => {
|
||||
it('should successfully initialize Composio with automatic file handling disabled', async () => {
|
||||
const request = new IncomingRequest('http://localhost/test/auto-upload-disabled');
|
||||
const ctx = createExecutionContext();
|
||||
const response = await app.fetch(request, env, ctx);
|
||||
@@ -93,7 +93,7 @@ describe('@composio/core Files - Cloudflare Workers compatibility', () => {
|
||||
};
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.message).toContain('autoUploadDownloadFiles: false');
|
||||
expect(body.message).toContain('dangerouslyAllowAutoUploadDownloadFiles: false');
|
||||
expect(body.hasProvider).toBe(true);
|
||||
expect(body.hasTools).toBe(true);
|
||||
expect(body.hasFiles).toBe(true);
|
||||
@@ -121,6 +121,6 @@ describe('@composio/core Files - Cloudflare Workers compatibility', () => {
|
||||
expect(body.hasProvider).toBe(true);
|
||||
expect(body.hasTools).toBe(true);
|
||||
expect(body.hasFiles).toBe(true);
|
||||
expect(body.note).toContain('autoUploadDownloadFiles defaults to false');
|
||||
expect(body.note).toContain('dangerouslyAllowAutoUploadDownloadFiles defaults to false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,23 @@ import 'dotenv/config';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Initialize Composio
|
||||
* Initialize Composio.
|
||||
*
|
||||
* `dangerouslyAllowAutoUploadDownloadFiles: true` enables the SDK to read local
|
||||
* files from disk during tool execution. When that flag is on, local paths are
|
||||
* restricted to the directories listed in `fileUploadDirs`. Passing a value
|
||||
* REPLACES the default `[~/.composio/temp]` — include it if you want staged
|
||||
* uploads to keep working. Pass `false` (or `[]`) to reject every local path
|
||||
* while still allowing URLs and `File`/`Blob` objects.
|
||||
*
|
||||
* `fileDownloadDir` controls where files in tool responses (`s3url` fields) are
|
||||
* written. Defaults to `~/.composio/files`.
|
||||
*/
|
||||
const composio = new Composio({
|
||||
apiKey: process.env.COMPOSIO_API_KEY,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
fileUploadDirs: [path.join(__dirname, '..'), '~/.composio/temp'],
|
||||
fileDownloadDir: path.join(__dirname, '..', '.composio-downloads'),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -122,7 +122,7 @@ interface ComposioConfig {
|
||||
apiKey?: string | null; // Your Composio API key
|
||||
baseURL?: string | null; // Custom API base URL (optional)
|
||||
allowTracking?: boolean; // Enable/disable telemetry (default: true)
|
||||
autoUploadDownloadFiles?: boolean; // Whether to automatically upload and download files during tool execution (default: true)
|
||||
dangerouslyAllowAutoUploadDownloadFiles?: boolean; // Opt-in for automatic file upload/download during tool execution (default: false)
|
||||
provider?: TProvider; // Custom provider (default: OpenAIProvider)
|
||||
host?: string; // Name of the host service which is using the SDK, this is for telemetry
|
||||
defaultHeaders?: ComposioRequestHeaders; // Request headers to be passed to the Composio API client
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ToolkitVersionParam } from './types/tool.types';
|
||||
import { ToolRouter } from './models/ToolRouter';
|
||||
import { ToolRouterCreateSessionConfig, Session } from './types/toolRouter.types';
|
||||
import { CONFIG_DEFAULTS } from './utils/config-defaults';
|
||||
|
||||
import { expandHomeAndResolve, expandHomeAndResolveMany } from './utils/fileDirs';
|
||||
export type ComposioConfig<
|
||||
TProvider extends BaseComposioProvider<unknown, unknown, unknown> = OpenAIProvider,
|
||||
> = {
|
||||
@@ -41,11 +41,11 @@ export type ComposioConfig<
|
||||
*/
|
||||
allowTracking?: boolean;
|
||||
/**
|
||||
* Whether to automatically upload and download files during tool execution.
|
||||
* @example true, false
|
||||
* @default true
|
||||
* Opt in to automatic file upload and download during tool execution (reads local paths
|
||||
* and fetches URLs marked as file-uploadable in tool schemas). Disabled by default.
|
||||
* @default false
|
||||
*/
|
||||
autoUploadDownloadFiles?: boolean;
|
||||
dangerouslyAllowAutoUploadDownloadFiles?: boolean;
|
||||
/**
|
||||
* When true, local file paths for auto-upload and `files.upload` are checked against
|
||||
* a built-in denylist of sensitive path segments (e.g. `.ssh`, `.aws`) and
|
||||
@@ -59,6 +59,33 @@ export type ComposioConfig<
|
||||
* they appear anywhere in the resolved path. Merged with the built-in list.
|
||||
*/
|
||||
fileUploadPathDenySegments?: string[];
|
||||
/**
|
||||
* Allowlist of directories from which the SDK is allowed to read local files
|
||||
* during **automatic** file upload (when
|
||||
* `dangerouslyAllowAutoUploadDownloadFiles: true`). Manual
|
||||
* `composio.files.upload()` calls are NOT subject to this allowlist.
|
||||
*
|
||||
* - `undefined` (default) → `[<home>/.composio/temp]`.
|
||||
* - `false` → reject every local path during auto-upload. URLs
|
||||
* (`http(s)://...`) and `File`/`Blob` objects continue to work.
|
||||
* - `string[]` (non-empty) → use as the allowlist. A file is accepted iff
|
||||
* its symlink-resolved absolute path is inside one of these directories
|
||||
* on a path-component boundary (so `/tmp/foo` allows `/tmp/foo/bar` but
|
||||
* NOT `/tmp/foo-bar`).
|
||||
* - `[]` → behaves like `false` (kept as an alias; prefer `false` for
|
||||
* readability).
|
||||
* - Providing any value **replaces** the default. Include `~/.composio/temp`
|
||||
* in your list if you want the default staging dir to keep working.
|
||||
* - On Windows, entries are compared case-insensitively.
|
||||
*/
|
||||
fileUploadDirs?: string[] | false;
|
||||
/**
|
||||
* Directory where files downloaded during tool execution (and
|
||||
* `composio.files.download()`) are written. Defaults to
|
||||
* `<home>/.composio/files`. Path is expanded at SDK-init time; relative
|
||||
* paths resolve against `process.cwd()`.
|
||||
*/
|
||||
fileDownloadDir?: string;
|
||||
/**
|
||||
* The tool provider to use for this Composio instance.
|
||||
* @example new OpenAIProvider()
|
||||
@@ -264,10 +291,13 @@ export class Composio<
|
||||
apiKey: apiKeyParsed,
|
||||
toolkitVersions: getToolkitVersionsFromEnv(config?.toolkitVersions),
|
||||
allowTracking: config?.allowTracking ?? CONFIG_DEFAULTS.allowTracking,
|
||||
autoUploadDownloadFiles:
|
||||
config?.autoUploadDownloadFiles ?? CONFIG_DEFAULTS.autoUploadDownloadFiles,
|
||||
dangerouslyAllowAutoUploadDownloadFiles:
|
||||
config?.dangerouslyAllowAutoUploadDownloadFiles ??
|
||||
CONFIG_DEFAULTS.dangerouslyAllowAutoUploadDownloadFiles,
|
||||
sensitiveFileUploadProtection: config?.sensitiveFileUploadProtection,
|
||||
fileUploadPathDenySegments: config?.fileUploadPathDenySegments,
|
||||
fileUploadDirs: expandHomeAndResolveMany(config?.fileUploadDirs),
|
||||
fileDownloadDir: expandHomeAndResolve(config?.fileDownloadDir),
|
||||
provider: config?.provider ?? this.provider,
|
||||
};
|
||||
|
||||
@@ -292,6 +322,7 @@ export class Composio<
|
||||
this.files = new Files(this.client, {
|
||||
sensitiveFileUploadProtection: this.config.sensitiveFileUploadProtection,
|
||||
fileUploadPathDenySegments: this.config.fileUploadPathDenySegments,
|
||||
fileDownloadDir: this.config.fileDownloadDir,
|
||||
});
|
||||
this.connectedAccounts = new ConnectedAccounts(this.client);
|
||||
this.toolRouter = new ToolRouter(this.client, this.config);
|
||||
@@ -343,11 +374,20 @@ export class Composio<
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configuration SDK is initialized with
|
||||
* @returns {ComposioConfig<TProvider>} The configuration SDK is initialized with
|
||||
* Get the configuration SDK is initialized with.
|
||||
*
|
||||
* Returns a frozen shallow clone — the SDK has already snapshotted
|
||||
* configuration values such as `dangerouslyAllowAutoUploadDownloadFiles`,
|
||||
* `fileUploadDirs`, and `fileDownloadDir` into its internal models, so
|
||||
* mutating the live config object would silently no-op. Freezing makes
|
||||
* that contract visible at the call site instead of letting the mutation
|
||||
* appear successful.
|
||||
*
|
||||
* @returns {Readonly<ComposioConfig<TProvider>>} The frozen configuration
|
||||
* the SDK is initialized with.
|
||||
*/
|
||||
getConfig(): ComposioConfig<TProvider> {
|
||||
return this.config;
|
||||
getConfig(): Readonly<ComposioConfig<TProvider>> {
|
||||
return Object.freeze({ ...this.config });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,8 @@ export const FileModifierErrorCodes = {
|
||||
FILE_UPLOAD_FAILED: 'FILE_UPLOAD_FAILED',
|
||||
SENSITIVE_FILE_PATH_BLOCKED: 'SENSITIVE_FILE_PATH_BLOCKED',
|
||||
FILE_UPLOAD_ABORTED: 'FILE_UPLOAD_ABORTED',
|
||||
FILE_UPLOAD_PATH_NOT_ALLOWED: 'FILE_UPLOAD_PATH_NOT_ALLOWED',
|
||||
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
|
||||
} as const;
|
||||
|
||||
export class ComposioFileUploadError extends ComposioError {
|
||||
@@ -52,3 +54,47 @@ export class ComposioFileUploadAbortedError extends ComposioFileUploadError {
|
||||
this.name = 'ComposioFileUploadAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when automatic file upload during tool execution is attempted from a path
|
||||
* that is outside the configured {@link ComposioConfig.fileUploadDirs} allowlist.
|
||||
*
|
||||
* This only fires for auto-upload (enabled via
|
||||
* `dangerouslyAllowAutoUploadDownloadFiles: true`). Manual `composio.files.upload()`
|
||||
* calls are never subject to this check.
|
||||
*/
|
||||
export class ComposioFileUploadPathNotAllowedError extends ComposioFileUploadError {
|
||||
constructor(message: string, options: ComposioErrorOptions = {}) {
|
||||
super(message, {
|
||||
...options,
|
||||
code: FileModifierErrorCodes.FILE_UPLOAD_PATH_NOT_ALLOWED,
|
||||
possibleFixes: options.possibleFixes ?? [
|
||||
'Move the file under one of the directories in `fileUploadDirs`',
|
||||
'Stage the file under the default upload dir (`~/.composio/temp`)',
|
||||
'Add the containing directory to `fileUploadDirs` when constructing Composio',
|
||||
'Pass the file by URL or as a File/Blob object instead of a filesystem path',
|
||||
],
|
||||
});
|
||||
this.name = 'ComposioFileUploadPathNotAllowedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a local file path passed for upload does not exist on disk.
|
||||
* Provides extra debugging context (resolved absolute path, cwd, allowlist state)
|
||||
* to help the caller distinguish between "typo" and "not-allowlisted" failures.
|
||||
*/
|
||||
export class ComposioFileNotFoundError extends ComposioFileUploadError {
|
||||
constructor(message: string, options: ComposioErrorOptions = {}) {
|
||||
super(message, {
|
||||
...options,
|
||||
code: FileModifierErrorCodes.FILE_NOT_FOUND,
|
||||
possibleFixes: options.possibleFixes ?? [
|
||||
'Double-check the path for typos',
|
||||
'Remember relative paths are resolved against process.cwd() at upload time',
|
||||
'Verify the file exists and the process has read permission',
|
||||
],
|
||||
});
|
||||
this.name = 'ComposioFileNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,21 @@ import { downloadFileFromS3, getFileDataAfterUploadingToS3 } from '../utils/file
|
||||
import { telemetry } from '../telemetry/Telemetry';
|
||||
|
||||
export class Files {
|
||||
private readonly fileUploadPathOptions: {
|
||||
private readonly fileOptions: {
|
||||
sensitiveFileUploadProtection?: boolean;
|
||||
fileUploadPathDenySegments?: string[];
|
||||
fileDownloadDir?: string;
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly client: ComposioClient,
|
||||
fileUploadPathOptions: {
|
||||
fileOptions: {
|
||||
sensitiveFileUploadProtection?: boolean;
|
||||
fileUploadPathDenySegments?: string[];
|
||||
fileDownloadDir?: string;
|
||||
} = {}
|
||||
) {
|
||||
this.fileUploadPathOptions = fileUploadPathOptions;
|
||||
this.fileOptions = fileOptions;
|
||||
telemetry.instrument(this, 'Files');
|
||||
}
|
||||
|
||||
@@ -53,12 +55,15 @@ export class Files {
|
||||
toolSlug: string;
|
||||
toolkitSlug: string;
|
||||
}): Promise<FileUploadData> {
|
||||
// Note: manual `composio.files.upload()` intentionally skips the
|
||||
// `fileUploadDirs` allowlist. The allowlist only gates *automatic* upload
|
||||
// during tool execution (see FileToolModifier).
|
||||
const fileData = await getFileDataAfterUploadingToS3(file, {
|
||||
toolSlug,
|
||||
toolkitSlug,
|
||||
client: this.client,
|
||||
sensitiveFileUploadProtection: this.fileUploadPathOptions.sensitiveFileUploadProtection,
|
||||
fileUploadPathDenySegments: this.fileUploadPathOptions.fileUploadPathDenySegments,
|
||||
sensitiveFileUploadProtection: this.fileOptions.sensitiveFileUploadProtection,
|
||||
fileUploadPathDenySegments: this.fileOptions.fileUploadPathDenySegments,
|
||||
});
|
||||
return fileData;
|
||||
}
|
||||
@@ -77,7 +82,12 @@ export class Files {
|
||||
toolSlug: string;
|
||||
mimeType: string;
|
||||
}): Promise<FileDownloadData> {
|
||||
const fileDownloadData = await downloadFileFromS3({ toolSlug, s3Url, mimeType });
|
||||
const fileDownloadData = await downloadFileFromS3({
|
||||
toolSlug,
|
||||
s3Url,
|
||||
mimeType,
|
||||
fileDownloadDir: this.fileOptions.fileDownloadDir,
|
||||
});
|
||||
return fileDownloadData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ import { handleToolExecutionError } from '../errors/ToolErrors';
|
||||
import { ToolExecuteMetaParams } from '../types/tool.types';
|
||||
import { SessionExecuteMetaParams } from '@composio/client/resources/tool-router.mjs';
|
||||
import { CONFIG_DEFAULTS } from '../utils/config-defaults';
|
||||
import { resolveEffectiveUploadAllowlist } from '../utils/fileDirs';
|
||||
import { schemaHasFileUploadable } from '../utils/modifiers/FileToolModifier.utils.neutral';
|
||||
/**
|
||||
* This class is used to manage tools in the Composio SDK.
|
||||
* It provides methods to list, get, and execute tools.
|
||||
@@ -73,7 +75,15 @@ export class Tools<
|
||||
private fileUploadPathOptions: {
|
||||
sensitiveFileUploadProtection?: boolean;
|
||||
fileUploadPathDenySegments?: string[];
|
||||
fileUploadAllowlist?: string[];
|
||||
fileDownloadDir?: string;
|
||||
};
|
||||
/**
|
||||
* Tracks tool slugs we've already warned about to avoid spamming the log when
|
||||
* the same file-input tool is executed repeatedly while auto-upload is off.
|
||||
* Scoped per-instance so a fresh `Composio` starts with a clean slate.
|
||||
*/
|
||||
private readonly warnedAutoUploadDisabledForTool = new Set<string>();
|
||||
|
||||
constructor(client: ComposioClient, config?: ComposioConfig<TProvider>) {
|
||||
if (!client) {
|
||||
@@ -86,12 +96,17 @@ export class Tools<
|
||||
this.client = client;
|
||||
this.customTools = new CustomTools(client);
|
||||
this.provider = config.provider;
|
||||
this.autoUploadDownloadFiles =
|
||||
config?.autoUploadDownloadFiles ?? CONFIG_DEFAULTS.autoUploadDownloadFiles;
|
||||
this.autoUploadDownloadFiles = config?.dangerouslyAllowAutoUploadDownloadFiles === true;
|
||||
this.toolkitVersions = config?.toolkitVersions ?? CONFIG_DEFAULTS.toolkitVersions;
|
||||
this.fileUploadPathOptions = {
|
||||
sensitiveFileUploadProtection: config?.sensitiveFileUploadProtection,
|
||||
fileUploadPathDenySegments: config?.fileUploadPathDenySegments,
|
||||
// The allowlist is only enforced during automatic upload (see
|
||||
// FileToolModifier). Manual `composio.files.upload()` calls don't see it.
|
||||
fileUploadAllowlist: this.autoUploadDownloadFiles
|
||||
? resolveEffectiveUploadAllowlist(config?.fileUploadDirs)
|
||||
: undefined,
|
||||
fileDownloadDir: config?.fileDownloadDir,
|
||||
};
|
||||
// Bind the execute method to ensure correct 'this' context
|
||||
this.execute = this.execute.bind(this);
|
||||
@@ -150,21 +165,29 @@ export class Tools<
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the default schema modifiers to the tools
|
||||
* Applies the default schema modifiers to the tools.
|
||||
*
|
||||
* The `file_uploadable` transform (collapsing the backend's internal
|
||||
* `{ name, mimetype, s3key }` staging shape to `{ type: 'string',
|
||||
* format: 'path' }`) is **gated on `dangerouslyAllowAutoUploadDownloadFiles`**
|
||||
* — the collapsed shape is a promise that the SDK will stage local paths
|
||||
* on your behalf at execute time, and that's only true when the flag is on.
|
||||
*
|
||||
* When the flag is off, the raw schema is returned unchanged; callers are
|
||||
* expected to stage files themselves via `composio.files.upload()` and pass
|
||||
* the resulting descriptor into `tools.execute`. If an LLM ends up calling
|
||||
* a file-uploadable tool in this mode, the execute path emits a one-shot
|
||||
* warning per tool slug (see `applyBeforeExecuteModifiers`).
|
||||
*
|
||||
* @param tools - The tools to apply the default schema modifiers to
|
||||
* @returns The tools with the default schema modifiers applied
|
||||
*/
|
||||
private async applyDefaultSchemaModifiers(tools: Tool[]): Promise<Tool[]> {
|
||||
if (this.autoUploadDownloadFiles) {
|
||||
const fileToolModifier = new FileToolModifier(this.client, this.fileUploadPathOptions);
|
||||
return await Promise.all(
|
||||
tools.map(tool =>
|
||||
fileToolModifier.modifyToolSchema(tool.slug, tool.toolkit?.slug ?? 'unknown', tool)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
if (!this.autoUploadDownloadFiles) {
|
||||
return tools;
|
||||
}
|
||||
const fileToolModifier = new FileToolModifier(this.client, this.fileUploadPathOptions);
|
||||
return await Promise.all(tools.map(tool => fileToolModifier.modifyToolSchema(tool)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,6 +223,26 @@ export class Tools<
|
||||
toolkitSlug,
|
||||
params: modifiedParams,
|
||||
});
|
||||
} else if (
|
||||
schemaHasFileUploadable(tool.inputParameters) &&
|
||||
!this.warnedAutoUploadDisabledForTool.has(toolSlug)
|
||||
) {
|
||||
// With auto-upload off, the raw `{ name, mimetype, s3key }` shape is
|
||||
// what the LLM / caller sees on `tool.inputParameters`. LLMs can't
|
||||
// produce a valid `s3key` and will hallucinate one, which then fails
|
||||
// at the staging-lookup step on the backend. Nudge the caller toward
|
||||
// manual staging or opting into auto-upload.
|
||||
this.warnedAutoUploadDisabledForTool.add(toolSlug);
|
||||
logger.warn(
|
||||
`Tool "${toolSlug}" (toolkit "${toolkitSlug}") has a file-uploadable input, but ` +
|
||||
`\`dangerouslyAllowAutoUploadDownloadFiles\` is disabled. The SDK will forward ` +
|
||||
`the file argument as-is; if it isn't already a staged ` +
|
||||
`{ name, mimetype, s3key } descriptor, the backend will reject the call. Either:\n` +
|
||||
` 1) Stage the file yourself: \`const f = await composio.files.upload({ file, toolSlug, toolkitSlug }); ` +
|
||||
`await composio.tools.execute('${toolSlug}', { userId, arguments: { <fileField>: f } })\`, or\n` +
|
||||
` 2) Enable auto-upload with a scoped allowlist: ` +
|
||||
`\`new Composio({ dangerouslyAllowAutoUploadDownloadFiles: true, fileUploadDirs: ['/safe/dir'] })\`.`
|
||||
);
|
||||
}
|
||||
// apply the before execute modifiers
|
||||
if (modifiers?.beforeExecute) {
|
||||
|
||||
@@ -22,6 +22,14 @@ export const platform = {
|
||||
return path.join(...paths);
|
||||
},
|
||||
|
||||
resolvePath(...paths: string[]): string {
|
||||
return path.resolve(...paths);
|
||||
},
|
||||
|
||||
isAbsolutePath(filePath: string): boolean {
|
||||
return path.isAbsolute(filePath);
|
||||
},
|
||||
|
||||
basename(filePath: string): string {
|
||||
return path.basename(filePath);
|
||||
},
|
||||
|
||||
@@ -16,6 +16,18 @@ export interface Platform {
|
||||
*/
|
||||
joinPath(...paths: string[]): string;
|
||||
|
||||
/**
|
||||
* Resolves a sequence of paths into an absolute path. On Node this is
|
||||
* `path.resolve`; on edge runtimes without a working directory it
|
||||
* normalizes/joins the segments and returns the result verbatim.
|
||||
*/
|
||||
resolvePath(...paths: string[]): string;
|
||||
|
||||
/**
|
||||
* Returns true if the given path is absolute.
|
||||
*/
|
||||
isAbsolutePath(filePath: string): boolean;
|
||||
|
||||
/**
|
||||
* Returns the last portion of a path (the filename).
|
||||
* @param filePath - The path to extract the basename from.
|
||||
|
||||
@@ -26,6 +26,18 @@ export const platform: Platform = {
|
||||
.join('/');
|
||||
},
|
||||
|
||||
resolvePath(...paths: string[]): string {
|
||||
// No working directory in edge runtimes. Best-effort: join the segments
|
||||
// and return the result. Callers that need a real `path.resolve` should
|
||||
// gate on `supportsFileSystem`.
|
||||
return this.joinPath(...paths);
|
||||
},
|
||||
|
||||
isAbsolutePath(filePath: string): boolean {
|
||||
// POSIX-only check; edge runtimes don't have Windows-style drive letters.
|
||||
return filePath.startsWith('/');
|
||||
},
|
||||
|
||||
basename(filePath: string): string {
|
||||
// Simple basename extraction without Node.js path module
|
||||
const segments = filePath.replace(/\/+$/, '').split('/');
|
||||
|
||||
@@ -180,12 +180,30 @@ export type afterExecuteModifier = (context: {
|
||||
}) => Promise<ToolExecuteResponse> | ToolExecuteResponse;
|
||||
|
||||
/**
|
||||
* Called for each `file_uploadable` value before the SDK reads the file and uploads to storage.
|
||||
* For a string, `path` is the local path or URL. For a `File` object, `path` is the file's name; you may return
|
||||
* a different filesystem path to upload instead, or return `false` to abort the upload. Throwing an error also aborts.
|
||||
* Discriminates what kind of input the hook is seeing:
|
||||
*
|
||||
* - `'path'` — a local filesystem path string.
|
||||
* - `'url'` — an `http(s)://...` URL string.
|
||||
* - `'file'` — a `File` object; `path` holds `file.name` (a filename, NOT a
|
||||
* filesystem path). Returning a string from the hook in this case replaces
|
||||
* the `File` with a local-path upload.
|
||||
*/
|
||||
export type BeforeFileUploadSource = 'path' | 'url' | 'file';
|
||||
|
||||
/**
|
||||
* Called for each `file_uploadable` value before the SDK reads the file and
|
||||
* uploads to storage.
|
||||
*
|
||||
* - `context.path` holds the local path, the URL, or the `File`'s name
|
||||
* depending on `context.source`.
|
||||
* - Return a string to replace the input (e.g. rewrite the path, redirect a
|
||||
* URL, or swap a `File` for a filesystem path).
|
||||
* - Return `false` to abort the upload with `ComposioFileUploadAbortedError`.
|
||||
* - Throwing an error also aborts.
|
||||
*/
|
||||
export type beforeFileUploadModifier = (context: {
|
||||
path: string;
|
||||
source: BeforeFileUploadSource;
|
||||
toolSlug: string;
|
||||
toolkitSlug: string;
|
||||
}) => Promise<string | false> | string | false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConfigDefaults } from './types';
|
||||
|
||||
export const CONFIG_DEFAULTS: ConfigDefaults = {
|
||||
autoUploadDownloadFiles: true,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: false,
|
||||
allowTracking: true,
|
||||
toolkitVersions: 'latest',
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export const CONFIG_DEFAULTS: ConfigDefaults = {
|
||||
/**
|
||||
* We don't support auto-uploading/download files in Cloudflare Workers yet.
|
||||
*/
|
||||
autoUploadDownloadFiles: false,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: false,
|
||||
allowTracking: true,
|
||||
toolkitVersions: 'latest',
|
||||
};
|
||||
|
||||
@@ -9,6 +9,6 @@ import type { BaseComposioProvider } from '../../provider/BaseProvider';
|
||||
export type ConfigDefaults = Required<
|
||||
Pick<
|
||||
ComposioConfig<BaseComposioProvider<never, never, never>>,
|
||||
'autoUploadDownloadFiles' | 'allowTracking' | 'toolkitVersions'
|
||||
'dangerouslyAllowAutoUploadDownloadFiles' | 'allowTracking' | 'toolkitVersions'
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Runtime-neutral helpers for normalizing user-provided filesystem paths.
|
||||
*
|
||||
* In filesystem-less runtimes (Cloudflare Workers, etc.) the platform
|
||||
* abstraction reports `supportsFileSystem: false` and `homedir()` returns
|
||||
* `null`. We use that as the signal to drop unresolvable entries from the
|
||||
* allowlist (fail-closed) rather than silently passing `~/foo` through as
|
||||
* a literal directory name.
|
||||
*/
|
||||
|
||||
import { platform } from '#platform';
|
||||
|
||||
/**
|
||||
* Expands a leading `~` to the user's home directory and resolves to an
|
||||
* absolute path. Returns `undefined` when expansion is required but no
|
||||
* home directory is available — that signals "unusable, drop me" to
|
||||
* {@link expandHomeAndResolveMany}, which is safer than letting the literal
|
||||
* `~/foo` survive as a CWD-relative path.
|
||||
*/
|
||||
export function expandHomeAndResolve(p: string | undefined): string | undefined {
|
||||
if (!p) return p;
|
||||
let expanded = p;
|
||||
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
|
||||
const home = platform.homedir();
|
||||
if (!home) return undefined;
|
||||
expanded = p === '~' ? home : platform.joinPath(home, p.slice(2));
|
||||
}
|
||||
return platform.resolvePath(expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies {@link expandHomeAndResolve} to each entry, dropping empty/invalid
|
||||
* values. Preserves `undefined` and `false` (so downstream can distinguish
|
||||
* "user did not configure" from "user explicitly disabled local uploads"
|
||||
* from "user configured []").
|
||||
*/
|
||||
export function expandHomeAndResolveMany(
|
||||
list: string[] | false | undefined
|
||||
): string[] | false | undefined {
|
||||
if (list === undefined) return undefined;
|
||||
if (list === false) return false;
|
||||
const out: string[] = [];
|
||||
for (const raw of list) {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') continue;
|
||||
const resolved = expandHomeAndResolve(raw);
|
||||
if (resolved) out.push(resolved);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path of the default upload staging directory
|
||||
* (`<home>/.composio/temp`), or `null` when a home directory is unavailable
|
||||
* (e.g. Cloudflare Workers).
|
||||
*/
|
||||
export function getDefaultUploadDir(): string | null {
|
||||
const home = platform.homedir();
|
||||
if (!home) return null;
|
||||
return platform.resolvePath(platform.joinPath(home, '.composio', 'temp'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective upload allowlist for a given user-provided value.
|
||||
*
|
||||
* - `undefined` → `[<default>]` when a home directory is available, else `[]`
|
||||
* (fail-closed if we can't derive the default staging dir).
|
||||
* - `false` → `[]` (explicit "reject all local paths"). URLs / File objects
|
||||
* still work because they aren't path-checked.
|
||||
* - `string[]` (including `[]`) replaces the default and is returned
|
||||
* verbatim after `~` expansion / `path.resolve`. `[]` is treated as
|
||||
* equivalent to `false`.
|
||||
*/
|
||||
export function resolveEffectiveUploadAllowlist(userDirs: string[] | false | undefined): string[] {
|
||||
if (userDirs === undefined) {
|
||||
const def = getDefaultUploadDir();
|
||||
return def ? [def] : [];
|
||||
}
|
||||
if (userDirs === false) return [];
|
||||
const resolved = expandHomeAndResolveMany(userDirs);
|
||||
return Array.isArray(resolved) ? resolved : [];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getRandomShortId } from './uuid';
|
||||
import { base64ToUint8Array, uint8ArrayToBase64 } from './buffer';
|
||||
import type { FileDownloadData, FileUploadData } from '../types/files.types';
|
||||
import { assertSafeFileUploadPath } from './sensitiveFileUploadPaths.node';
|
||||
import { assertPathInsideUploadDirs } from './uploadDirAllowlist.node';
|
||||
|
||||
/**
|
||||
* Options for {@link getFileDataAfterUploadingToS3} (S3 presigned upload from local path, URL, or File).
|
||||
@@ -26,6 +27,15 @@ export type GetFileDataAfterUploadingToS3Options = {
|
||||
* merged with the built-in list.
|
||||
*/
|
||||
fileUploadPathDenySegments?: string[];
|
||||
/**
|
||||
* When provided, the local path must resolve inside one of these directories
|
||||
* (after symlink resolution, on a component boundary) or upload is rejected.
|
||||
* Intended for automatic upload during tool execution; manual
|
||||
* `composio.files.upload()` calls pass `undefined` here to skip enforcement.
|
||||
*
|
||||
* An empty array means "no paths are allowed" (fail-closed).
|
||||
*/
|
||||
fileUploadAllowlist?: string[];
|
||||
};
|
||||
|
||||
// Helper function to get file extension from MIME type
|
||||
@@ -244,18 +254,23 @@ export const getFileDataAfterUploadingToS3 = async (
|
||||
client,
|
||||
sensitiveFileUploadProtection,
|
||||
fileUploadPathDenySegments,
|
||||
fileUploadAllowlist,
|
||||
}: GetFileDataAfterUploadingToS3Options
|
||||
): Promise<FileUploadData> => {
|
||||
if (!file) {
|
||||
throw new Error('Either path or blob must be provided');
|
||||
}
|
||||
|
||||
if (
|
||||
sensitiveFileUploadProtection !== false &&
|
||||
typeof file === 'string' &&
|
||||
!file.startsWith('http')
|
||||
) {
|
||||
assertSafeFileUploadPath(file, { additionalDenySegments: fileUploadPathDenySegments });
|
||||
const isLocalPath = typeof file === 'string' && !file.startsWith('http');
|
||||
|
||||
if (isLocalPath && fileUploadAllowlist !== undefined) {
|
||||
assertPathInsideUploadDirs(file as string, fileUploadAllowlist);
|
||||
}
|
||||
|
||||
if (sensitiveFileUploadProtection !== false && isLocalPath) {
|
||||
assertSafeFileUploadPath(file as string, {
|
||||
additionalDenySegments: fileUploadPathDenySegments,
|
||||
});
|
||||
}
|
||||
|
||||
const fileData = await readFile(file);
|
||||
@@ -281,10 +296,16 @@ export const downloadFileFromS3 = async ({
|
||||
toolSlug,
|
||||
s3Url,
|
||||
mimeType,
|
||||
fileDownloadDir,
|
||||
}: {
|
||||
toolSlug: string;
|
||||
s3Url: string;
|
||||
mimeType: string;
|
||||
/**
|
||||
* Absolute path to the directory to save the file into. When omitted, falls
|
||||
* back to `<home>/.composio/files` (the legacy default).
|
||||
*/
|
||||
fileDownloadDir?: string;
|
||||
}): Promise<FileDownloadData> => {
|
||||
const response = await fetch(s3Url);
|
||||
if (!response.ok) {
|
||||
@@ -294,7 +315,10 @@ export const downloadFileFromS3 = async ({
|
||||
|
||||
const extension = getExtensionFromMimeType(mimeType);
|
||||
const fileName = generateTimestampedFilename(extension, `${toolSlug}_`);
|
||||
const filePath = saveFile(fileName, new Uint8Array(data), true);
|
||||
const filePath = saveFile(fileName, new Uint8Array(data), {
|
||||
isTempFile: fileDownloadDir === undefined,
|
||||
outputDir: fileDownloadDir,
|
||||
});
|
||||
return {
|
||||
name: fileName,
|
||||
mimeType: mimeType,
|
||||
@@ -354,23 +378,50 @@ export const getComposioTempFilesDir = (createDirIfNotExists: boolean = false) =
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a file to the Composio directory.
|
||||
* @param file - The name of the file to save.
|
||||
* @param content - The content of the file to save. Can be a string or Uint8Array.
|
||||
* @param isTempFile - Whether the file is a temporary file.
|
||||
* @returns The path to the saved file.
|
||||
* Saves a file to a local directory.
|
||||
*
|
||||
* Directory precedence (highest first):
|
||||
* 1. `options.outputDir` (explicit, absolute or relative; created if missing)
|
||||
* 2. `options.isTempFile === true` → `<home>/.composio/files`
|
||||
* 3. fallback → `<home>/.composio`
|
||||
*
|
||||
* @returns The absolute path to the saved file, or `null` if the runtime has
|
||||
* no filesystem (e.g. Cloudflare Workers) or the save failed.
|
||||
*/
|
||||
export const saveFile = (
|
||||
file: string,
|
||||
content: string | Uint8Array,
|
||||
isTempFile: boolean = false
|
||||
options: { isTempFile?: boolean; outputDir?: string } | boolean = {}
|
||||
) => {
|
||||
// Back-compat: legacy callers passed a boolean `isTempFile` positional.
|
||||
const opts: { isTempFile?: boolean; outputDir?: string } =
|
||||
typeof options === 'boolean' ? { isTempFile: options } : options;
|
||||
|
||||
try {
|
||||
if (!platform.supportsFileSystem) {
|
||||
logger.debug('File system operations are not supported in this runtime environment');
|
||||
return null;
|
||||
}
|
||||
const composioFilesDir = isTempFile ? getComposioTempFilesDir(true) : getComposioDir(true);
|
||||
|
||||
let composioFilesDir: string | null | undefined;
|
||||
if (opts.outputDir) {
|
||||
const absDir = platform.joinPath(opts.outputDir); // platform.joinPath normalizes
|
||||
try {
|
||||
if (!platform.existsSync(absDir)) {
|
||||
platform.mkdirSync(absDir);
|
||||
}
|
||||
composioFilesDir = absDir;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`fileDownloadDir "${absDir}" is not writable, falling back to the default ` +
|
||||
`(~/.composio/files). Error: ${err}`
|
||||
);
|
||||
composioFilesDir = getComposioTempFilesDir(true);
|
||||
}
|
||||
} else {
|
||||
composioFilesDir = opts.isTempFile ? getComposioTempFilesDir(true) : getComposioDir(true);
|
||||
}
|
||||
|
||||
if (!composioFilesDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
import ComposioClient from '@composio/client';
|
||||
import logger from '../logger';
|
||||
import {
|
||||
ComposioFileNotFoundError,
|
||||
ComposioFileUploadAbortedError,
|
||||
ComposioFileUploadError,
|
||||
ComposioFileUploadPathNotAllowedError,
|
||||
ComposioSensitiveFilePathBlockedError,
|
||||
} from '../../errors/FileModifierErrors';
|
||||
import type { beforeFileUploadModifier } from '../../types/modifiers.types';
|
||||
@@ -39,7 +41,7 @@ const hydrateFiles = async (
|
||||
client: ComposioClient;
|
||||
} & Pick<
|
||||
GetFileDataAfterUploadingToS3Options,
|
||||
'sensitiveFileUploadProtection' | 'fileUploadPathDenySegments'
|
||||
'sensitiveFileUploadProtection' | 'fileUploadPathDenySegments' | 'fileUploadAllowlist'
|
||||
> & {
|
||||
beforeFileUpload?: beforeFileUploadModifier;
|
||||
}
|
||||
@@ -51,12 +53,16 @@ const hydrateFiles = async (
|
||||
// Upload only if the runtime value is a string (i.e., a local path) or blob
|
||||
if (typeof value !== 'string' && !(value instanceof File)) return value;
|
||||
|
||||
const runBeforeFileUpload = async (path: string): Promise<string> => {
|
||||
const runBeforeFileUpload = async (
|
||||
path: string,
|
||||
source: 'path' | 'url' | 'file'
|
||||
): Promise<string> => {
|
||||
if (!ctx.beforeFileUpload) {
|
||||
return path;
|
||||
}
|
||||
const out = await ctx.beforeFileUpload({
|
||||
path,
|
||||
source,
|
||||
toolSlug: ctx.toolSlug,
|
||||
toolkitSlug: ctx.toolkitSlug,
|
||||
});
|
||||
@@ -69,7 +75,10 @@ const hydrateFiles = async (
|
||||
};
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const pathOrUrl = await runBeforeFileUpload(value);
|
||||
// Match the URL/local-path split used downstream in
|
||||
// getFileDataAfterUploadingToS3 so the hook sees the same categorisation.
|
||||
const source = value.startsWith('http') ? 'url' : 'path';
|
||||
const pathOrUrl = await runBeforeFileUpload(value, source);
|
||||
logger.debug(`Uploading file "${pathOrUrl}"`);
|
||||
return getFileDataAfterUploadingToS3(pathOrUrl, {
|
||||
toolSlug: ctx.toolSlug,
|
||||
@@ -77,13 +86,16 @@ const hydrateFiles = async (
|
||||
client: ctx.client,
|
||||
sensitiveFileUploadProtection: ctx.sensitiveFileUploadProtection,
|
||||
fileUploadPathDenySegments: ctx.fileUploadPathDenySegments,
|
||||
fileUploadAllowlist: ctx.fileUploadAllowlist,
|
||||
});
|
||||
}
|
||||
|
||||
// File
|
||||
// File — `path` is the filename only; a string return replaces it with a
|
||||
// local-path upload.
|
||||
if (ctx.beforeFileUpload) {
|
||||
const out = await ctx.beforeFileUpload({
|
||||
path: value.name,
|
||||
source: 'file',
|
||||
toolSlug: ctx.toolSlug,
|
||||
toolkitSlug: ctx.toolkitSlug,
|
||||
});
|
||||
@@ -100,10 +112,12 @@ const hydrateFiles = async (
|
||||
client: ctx.client,
|
||||
sensitiveFileUploadProtection: ctx.sensitiveFileUploadProtection,
|
||||
fileUploadPathDenySegments: ctx.fileUploadPathDenySegments,
|
||||
fileUploadAllowlist: ctx.fileUploadAllowlist,
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.debug(`Uploading file "${value.name}"`);
|
||||
// File/Blob values are not subject to the upload-dir allowlist.
|
||||
return getFileDataAfterUploadingToS3(value, {
|
||||
toolSlug: ctx.toolSlug,
|
||||
toolkitSlug: ctx.toolkitSlug,
|
||||
@@ -114,7 +128,14 @@ const hydrateFiles = async (
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 2. Handle anyOf/oneOf/allOf - try each variant that may contain file_uploadable
|
||||
// 2. Handle anyOf/oneOf/allOf — pick the first variant that contains a
|
||||
// file_uploadable property and hydrate against it.
|
||||
//
|
||||
// We deliberately do NOT loop over every uploadable variant. With `oneOf`,
|
||||
// exactly one variant should apply at runtime, and applying multiple would
|
||||
// upload the same file once per variant — two presigned-URL round-trips and
|
||||
// two S3 PUTs for a two-variant `oneOf`. Matching the Python SDK
|
||||
// (`_find_uploadable_schema_variant`), we short-circuit on the first match.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
const schemaVariants = [
|
||||
...(schema?.anyOf ?? []),
|
||||
@@ -123,16 +144,9 @@ const hydrateFiles = async (
|
||||
];
|
||||
|
||||
if (schemaVariants.length > 0) {
|
||||
// Find variants that have file_uploadable properties
|
||||
const uploadableVariants = schemaVariants.filter(schemaHasFileUploadable);
|
||||
|
||||
if (uploadableVariants.length > 0) {
|
||||
// Process with each uploadable variant - we try all since we can't know which one matches at runtime
|
||||
let result = value;
|
||||
for (const variant of uploadableVariants) {
|
||||
result = await hydrateFiles(result, variant, ctx);
|
||||
}
|
||||
return result;
|
||||
const firstUploadableVariant = schemaVariants.find(schemaHasFileUploadable);
|
||||
if (firstUploadableVariant) {
|
||||
return hydrateFiles(value, firstUploadableVariant, ctx);
|
||||
}
|
||||
// If no uploadable variants found, fall through to check base properties
|
||||
}
|
||||
@@ -172,7 +186,7 @@ const hydrateFiles = async (
|
||||
*/
|
||||
const downloadS3File = async (
|
||||
value: Record<string, unknown>,
|
||||
ctx: { toolSlug: string }
|
||||
ctx: { toolSlug: string; fileDownloadDir?: string }
|
||||
): Promise<unknown> => {
|
||||
const { s3url, mimetype } = value as {
|
||||
s3url: string;
|
||||
@@ -186,6 +200,7 @@ const downloadS3File = async (
|
||||
toolSlug: ctx.toolSlug,
|
||||
s3Url: s3url,
|
||||
mimeType: mimetype ?? 'application/octet-stream',
|
||||
fileDownloadDir: ctx.fileDownloadDir,
|
||||
});
|
||||
|
||||
logger.debug(`Downloaded → ${dl.filePath}`);
|
||||
@@ -223,7 +238,7 @@ const downloadS3File = async (
|
||||
const hydrateDownloads = async (
|
||||
value: unknown,
|
||||
schema: JSONSchemaProperty | undefined,
|
||||
ctx: { toolSlug: string }
|
||||
ctx: { toolSlug: string; fileDownloadDir?: string }
|
||||
): Promise<unknown> => {
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 1. Direct S3 reference (data-driven detection)
|
||||
@@ -304,8 +319,8 @@ export class FileToolModifier {
|
||||
private client: ComposioClient;
|
||||
private fileUploadPathOptions: Pick<
|
||||
GetFileDataAfterUploadingToS3Options,
|
||||
'sensitiveFileUploadProtection' | 'fileUploadPathDenySegments'
|
||||
> & { beforeFileUpload?: beforeFileUploadModifier };
|
||||
'sensitiveFileUploadProtection' | 'fileUploadPathDenySegments' | 'fileUploadAllowlist'
|
||||
> & { beforeFileUpload?: beforeFileUploadModifier; fileDownloadDir?: string };
|
||||
|
||||
constructor(
|
||||
client: ComposioClient,
|
||||
@@ -315,7 +330,7 @@ export class FileToolModifier {
|
||||
this.fileUploadPathOptions = fileUploadPathOptions;
|
||||
}
|
||||
|
||||
async modifyToolSchema(toolSlug: string, toolkitSlug: string, schema: Tool): Promise<Tool> {
|
||||
async modifyToolSchema(schema: Tool): Promise<Tool> {
|
||||
if (!schema.inputParameters?.properties) {
|
||||
return schema;
|
||||
}
|
||||
@@ -356,7 +371,9 @@ export class FileToolModifier {
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ComposioSensitiveFilePathBlockedError ||
|
||||
error instanceof ComposioFileUploadAbortedError
|
||||
error instanceof ComposioFileUploadAbortedError ||
|
||||
error instanceof ComposioFileUploadPathNotAllowedError ||
|
||||
error instanceof ComposioFileNotFoundError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
@@ -379,6 +396,7 @@ export class FileToolModifier {
|
||||
// Walk result.data without mutating the original, using output schema for guidance
|
||||
const dataWithDownloads = await hydrateDownloads(result.data, tool.outputParameters, {
|
||||
toolSlug,
|
||||
fileDownloadDir: this.fileUploadPathOptions.fileDownloadDir,
|
||||
});
|
||||
|
||||
return { ...result, data: dataWithDownloads as typeof result.data };
|
||||
|
||||
@@ -4,12 +4,12 @@ import { transformProperties } from './FileToolModifier.utils.neutral';
|
||||
|
||||
const UNSUPPORTED_MESSAGE =
|
||||
'File upload/download modifiers are not available on edge runtimes yet. ' +
|
||||
'Please set `autoUploadDownloadFiles: false` or run Composio in another JS runtime (Node.js / Bun).';
|
||||
'Please set `dangerouslyAllowAutoUploadDownloadFiles: false` (or unset it; it defaults to false) or run Composio in another JS runtime (Node.js / Bun).';
|
||||
|
||||
export class FileToolModifier {
|
||||
constructor(_client: ComposioClient) {}
|
||||
|
||||
async modifyToolSchema(toolSlug: string, toolkitSlug: string, schema: Tool): Promise<Tool> {
|
||||
async modifyToolSchema(schema: Tool): Promise<Tool> {
|
||||
if (!schema.inputParameters?.properties) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Allowlist for automatic file upload during tool execution.
|
||||
*
|
||||
* This is only consulted when `dangerouslyAllowAutoUploadDownloadFiles` is enabled.
|
||||
* Manual `composio.files.upload()` calls are not subject to the allowlist.
|
||||
*
|
||||
* - A local path is accepted iff its real (symlink-resolved, absolute) path is
|
||||
* inside one of the allowed directories on a path-component boundary.
|
||||
* `/tmp/foo` allows `/tmp/foo/bar` but NOT `/tmp/foo-bar`.
|
||||
* - URLs (`http(s)://…`) and `File`/`Blob` values never hit this check.
|
||||
* - User-provided `fileUploadDirs` fully replaces the default `[~/.composio/temp]`.
|
||||
* - On Windows, path comparisons are case-insensitive.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
ComposioFileUploadPathNotAllowedError,
|
||||
ComposioFileNotFoundError,
|
||||
} from '../errors/FileModifierErrors';
|
||||
|
||||
/**
|
||||
* Ensures the given directory exists (best-effort, non-throwing). Safe to call
|
||||
* during SDK init; allowlist enforcement still works even if creation fails.
|
||||
*/
|
||||
export function ensureDirExists(dir: string): void {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeForCompare(p: string): string {
|
||||
return process.platform === 'win32' ? p.toLowerCase() : p;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `child` is equal to `parent` or nested inside it on a component
|
||||
* boundary. Assumes both paths are absolute and normalized.
|
||||
*/
|
||||
function isInsideDir(child: string, parent: string): boolean {
|
||||
const a = normalizeForCompare(child);
|
||||
const b = normalizeForCompare(parent);
|
||||
if (a === b) return true;
|
||||
const sep = path.sep;
|
||||
const bWithSep = b.endsWith(sep) ? b : b + sep;
|
||||
return a.startsWith(bWithSep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the real (symlink-resolved) absolute path when the target exists,
|
||||
* else `null`. Mirrors the semantics of the sensitive-path util.
|
||||
*/
|
||||
function tryRealpath(absPath: string): string | null {
|
||||
try {
|
||||
if (fs.existsSync(absPath)) {
|
||||
return fs.realpathSync(absPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatAllowlist(dirs: string[]): string {
|
||||
if (dirs.length === 0) return ' (none configured \u2014 all local paths are blocked)';
|
||||
return dirs.map(d => ` - ${d}`).join('\n');
|
||||
}
|
||||
|
||||
function buildHelpFooter(allowlist: string[]): string {
|
||||
return [
|
||||
'',
|
||||
'Allowed upload directories (fileUploadDirs):',
|
||||
formatAllowlist(allowlist),
|
||||
'',
|
||||
'Fix one of the following:',
|
||||
'',
|
||||
' 1. Recommended \u2014 move the file under an allowed directory, or add your',
|
||||
' directory to the allowlist:',
|
||||
' new Composio({',
|
||||
" fileUploadDirs: ['/abs/path/to/uploads', '~/.composio/temp'],",
|
||||
' })',
|
||||
' Note: user-provided `fileUploadDirs` REPLACES the default',
|
||||
' `~/.composio/temp`. Include it explicitly if you want staged uploads',
|
||||
' to keep working.',
|
||||
'',
|
||||
' 2. Pass the file by URL or as a File/Blob object instead of a filesystem',
|
||||
' path. URLs and File objects are never path-checked.',
|
||||
'',
|
||||
' 3. (Dangerous) Bypass the allowlist entirely by opting into automatic',
|
||||
' file upload/download from any readable path:',
|
||||
' new Composio({ dangerouslyAllowAutoUploadDownloadFiles: true })',
|
||||
' WARNING: This lets a prompt-injected or misbehaving tool read ANY',
|
||||
' file the process can access. Only enable it in trusted, sandboxed',
|
||||
' environments. The built-in sensitive-path denylist (.ssh, .aws,',
|
||||
' .env, private SSH keys, etc.) still applies unless you also set',
|
||||
' `sensitiveFileUploadProtection: false`.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {ComposioFileNotFoundError} when `filePath` does not exist on disk.
|
||||
* @throws {ComposioFileUploadPathNotAllowedError} when the resolved path is
|
||||
* outside every entry of `allowlist`.
|
||||
*/
|
||||
export function assertPathInsideUploadDirs(filePath: string, allowlist: string[]): void {
|
||||
const attempted = filePath;
|
||||
const abs = path.resolve(filePath);
|
||||
const real = tryRealpath(abs);
|
||||
|
||||
if (!real) {
|
||||
const cwd = process.cwd();
|
||||
const parent = path.dirname(abs);
|
||||
const parentExists = (() => {
|
||||
try {
|
||||
return fs.existsSync(parent);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
throw new ComposioFileNotFoundError(
|
||||
[
|
||||
`Refusing to auto-upload "${attempted}": the file does not exist on disk.`,
|
||||
'',
|
||||
`Path attempted: ${attempted}`,
|
||||
`Resolved to: ${abs}`,
|
||||
`Process cwd: ${cwd}`,
|
||||
`Parent exists: ${parentExists ? 'yes (' + parent + ')' : 'no (' + parent + ')'}`,
|
||||
'',
|
||||
'Common causes:',
|
||||
' - Typo in the filename passed to the tool.',
|
||||
' - Relative path resolved against the wrong working directory',
|
||||
' (relative paths use process.cwd() at the moment of upload).',
|
||||
' - File was deleted between the tool being called and the upload starting.',
|
||||
'',
|
||||
buildHelpFooter(allowlist),
|
||||
].join('\n'),
|
||||
{ meta: { attempted, resolved: abs, cwd, allowlist } }
|
||||
);
|
||||
}
|
||||
|
||||
if (allowlist.length === 0) {
|
||||
throw new ComposioFileUploadPathNotAllowedError(
|
||||
[
|
||||
`Refusing to auto-upload "${attempted}": no upload directories are configured.`,
|
||||
'',
|
||||
`Path attempted: ${attempted}`,
|
||||
`Resolved to: ${real}`,
|
||||
'',
|
||||
'Automatic file upload during tool execution is locked down by default',
|
||||
'to prevent a prompt-injected tool from exfiltrating server files',
|
||||
'(source code, .env, SSH keys, etc.).',
|
||||
buildHelpFooter(allowlist),
|
||||
].join('\n'),
|
||||
{ meta: { attempted, resolved: real, allowlist } }
|
||||
);
|
||||
}
|
||||
|
||||
for (const dir of allowlist) {
|
||||
const realDir = tryRealpath(dir) ?? path.resolve(dir);
|
||||
if (isInsideDir(real, realDir)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ComposioFileUploadPathNotAllowedError(
|
||||
[
|
||||
`Refusing to auto-upload "${attempted}": resolved path is not inside any`,
|
||||
'directory in the configured `fileUploadDirs` allowlist.',
|
||||
'',
|
||||
`Path attempted: ${attempted}`,
|
||||
`Resolved to: ${real}`,
|
||||
buildHelpFooter(allowlist),
|
||||
].join('\n'),
|
||||
{ meta: { attempted, resolved: real, allowlist } }
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ describe('FileToolModifier', () => {
|
||||
availableVersions: ['20251201_01'],
|
||||
};
|
||||
|
||||
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
|
||||
const result = await fileToolModifier.modifyToolSchema(schema);
|
||||
expect(result).toEqual(schema);
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('FileToolModifier', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
|
||||
const result = await fileToolModifier.modifyToolSchema(schema);
|
||||
expect(result.inputParameters?.properties?.file).toHaveProperty('format', 'path');
|
||||
expect(result.inputParameters?.properties?.text).not.toHaveProperty('format');
|
||||
});
|
||||
@@ -102,7 +102,7 @@ describe('FileToolModifier', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
|
||||
const result = await fileToolModifier.modifyToolSchema(schema);
|
||||
expect(result.inputParameters?.properties?.fileInput?.anyOf?.[0]).toHaveProperty(
|
||||
'format',
|
||||
'path'
|
||||
@@ -145,7 +145,7 @@ describe('FileToolModifier', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
|
||||
const result = await fileToolModifier.modifyToolSchema(schema);
|
||||
expect(result.inputParameters?.properties?.fileInput?.oneOf?.[0]).toHaveProperty(
|
||||
'format',
|
||||
'path'
|
||||
@@ -185,7 +185,7 @@ describe('FileToolModifier', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
|
||||
const result = await fileToolModifier.modifyToolSchema(schema);
|
||||
expect(result.inputParameters?.properties?.fileInput?.allOf?.[0]).toHaveProperty(
|
||||
'format',
|
||||
'path'
|
||||
@@ -230,7 +230,7 @@ describe('FileToolModifier', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
|
||||
const result = await fileToolModifier.modifyToolSchema(schema);
|
||||
expect(
|
||||
result.inputParameters?.properties?.content?.anyOf?.[0]?.properties?.attachment
|
||||
).toHaveProperty('format', 'path');
|
||||
@@ -268,7 +268,7 @@ describe('FileToolModifier', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
|
||||
const result = await fileToolModifier.modifyToolSchema(schema);
|
||||
expect(result.inputParameters?.properties?.files?.items?.anyOf?.[0]).toHaveProperty(
|
||||
'format',
|
||||
'path'
|
||||
@@ -371,6 +371,80 @@ describe('FileToolModifier', () => {
|
||||
expect(result.arguments?.file).toEqual(mockFileData);
|
||||
});
|
||||
|
||||
it('should pass source="path" to beforeFileUpload for local filesystem paths', async () => {
|
||||
const mockFileData = {
|
||||
name: 'file.txt',
|
||||
mimetype: 'text/plain',
|
||||
s3key: 'uploads/file.txt',
|
||||
};
|
||||
vi.mocked(fileUtils.getFileDataAfterUploadingToS3).mockResolvedValue(mockFileData);
|
||||
|
||||
const hook = vi.fn(async ({ path }) => path);
|
||||
const withHook = new FileToolModifier(mockClient, { beforeFileUpload: hook });
|
||||
|
||||
await withHook.fileUploadModifier(mockTool, {
|
||||
toolSlug: 'test-tool',
|
||||
toolkitSlug: 'test-toolkit',
|
||||
params: { arguments: { file: '/path/to/a.txt' }, userId: 'u' },
|
||||
});
|
||||
|
||||
expect(hook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/path/to/a.txt', source: 'path' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass source="url" to beforeFileUpload for http(s) URLs', async () => {
|
||||
const mockFileData = {
|
||||
name: 'file.txt',
|
||||
mimetype: 'text/plain',
|
||||
s3key: 'uploads/file.txt',
|
||||
};
|
||||
vi.mocked(fileUtils.getFileDataAfterUploadingToS3).mockResolvedValue(mockFileData);
|
||||
|
||||
const hook = vi.fn(async ({ path }) => path);
|
||||
const withHook = new FileToolModifier(mockClient, { beforeFileUpload: hook });
|
||||
|
||||
await withHook.fileUploadModifier(mockTool, {
|
||||
toolSlug: 'test-tool',
|
||||
toolkitSlug: 'test-toolkit',
|
||||
params: {
|
||||
arguments: { file: 'https://example.com/report.pdf' },
|
||||
userId: 'u',
|
||||
},
|
||||
});
|
||||
|
||||
expect(hook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: 'https://example.com/report.pdf',
|
||||
source: 'url',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass source="file" to beforeFileUpload for File objects', async () => {
|
||||
const mockFileData = {
|
||||
name: 'file.txt',
|
||||
mimetype: 'text/plain',
|
||||
s3key: 'uploads/file.txt',
|
||||
};
|
||||
vi.mocked(fileUtils.getFileDataAfterUploadingToS3).mockResolvedValue(mockFileData);
|
||||
|
||||
const hook = vi.fn(async () => undefined as unknown as string);
|
||||
const withHook = new FileToolModifier(mockClient, { beforeFileUpload: hook });
|
||||
|
||||
const fileObject = new File(['x'], 'uploaded.txt', { type: 'text/plain' });
|
||||
await withHook.fileUploadModifier(mockTool, {
|
||||
toolSlug: 'test-tool',
|
||||
toolkitSlug: 'test-toolkit',
|
||||
params: { arguments: { file: fileObject }, userId: 'u' },
|
||||
});
|
||||
|
||||
// `path` is the File's name, not a filesystem path.
|
||||
expect(hook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: 'uploaded.txt', source: 'file' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ComposioFileUploadAbortedError when beforeFileUpload returns false', async () => {
|
||||
const withHook = new FileToolModifier(mockClient, {
|
||||
beforeFileUpload: async () => false,
|
||||
@@ -668,6 +742,54 @@ describe('FileToolModifier', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads once when multiple oneOf variants are file_uploadable', async () => {
|
||||
// Regression: previously hydrateFiles iterated over every uploadable
|
||||
// variant in anyOf/oneOf/allOf and re-uploaded the same file once per
|
||||
// variant. With `oneOf` semantics this is doubly wrong (only one
|
||||
// variant matches at runtime). Match the Python SDK and short-circuit
|
||||
// on the first uploadable variant.
|
||||
const mockFileData = {
|
||||
name: 'file.txt',
|
||||
mimetype: 'text/plain',
|
||||
s3key: 'uploads/file.txt',
|
||||
};
|
||||
vi.mocked(fileUtils.getFileDataAfterUploadingToS3).mockResolvedValue(mockFileData);
|
||||
|
||||
const toolWithTwoUploadableVariants: Tool = {
|
||||
slug: 'test-tool',
|
||||
name: 'Test Tool',
|
||||
description: 'A test tool',
|
||||
tags: ['test'],
|
||||
inputParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileInput: {
|
||||
oneOf: [
|
||||
{ type: 'string', file_uploadable: true },
|
||||
{ type: 'string', file_uploadable: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '20251201_01',
|
||||
availableVersions: ['20251201_01'],
|
||||
};
|
||||
|
||||
const params = {
|
||||
arguments: { fileInput: '/path/to/file.txt' },
|
||||
userId: 'test-user',
|
||||
};
|
||||
|
||||
const result = await fileToolModifier.fileUploadModifier(toolWithTwoUploadableVariants, {
|
||||
toolSlug: 'test-tool',
|
||||
toolkitSlug: 'test-toolkit',
|
||||
params,
|
||||
});
|
||||
|
||||
expect(fileUtils.getFileDataAfterUploadingToS3).toHaveBeenCalledTimes(1);
|
||||
expect(result.arguments?.fileInput).toEqual(mockFileData);
|
||||
});
|
||||
|
||||
it('should not upload when value is null for anyOf with null variant', async () => {
|
||||
const toolWithAnyOf: Tool = {
|
||||
slug: 'test-tool',
|
||||
@@ -1326,7 +1448,7 @@ describe('FileToolModifier', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tools with autoUploadDownloadFiles', () => {
|
||||
describe('Tools with dangerouslyAllowAutoUploadDownloadFiles', () => {
|
||||
const context = createTestContext();
|
||||
setupTest(context);
|
||||
|
||||
@@ -1410,11 +1532,11 @@ describe('Tools with autoUploadDownloadFiles', () => {
|
||||
availableVersions: ['20251201_01'],
|
||||
};
|
||||
|
||||
describe('when autoUploadDownloadFiles is false', () => {
|
||||
describe('when dangerouslyAllowAutoUploadDownloadFiles is false', () => {
|
||||
beforeEach(async () => {
|
||||
context.tools = new Tools(mockClient as unknown as ComposioClient, {
|
||||
provider: context.mockProvider,
|
||||
autoUploadDownloadFiles: false,
|
||||
dangerouslyAllowAutoUploadDownloadFiles: false,
|
||||
});
|
||||
|
||||
// Mock the tool execution
|
||||
@@ -1447,6 +1569,12 @@ describe('Tools with autoUploadDownloadFiles', () => {
|
||||
});
|
||||
|
||||
it('should not modify tool schema for file upload', async () => {
|
||||
// The collapsed `{ type: 'string', format: 'path' }` shape is a promise
|
||||
// that the SDK will stage local paths for the caller. That promise is
|
||||
// only true when auto-upload is on, so we leave the raw shape
|
||||
// untouched when the flag is off — callers are expected to pre-stage
|
||||
// via `composio.files.upload()`. Runtime behavior is documented by
|
||||
// the warning emitted from `applyBeforeExecuteModifiers`.
|
||||
const result = await context.tools.getRawComposioTools({ tools: ['COMPOSIO_TOOL'] });
|
||||
expect(result[0].inputParameters?.properties?.file).not.toHaveProperty('format');
|
||||
expect(fileUtils.getFileDataAfterUploadingToS3).not.toHaveBeenCalled();
|
||||
@@ -1481,6 +1609,35 @@ describe('Tools with autoUploadDownloadFiles', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('warns once per tool when auto-upload is off and the tool has a file-uploadable input', async () => {
|
||||
const warnSpy = vi.spyOn((await import('../../src/utils/logger')).default, 'warn');
|
||||
warnSpy.mockImplementation(() => {}); // silence the log in test output
|
||||
|
||||
vi.spyOn(context.tools, 'getRawComposioToolBySlug').mockResolvedValue(mockToolWithFileUpload);
|
||||
|
||||
// First call emits the warning.
|
||||
await context.tools.execute('COMPOSIO_TOOL', {
|
||||
arguments: { file: '/path/to/file.txt' },
|
||||
userId: 'u',
|
||||
dangerouslySkipVersionCheck: true,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const msg = String(warnSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(msg).toContain('COMPOSIO_TOOL');
|
||||
expect(msg).toContain('dangerouslyAllowAutoUploadDownloadFiles');
|
||||
expect(msg).toContain('composio.files.upload');
|
||||
|
||||
// Repeated executions of the same tool do NOT re-warn.
|
||||
await context.tools.execute('COMPOSIO_TOOL', {
|
||||
arguments: { file: '/path/to/file.txt' },
|
||||
userId: 'u',
|
||||
dangerouslySkipVersionCheck: true,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not download files from execution results', async () => {
|
||||
// Mock getRawComposioToolBySlug for this test
|
||||
vi.spyOn(context.tools, 'getRawComposioToolBySlug').mockResolvedValueOnce(
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Behavior matrix for the file upload / download gating introduced with
|
||||
* `fileUploadDirs` and `fileDownloadDir`. Exercises the interaction between:
|
||||
*
|
||||
* - `dangerouslyAllowAutoUploadDownloadFiles` (off | on)
|
||||
* - Caller (automatic via `tools.execute` | manual via `composio.files.*`)
|
||||
* - Direction (upload | download)
|
||||
* - `fileUploadDirs` shape (`undefined`, `false`, `[]`, `string[]`)
|
||||
*
|
||||
* Runtime filesystem operations and HTTP are mocked; we only assert what the
|
||||
* SDK tries to do and what errors it raises.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import ComposioClient from '@composio/client';
|
||||
|
||||
import { Tools } from '../../src/models/Tools';
|
||||
import { Files } from '../../src/models/Files.node';
|
||||
import * as fileUtilsModule from '../../src/utils/fileUtils.node';
|
||||
import { mockClient } from '../utils/mocks/client.mock';
|
||||
import { MockProvider } from '../utils/mocks/provider.mock';
|
||||
import {
|
||||
ComposioFileUploadPathNotAllowedError,
|
||||
ComposioFileNotFoundError,
|
||||
} from '../../src/errors/FileModifierErrors';
|
||||
import { getDefaultUploadDir } from '../../src/utils/fileDirs';
|
||||
|
||||
// Mock network-bound helpers so we can assert inputs without hitting the API.
|
||||
vi.mock('../../src/utils/fileUtils.node', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../src/utils/fileUtils.node')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadFileFromS3: vi.fn(),
|
||||
// Keep the real `getFileDataAfterUploadingToS3` so path checks run, but
|
||||
// stub the S3 upload happening inside it.
|
||||
uploadFileToS3: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mkTemp = () => mkdtempSync(path.join(os.tmpdir(), 'composio-matrix-'));
|
||||
|
||||
const makeTools = (opts: {
|
||||
dangerouslyAllowAutoUploadDownloadFiles?: boolean;
|
||||
fileUploadDirs?: string[] | false;
|
||||
fileDownloadDir?: string;
|
||||
}) => {
|
||||
return new Tools(mockClient as unknown as ComposioClient, {
|
||||
provider: new MockProvider(),
|
||||
...opts,
|
||||
});
|
||||
};
|
||||
|
||||
describe('File handling matrix — Tools constructor resolves allowlist correctly', () => {
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('dangerouslyAllowAutoUploadDownloadFiles = false (default)', () => {
|
||||
it('leaves fileUploadAllowlist undefined (auto-upload code path is never run)', () => {
|
||||
const t = makeTools({});
|
||||
// `fileUploadAllowlist` lives on the private options the class forwards
|
||||
// to FileToolModifier. When the flag is off we leave it undefined so the
|
||||
// modifier isn't even constructed.
|
||||
expect(
|
||||
(t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
|
||||
.fileUploadPathOptions.fileUploadAllowlist
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores fileUploadDirs entirely (including `false`)', () => {
|
||||
const t = makeTools({ fileUploadDirs: false });
|
||||
expect(
|
||||
(t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
|
||||
.fileUploadPathOptions.fileUploadAllowlist
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dangerouslyAllowAutoUploadDownloadFiles = true', () => {
|
||||
it('defaults fileUploadAllowlist to [~/.composio/temp] when fileUploadDirs is unset', () => {
|
||||
const t = makeTools({ dangerouslyAllowAutoUploadDownloadFiles: true });
|
||||
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
|
||||
.fileUploadPathOptions;
|
||||
expect(opts.fileUploadAllowlist).toEqual([getDefaultUploadDir()]);
|
||||
});
|
||||
|
||||
it('resolves to [] when fileUploadDirs is `false` (explicit "no local paths")', () => {
|
||||
const t = makeTools({
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
fileUploadDirs: false,
|
||||
});
|
||||
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
|
||||
.fileUploadPathOptions;
|
||||
expect(opts.fileUploadAllowlist).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves to [] when fileUploadDirs is `[]` (alias for `false`)', () => {
|
||||
const t = makeTools({
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
fileUploadDirs: [],
|
||||
});
|
||||
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
|
||||
.fileUploadPathOptions;
|
||||
expect(opts.fileUploadAllowlist).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses user-provided dirs verbatim when fileUploadDirs is a non-empty array', () => {
|
||||
const dir = mkTemp();
|
||||
try {
|
||||
const t = makeTools({
|
||||
dangerouslyAllowAutoUploadDownloadFiles: true,
|
||||
fileUploadDirs: [dir],
|
||||
});
|
||||
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
|
||||
.fileUploadPathOptions;
|
||||
expect(opts.fileUploadAllowlist).toEqual([path.resolve(dir)]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('File handling matrix — manual Files API bypasses the allowlist', () => {
|
||||
const mkClient = () =>
|
||||
({
|
||||
baseURL: 'https://api.composio.dev',
|
||||
apiKey: 'test',
|
||||
}) as unknown as ComposioClient;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('composio.files.upload() accepts a local path that is NOT in any allowlist', async () => {
|
||||
const tmp = mkTemp();
|
||||
try {
|
||||
const file = path.join(tmp, 'a.txt');
|
||||
writeFileSync(file, 'x');
|
||||
|
||||
// Stub the actual S3 call so the manual upload can complete.
|
||||
const stubbed = vi.spyOn(fileUtilsModule, 'getFileDataAfterUploadingToS3');
|
||||
stubbed.mockResolvedValueOnce({ name: 'a.txt', mimetype: 'text/plain', s3key: 'k' });
|
||||
|
||||
const files = new Files(mkClient());
|
||||
const result = await files.upload({
|
||||
file,
|
||||
toolSlug: 'T',
|
||||
toolkitSlug: 'tk',
|
||||
});
|
||||
|
||||
expect(result.s3key).toBe('k');
|
||||
|
||||
// The call must NOT include a `fileUploadAllowlist` — that's what keeps
|
||||
// manual upload unconstrained.
|
||||
const callArg = stubbed.mock.calls[0]?.[1] as { fileUploadAllowlist?: unknown };
|
||||
expect(callArg.fileUploadAllowlist).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('composio.files.download() works regardless of `dangerouslyAllowAutoUploadDownloadFiles`', async () => {
|
||||
const downloadMock = vi.spyOn(fileUtilsModule, 'downloadFileFromS3');
|
||||
downloadMock.mockResolvedValueOnce({
|
||||
name: 'x.txt',
|
||||
mimeType: 'text/plain',
|
||||
s3Url: 'https://s3.example/x',
|
||||
filePath: '/tmp/fake/x.txt',
|
||||
});
|
||||
|
||||
const files = new Files(mkClient(), { fileDownloadDir: '/var/app/dl' });
|
||||
const res = await files.download({
|
||||
toolSlug: 'T',
|
||||
s3Url: 'https://s3.example/x',
|
||||
mimeType: 'text/plain',
|
||||
});
|
||||
|
||||
expect(res.filePath).toBe('/tmp/fake/x.txt');
|
||||
// The manual download path forwards the configured `fileDownloadDir`.
|
||||
expect(downloadMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
fileDownloadDir: '/var/app/dl',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('File handling matrix — auto upload honors the resolved allowlist', () => {
|
||||
let getFileDataSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Keep the real implementation so allowlist enforcement runs, but stub the
|
||||
// S3 upload that happens inside it.
|
||||
getFileDataSpy = vi.spyOn(fileUtilsModule, 'uploadFileToS3' as never);
|
||||
(getFileDataSpy as unknown as { mockResolvedValue: (v: string) => void }).mockResolvedValue(
|
||||
's3-key'
|
||||
);
|
||||
});
|
||||
|
||||
const callUpload = async (file: string, fileUploadAllowlist: string[] | undefined) => {
|
||||
return fileUtilsModule.getFileDataAfterUploadingToS3(file, {
|
||||
toolSlug: 'T',
|
||||
toolkitSlug: 'tk',
|
||||
client: mockClient as unknown as ComposioClient,
|
||||
fileUploadAllowlist,
|
||||
});
|
||||
};
|
||||
|
||||
it('rejects local paths outside the allowlist with ComposioFileUploadPathNotAllowedError', async () => {
|
||||
const allowed = mkTemp();
|
||||
const outside = mkTemp();
|
||||
try {
|
||||
const file = path.join(outside, 'leak.txt');
|
||||
writeFileSync(file, 'x');
|
||||
await expect(callUpload(file, [allowed])).rejects.toBeInstanceOf(
|
||||
ComposioFileUploadPathNotAllowedError
|
||||
);
|
||||
} finally {
|
||||
rmSync(allowed, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects ALL local paths when allowlist is [] (the `false` / fail-closed case)', async () => {
|
||||
const tmp = mkTemp();
|
||||
try {
|
||||
const file = path.join(tmp, 'a.txt');
|
||||
writeFileSync(file, 'x');
|
||||
await expect(callUpload(file, [])).rejects.toBeInstanceOf(
|
||||
ComposioFileUploadPathNotAllowedError
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows URLs even when allowlist is [] (URLs are never path-checked)', async () => {
|
||||
// Stub global fetch so `readFile` (URL branch) doesn't hit the network.
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockRejectedValueOnce(new Error('stubbed-network'));
|
||||
|
||||
// Passes the allowlist gate (because the URL isn't a local path), then
|
||||
// fails at the network layer. Any non-allowlist error proves the gate
|
||||
// didn't reject the URL.
|
||||
const http = 'https://example.com/file.pdf';
|
||||
await expect(callUpload(http, [])).rejects.toThrowError('stubbed-network');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('raises ComposioFileNotFoundError when the file is missing under auto-upload', async () => {
|
||||
const allowed = mkTemp();
|
||||
try {
|
||||
const missing = path.join(allowed, 'nope.txt');
|
||||
await expect(callUpload(missing, [allowed])).rejects.toBeInstanceOf(
|
||||
ComposioFileNotFoundError
|
||||
);
|
||||
} finally {
|
||||
rmSync(allowed, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT throw an allowlist error when the file is inside an allowed dir', async () => {
|
||||
// We can't easily stub the S3 upload from outside the module, so we just
|
||||
// assert the error (if any) is not the allowlist rejection. The happy
|
||||
// path for `assertPathInsideUploadDirs` itself is covered directly in
|
||||
// `test/utils/uploadDirAllowlist.test.ts`.
|
||||
const allowed = mkTemp();
|
||||
try {
|
||||
const file = path.join(allowed, 'ok.txt');
|
||||
writeFileSync(file, 'x');
|
||||
try {
|
||||
await callUpload(file, [allowed]);
|
||||
} catch (err) {
|
||||
expect(err).not.toBeInstanceOf(ComposioFileUploadPathNotAllowedError);
|
||||
expect(err).not.toBeInstanceOf(ComposioFileNotFoundError);
|
||||
}
|
||||
} finally {
|
||||
rmSync(allowed, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -335,6 +335,58 @@ describe('fileUtils', () => {
|
||||
})
|
||||
).rejects.toThrow('Failed to download file: Not Found');
|
||||
});
|
||||
|
||||
it('synthesizes the filename server-side and ignores the s3Url path', async () => {
|
||||
// Regression: lock the invariant that the saved filename comes from
|
||||
// (toolSlug, mimeType, timestamp, random id) — never from the s3Url.
|
||||
// If a future change started reflecting the s3Url path or query into
|
||||
// the local filename, an attacker-controlled CDN response could write
|
||||
// outside the download dir or stomp on existing files.
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(10)),
|
||||
});
|
||||
|
||||
const result = await downloadFileFromS3({
|
||||
toolSlug: 'github',
|
||||
// The URL path contains traversal-looking segments and a totally
|
||||
// different "filename"; none of this should leak into result.name.
|
||||
s3Url:
|
||||
'https://s3.example.com/../../../etc/passwd?response-content-disposition=attachment%3Bfilename%3Devil.sh',
|
||||
mimeType: 'text/plain',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('github_1640995200000abc12345.txt');
|
||||
expect(result.name).not.toContain('..');
|
||||
expect(result.name).not.toContain('/');
|
||||
expect(result.name).not.toContain('passwd');
|
||||
expect(result.name).not.toContain('evil');
|
||||
});
|
||||
|
||||
it('strips path components from a path-laden toolSlug before saving', async () => {
|
||||
// Regression: the prefix is `${toolSlug}_`, so a slug containing `/`
|
||||
// could in theory smuggle path components into the saved filename.
|
||||
// `saveFile` defends against that by running the assembled filename
|
||||
// through `path.basename` before joining with the download dir.
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(10)),
|
||||
});
|
||||
|
||||
const result = await downloadFileFromS3({
|
||||
toolSlug: 'AAA/../etc/passwd',
|
||||
s3Url: 'https://s3.example.com/file.bin',
|
||||
mimeType: 'text/plain',
|
||||
});
|
||||
|
||||
// `result.name` is the pre-basename string (raw composed name); the
|
||||
// actual on-disk path goes through `path.basename`. We assert that
|
||||
// the on-disk path stays under the download dir by checking the
|
||||
// returned filePath.
|
||||
expect(result.filePath).toBeDefined();
|
||||
expect(path.dirname(result.filePath as string)).not.toContain('etc');
|
||||
expect(path.basename(result.filePath as string)).toBe('passwd_1640995200000abc12345.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File object handling', () => {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { mkdtempSync, writeFileSync, symlinkSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { assertPathInsideUploadDirs } from '../../src/utils/uploadDirAllowlist.node';
|
||||
import { resolveEffectiveUploadAllowlist, getDefaultUploadDir } from '../../src/utils/fileDirs';
|
||||
import {
|
||||
ComposioFileNotFoundError,
|
||||
ComposioFileUploadPathNotAllowedError,
|
||||
} from '../../src/errors/FileModifierErrors';
|
||||
|
||||
describe('resolveEffectiveUploadAllowlist', () => {
|
||||
it('returns the default [<home>/.composio/temp] when userDirs is undefined', () => {
|
||||
const out = resolveEffectiveUploadAllowlist(undefined);
|
||||
const def = getDefaultUploadDir();
|
||||
expect(def).not.toBeNull();
|
||||
expect(out).toEqual([def]);
|
||||
});
|
||||
|
||||
it('returns [] (fail-closed) when user passes []', () => {
|
||||
expect(resolveEffectiveUploadAllowlist([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] when user passes `false` (explicit "no local paths")', () => {
|
||||
expect(resolveEffectiveUploadAllowlist(false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces the default when user passes explicit dirs', () => {
|
||||
const dirs = ['/tmp/one', '/tmp/two'];
|
||||
const out = resolveEffectiveUploadAllowlist(dirs);
|
||||
expect(out).toEqual(dirs.map(d => path.resolve(d)));
|
||||
});
|
||||
|
||||
it('expands leading ~ in entries', () => {
|
||||
const out = resolveEffectiveUploadAllowlist(['~/foo']);
|
||||
expect(out[0]).toBe(path.resolve(os.homedir(), 'foo'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertPathInsideUploadDirs', () => {
|
||||
const makeTempRoot = () => mkdtempSync(path.join(os.tmpdir(), 'composio-allowlist-'));
|
||||
|
||||
it('accepts a file inside an allowed directory', () => {
|
||||
const root = makeTempRoot();
|
||||
try {
|
||||
const file = path.join(root, 'hello.txt');
|
||||
writeFileSync(file, 'hi');
|
||||
expect(() => assertPathInsideUploadDirs(file, [root])).not.toThrow();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws ComposioFileUploadPathNotAllowedError when path is outside all allowed dirs', () => {
|
||||
const root = makeTempRoot();
|
||||
const other = makeTempRoot();
|
||||
try {
|
||||
const file = path.join(root, 'hello.txt');
|
||||
writeFileSync(file, 'hi');
|
||||
expect(() => assertPathInsideUploadDirs(file, [other])).toThrow(
|
||||
ComposioFileUploadPathNotAllowedError
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
rmSync(other, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces component-boundary matching (does not treat sibling prefix as inside)', () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'composio-boundary-'));
|
||||
try {
|
||||
const allowed = path.join(root, 'uploads');
|
||||
const sibling = path.join(root, 'uploads-not-allowed');
|
||||
mkdirSync(allowed);
|
||||
mkdirSync(sibling);
|
||||
const file = path.join(sibling, 'leak.txt');
|
||||
writeFileSync(file, 'nope');
|
||||
expect(() => assertPathInsideUploadDirs(file, [allowed])).toThrow(
|
||||
ComposioFileUploadPathNotAllowedError
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects paths that escape via symlink (realpath is enforced)', () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'composio-symesc-'));
|
||||
try {
|
||||
const allowed = path.join(root, 'allowed');
|
||||
const outside = path.join(root, 'outside');
|
||||
mkdirSync(allowed);
|
||||
mkdirSync(outside);
|
||||
const target = path.join(outside, 'secret.txt');
|
||||
writeFileSync(target, 'x');
|
||||
const link = path.join(allowed, 'innocent.txt');
|
||||
symlinkSync(target, link);
|
||||
expect(() => assertPathInsideUploadDirs(link, [allowed])).toThrow(
|
||||
ComposioFileUploadPathNotAllowedError
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws ComposioFileNotFoundError when the file does not exist', () => {
|
||||
const root = makeTempRoot();
|
||||
try {
|
||||
const missing = path.join(root, 'does-not-exist.txt');
|
||||
expect(() => assertPathInsideUploadDirs(missing, [root])).toThrow(ComposioFileNotFoundError);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws ComposioFileUploadPathNotAllowedError with empty allowlist (fail-closed)', () => {
|
||||
const root = makeTempRoot();
|
||||
try {
|
||||
const file = path.join(root, 'a.txt');
|
||||
writeFileSync(file, 'x');
|
||||
expect(() => assertPathInsideUploadDirs(file, [])).toThrow(
|
||||
ComposioFileUploadPathNotAllowedError
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('includes actionable guidance and the resolved path in the error message', () => {
|
||||
const root = makeTempRoot();
|
||||
try {
|
||||
const file = path.join(root, 'a.txt');
|
||||
writeFileSync(file, 'x');
|
||||
try {
|
||||
assertPathInsideUploadDirs(file, ['/tmp/definitely-not-here-xyz']);
|
||||
throw new Error('expected to throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ComposioFileUploadPathNotAllowedError);
|
||||
const message = (err as Error).message;
|
||||
expect(message).toContain('fileUploadDirs');
|
||||
expect(message).toContain('dangerouslyAllowAutoUploadDownloadFiles');
|
||||
expect(message).toContain('~/.composio/temp');
|
||||
expect(message).toContain(file);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user