diff --git a/.env.live.example b/.env.live.example
index 48f6b89..db1bda3 100644
--- a/.env.live.example
+++ b/.env.live.example
@@ -51,3 +51,8 @@ QODER_REALTIME_GLOBAL_SAT=
# Optional raw PCM16 input (16 kHz mono little-endian); otherwise sends short text.
# QODER_REALTIME_CN_PCM_FILE=
# QODER_REALTIME_GLOBAL_PCM_FILE=
+
+# Forward Drive Beta lifecycle (creates isolated Identities; no model execution).
+# Uses QODER_FORWARD_CN_PAT, QODER_FORWARD_GLOBAL_PAT, QODER_SA_CN_KEY,
+# and QODER_SA_GLOBAL_KEY. Never point clear at a shared Identity.
+QODER_DRIVE_LIVE_ALLOW_WRITE=false
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 750431a..f9cdd8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ existing `0.1.0` release; earlier development prereleases are not listed.
## [Unreleased]
+### Added
+
+- Forward Drive Beta: typed directory listing, upload/download signed URLs, entry deletion and Drive clearing, with required Identity scope, independent cursor pagination, service ordering and canonical directory query encoding.
+- Template `managed_tool_config.enabled_tools` in create/update requests and responses, including omission, empty-list and null semantics. Runnable Drive examples keep storage requests separate from QCA authentication; opt-in CN/Global lifecycle tests cover PAT, admin SAT and Identity SAT.
+
## [0.5.0]
### Added
diff --git a/Makefile b/Makefile
index e035134..82b25c8 100644
--- a/Makefile
+++ b/Makefile
@@ -45,3 +45,7 @@ test-live-realtime:
test-live-sa-tokens:
QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/sa/test_tokens_live.py -m integration -v -s
+
+.PHONY: test-live-drive
+test-live-drive:
+ QODER_DRIVE_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/integration/test_drive.py -m integration -v -s
diff --git a/README.md b/README.md
index e0cdd0b..aa50764 100644
--- a/README.md
+++ b/README.md
@@ -609,6 +609,57 @@ credential = client.vaults.credentials.retrieve("credential-id", vault_id="vault
memory = client.memory_stores.memories.retrieve("memory-id", memory_store_id="store-id")
```
+## Forward Drive (Beta)
+
+Drive is a Beta API. It uses `entries` / `next_page_token`, not the ordinary
+resource paginator. Pass `identity_id` even with an Identity SAT, preserve the
+server order, and reuse a cursor only within the same Identity, directory and
+authentication scope. Paths are relative; the SDK preserves slashes when encoding
+the list query and leaves path validation to the service.
+
+Upload/download methods return a signed URL, method, headers and expiry. Send the
+storage request with a separate HTTP client using only those returned headers;
+do not attach QCA credentials or log signed URLs. Replaying an `Idempotency-Key`
+does not renew the URL: use a new key or omit it when requesting a fresh URL.
+
+Deleting a nonempty directory requires `recursive=true`; `clear` deletes the
+entire selected Identity Drive. Partial failures retain `drive_delete_partial`
+and `deleted_count` in the standard API error body. Direct Drive APIs do not
+require Template tool configuration.
+
+```python
+page = client.drives.list_entries(identity_id=identity_id, path="reports", limit=20)
+for entry in page.entries:
+ print(entry.path)
+if page.next_page_token:
+ page = client.drives.list_entries(
+ identity_id=identity_id, path="reports", page_token=page.next_page_token,
+ )
+# Replace the complete managed-tool list, retaining any other tools you need.
+client.templates.update(template_id, managed_tool_config={"enabled_tools": ["drive"]})
+```
+
+`AsyncForward` exposes the same methods with `await`. Run the
+[file lifecycle example](examples/forward/drive.py) with
+`python -m examples.forward.drive --region cn` (or `global`); it uses the shared
+PAT/SA configuration and creates a fresh Identity. A directory has `size=None`;
+a zero-byte file has `size=0`.
+For maintainers, `uv run make test-live-drive` tests CN/Global × PAT/admin
+SAT/Identity SAT with both sync and async clients.
+
+To enable Agent-managed Drive tools, use Template `managed_tool_config.enabled_tools`.
+It is separate from `tools[].enabled_tools` and replaces the entire list; an empty
+list disables managed tools, omission preserves the setting, and `null` clears it.
+Full Agent Drive usage also requires the DeliverArtifacts tool. Internal
+`/forward-mcp` routes are invoked by the runtime and are not public SDK methods.
+
+The Drive live suite requires `QODER_DRIVE_LIVE_ALLOW_WRITE=true`,
+`QODER_FORWARD_CN_PAT`, `QODER_FORWARD_GLOBAL_PAT`, `QODER_SA_CN_KEY`, and
+`QODER_SA_GLOBAL_KEY` in `.env.live` (or `LIVE_ENV_FILE`). It creates isolated
+Identities, verifies storage transfers, pagination, deletion, Identity isolation,
+and Template configuration, then clears only those new Identities. It does not
+run Agent/model tool execution.
+
## File uploads and downloads
Uploads accept `bytes`, a binary file object, a `Path`, or a `(filename, content[, content type])` tuple. Content is buffered so it can be replayed on retry; a file object you open stays yours to close.
diff --git a/docs/api/reference.md b/docs/api/reference.md
index 4d32912..cf3ab65 100644
--- a/docs/api/reference.md
+++ b/docs/api/reference.md
@@ -2066,6 +2066,17 @@ class Forward(SyncAPIClient)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
+
+#### drives
+
+```python
+@cached_property
+def drives() -> Drives
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
#### realtime
@@ -2263,6 +2274,17 @@ class AsyncForward(AsyncAPIClient)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
+
+#### drives
+
+```python
+@cached_property
+def drives() -> AsyncDrives
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
#### realtime
@@ -3301,6 +3323,244 @@ async def retrieve(
GET /qr_sessions/{session_key}.
+
+
+# qca.forward.resources.drives
+
+
+
+## Drives
+
+```python
+class Drives(SyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+Persistent Identity files (Beta). Identity SATs also require identity_id.
+
+
+
+#### list\_entries
+
+```python
+def list_entries(
+ *,
+ identity_id: str,
+ path: str | NotGiven = NOT_GIVEN,
+ limit: int | NotGiven = NOT_GIVEN,
+ page_token: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveListEntriesResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+GET /drives/entries. Returns one page in service order; reuse next_page_token in the same scope/path.
+
+
+
+#### create\_upload\_url
+
+```python
+def create_upload_url(
+ *,
+ identity_id: str,
+ path: str,
+ content_type: str | NotGiven = NOT_GIVEN,
+ idempotency_key: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveUploadURL
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+POST /drives/upload-url. Use only the returned headers when uploading; do not send API credentials to storage.
+
+
+
+#### create\_download\_url
+
+```python
+def create_download_url(
+ *,
+ identity_id: str,
+ path: str,
+ idempotency_key: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveDownloadURL
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+POST /drives/download-url. Use a new Idempotency-Key or omit it to refresh expiry; replay does not renew it.
+
+
+
+#### delete\_entry
+
+```python
+def delete_entry(
+ *,
+ identity_id: str,
+ path: str,
+ recursive: bool | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveDeleteEntryResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+DELETE /drives/entries. Nonempty directories require recursive=True. Partial failures retain error.deleted_count.
+
+
+
+#### clear
+
+```python
+def clear(
+ *,
+ identity_id: str,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveClearResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+DELETE /drives. Deletes all files in the selected Identity Drive.
+
+
+
+## AsyncDrives
+
+```python
+class AsyncDrives(AsyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+Persistent Identity files (Beta). Identity SATs also require identity_id.
+
+
+
+#### list\_entries
+
+```python
+async def list_entries(
+ *,
+ identity_id: str,
+ path: str | NotGiven = NOT_GIVEN,
+ limit: int | NotGiven = NOT_GIVEN,
+ page_token: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveListEntriesResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+GET /drives/entries. Returns one page in service order; reuse next_page_token in the same scope/path.
+
+
+
+#### create\_upload\_url
+
+```python
+async def create_upload_url(
+ *,
+ identity_id: str,
+ path: str,
+ content_type: str | NotGiven = NOT_GIVEN,
+ idempotency_key: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveUploadURL
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+POST /drives/upload-url. Use only the returned headers when uploading; do not send API credentials to storage.
+
+
+
+#### create\_download\_url
+
+```python
+async def create_download_url(
+ *,
+ identity_id: str,
+ path: str,
+ idempotency_key: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveDownloadURL
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+POST /drives/download-url. Use a new Idempotency-Key or omit it to refresh expiry; replay does not renew it.
+
+
+
+#### delete\_entry
+
+```python
+async def delete_entry(
+ *,
+ identity_id: str,
+ path: str,
+ recursive: bool | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveDeleteEntryResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+DELETE /drives/entries. Nonempty directories require recursive=True. Partial failures retain error.deleted_count.
+
+
+
+#### clear
+
+```python
+async def clear(
+ *,
+ identity_id: str,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> DriveClearResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py)
+
+DELETE /drives. Deletes all files in the selected Identity Drive.
+
# qca.forward.resources.environments
@@ -8477,6 +8737,8 @@ def create(
mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN,
skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN,
multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN,
+ managed_tool_config: Union[ManagedToolConfigParam, None,
+ NotGiven] = NOT_GIVEN,
vaults: Union[Dict[str, ResourceBindingParam], None,
NotGiven] = NOT_GIVEN,
files: Union[Dict[str, ResourceBindingParam], None,
@@ -8534,6 +8796,8 @@ def update(
mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN,
skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN,
multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN,
+ managed_tool_config: Union[ManagedToolConfigParam, None,
+ NotGiven] = NOT_GIVEN,
environment_id: Union[str, None, NotGiven] = NOT_GIVEN,
vaults: Union[Dict[str, ResourceBindingParam], None,
NotGiven] = NOT_GIVEN,
@@ -8647,6 +8911,8 @@ async def create(
mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN,
skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN,
multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN,
+ managed_tool_config: Union[ManagedToolConfigParam, None,
+ NotGiven] = NOT_GIVEN,
vaults: Union[Dict[str, ResourceBindingParam], None,
NotGiven] = NOT_GIVEN,
files: Union[Dict[str, ResourceBindingParam], None,
@@ -8704,6 +8970,8 @@ async def update(
mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN,
skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN,
multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN,
+ managed_tool_config: Union[ManagedToolConfigParam, None,
+ NotGiven] = NOT_GIVEN,
environment_id: Union[str, None, NotGiven] = NOT_GIVEN,
vaults: Union[Dict[str, ResourceBindingParam], None,
NotGiven] = NOT_GIVEN,
@@ -10349,6 +10617,268 @@ class DeletedSkillVersion(BaseModel)
#### deleted
+
+
+# qca.forward.types.drive
+
+
+
+## DriveEntry
+
+```python
+class DriveEntry(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### path
+
+
+
+#### name
+
+
+
+#### type
+
+
+
+#### size
+
+
+
+#### etag
+
+
+
+#### last\_modified
+
+
+
+## DriveListEntriesResponse
+
+```python
+class DriveListEntriesResponse(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+One page in service order; pass next_page_token unchanged to continue.
+
+
+
+#### entries
+
+
+
+#### next\_page\_token
+
+
+
+## DriveUploadURL
+
+```python
+class DriveUploadURL(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+Signed storage URL valid for 15 minutes. Treat url as a credential.
+
+
+
+#### url
+
+
+
+#### method
+
+
+
+#### headers
+
+
+
+#### expires\_at
+
+
+
+## DriveDownloadURL
+
+```python
+class DriveDownloadURL(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### url
+
+
+
+#### method
+
+
+
+#### headers
+
+
+
+#### expires\_at
+
+
+
+#### size
+
+
+
+#### etag
+
+
+
+#### content\_type
+
+
+
+## DriveDeleteEntryResponse
+
+```python
+class DriveDeleteEntryResponse(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### deleted\_count
+
+
+
+## DriveClearResponse
+
+```python
+class DriveClearResponse(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### deleted\_count
+
+
+
+## DriveListEntriesParams
+
+```python
+class DriveListEntriesParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### identity\_id
+
+
+
+#### path
+
+
+
+#### limit
+
+
+
+#### page\_token
+
+
+
+## DriveCreateUploadURLParams
+
+```python
+class DriveCreateUploadURLParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### identity\_id
+
+
+
+#### path
+
+
+
+#### content\_type
+
+
+
+#### idempotency\_key
+
+
+
+## DriveCreateDownloadURLParams
+
+```python
+class DriveCreateDownloadURLParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### identity\_id
+
+
+
+#### path
+
+
+
+#### idempotency\_key
+
+
+
+## DriveDeleteEntryParams
+
+```python
+class DriveDeleteEntryParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### identity\_id
+
+
+
+#### path
+
+
+
+#### recursive
+
+
+
+## DriveClearParams
+
+```python
+class DriveClearParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py)
+
+
+
+#### identity\_id
+
# qca.forward.types.effective\_config
@@ -11645,6 +12175,46 @@ class ImageSourceParam(TypedDict)
#### file\_id
+
+
+# qca.forward.types.managed\_tool\_config
+
+
+
+## ManagedToolConfig
+
+```python
+class ManagedToolConfig(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/managed_tool_config.py)
+
+Forward-managed tools, distinct from tools[].enabled_tools.
+
+
+
+#### enabled\_tools
+
+
+
+# qca.forward.types.managed\_tool\_config\_param
+
+
+
+## ManagedToolConfigParam
+
+```python
+class ManagedToolConfigParam(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/managed_tool_config_param.py)
+
+Replaces the complete enabled list; [] disables all managed tools.
+
+
+
+#### enabled\_tools
+
# qca.forward.types.mcp\_server
@@ -15453,6 +16023,10 @@ class Template(BaseModel)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/template.py)
+
+
+#### managed\_tool\_config
+
#### type
@@ -15587,6 +16161,10 @@ class TemplateCreateParams(TypedDict)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/template_create_params.py)
+
+
+#### managed\_tool\_config
+
#### name
@@ -15695,6 +16273,10 @@ class TemplateUpdateParams(TypedDict)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/template_update_params.py)
+
+
+#### managed\_tool\_config
+
#### name
diff --git a/examples/forward/drive.py b/examples/forward/drive.py
new file mode 100644
index 0000000..8d040cc
--- /dev/null
+++ b/examples/forward/drive.py
@@ -0,0 +1,61 @@
+"""Drive Beta: upload, page through a directory, download and delete a file.
+
+Run: python -m examples.forward.drive
+Uses the shared PAT / SA example configuration and a new, isolated Identity.
+"""
+
+from __future__ import annotations
+
+import httpx
+
+from examples.common.live import Run, name, run_cli
+from qca import Forward
+
+
+def run(client: Forward, context: Run) -> None:
+ identity = client.identities.create(external_id=name("drive-identity"))
+ context.track("identity", identity.id, lambda: client.identities.delete(identity.id))
+ path = "sdk-example/说明.txt"
+ context.track(
+ "drive directory",
+ "sdk-example",
+ lambda: client.drives.delete_entry(identity_id=identity.id, path="sdk-example", recursive=True),
+ )
+ # A separate HTTP client sends only storage headers, never the QCA credential.
+ with httpx.Client(timeout=30, follow_redirects=False) as storage:
+ signed = client.drives.create_upload_url(identity_id=identity.id, path=path, content_type="text/plain")
+ try:
+ uploaded = storage.request(
+ signed.method, signed.url, headers=signed.headers, content=b"Hello from QCA Drive"
+ )
+ except httpx.HTTPError:
+ raise RuntimeError("Storage upload failed") from None
+ if not uploaded.is_success:
+ raise RuntimeError(f"Storage upload HTTP {uploaded.status_code}")
+ cursor = None
+ while True:
+ page = client.drives.list_entries(
+ identity_id=identity.id,
+ path="sdk-example",
+ limit=20,
+ **({"page_token": cursor} if cursor else {}),
+ )
+ for entry in page.entries:
+ context.output("drive_entry", {"path": entry.path, "type": entry.type, "size": entry.size})
+ cursor = page.next_page_token
+ if not cursor:
+ break
+ signed = client.drives.create_download_url(identity_id=identity.id, path=path)
+ try:
+ downloaded = storage.request(signed.method, signed.url, headers=signed.headers)
+ except httpx.HTTPError:
+ raise RuntimeError("Storage download failed") from None
+ if not downloaded.is_success:
+ raise RuntimeError(f"Storage download HTTP {downloaded.status_code}")
+ context.output("download", downloaded.text)
+ deleted = client.drives.delete_entry(identity_id=identity.id, path=path)
+ context.output("deleted_count", deleted.deleted_count)
+
+
+if __name__ == "__main__":
+ run_cli("forward", Forward, {"drive": run})
diff --git a/src/qca/common/_base_client.py b/src/qca/common/_base_client.py
index d5e5106..44eb7a4 100644
--- a/src/qca/common/_base_client.py
+++ b/src/qca/common/_base_client.py
@@ -364,6 +364,8 @@ def request(
if stream:
args["headers"]["Accept"] = "text/event-stream"
request = self._client.build_request(**args)
+ if serializer := options.get("query_serializer"):
+ request.url = request.url.copy_with(query=serializer(request.url.params.multi_items()).encode("ascii"))
request.extensions["qca_explicit_auth"] = any(
key.lower() == "authorization" for key in (*self.default_headers, *options.get("headers", {}))
)
@@ -498,6 +500,8 @@ async def request(
if stream:
args["headers"]["Accept"] = "text/event-stream"
request = self._client.build_request(**args)
+ if serializer := options.get("query_serializer"):
+ request.url = request.url.copy_with(query=serializer(request.url.params.multi_items()).encode("ascii"))
request.extensions["qca_explicit_auth"] = any(
key.lower() == "authorization" for key in (*self.default_headers, *options.get("headers", {}))
)
diff --git a/src/qca/forward/_client.py b/src/qca/forward/_client.py
index edc095c..437fe84 100644
--- a/src/qca/forward/_client.py
+++ b/src/qca/forward/_client.py
@@ -7,6 +7,7 @@
from .resources.batches.batches import AsyncBatches, Batches
from .resources.channel_pairings import AsyncChannelPairings, ChannelPairings
from .resources.channels.channels import AsyncChannels, Channels
+from .resources.drives import AsyncDrives, Drives
from .resources.environments import AsyncEnvironments, Environments
from .resources.files import AsyncFiles, Files
from .resources.identities.identities import AsyncIdentities, Identities
@@ -24,6 +25,10 @@
class Forward(SyncAPIClient):
+ @cached_property
+ def drives(self) -> Drives:
+ return Drives(self)
+
_default_base_url = "https://api.qoder.com/api/v1/forward/"
_base_url_env = "QODER_FORWARD_BASE_URL"
@@ -97,6 +102,10 @@ def models(self) -> Models:
class AsyncForward(AsyncAPIClient):
+ @cached_property
+ def drives(self) -> AsyncDrives:
+ return AsyncDrives(self)
+
_default_base_url = "https://api.qoder.com/api/v1/forward/"
_base_url_env = "QODER_FORWARD_BASE_URL"
diff --git a/src/qca/forward/resources/__init__.py b/src/qca/forward/resources/__init__.py
index fc034db..cc2234f 100644
--- a/src/qca/forward/resources/__init__.py
+++ b/src/qca/forward/resources/__init__.py
@@ -1,5 +1,7 @@
from .channel_pairings import AsyncChannelPairings as AsyncChannelPairings
from .channel_pairings import ChannelPairings as ChannelPairings
+from .drives import AsyncDrives as AsyncDrives
+from .drives import Drives as Drives
from .environments import AsyncEnvironments as AsyncEnvironments
from .environments import Environments as Environments
from .files import AsyncFiles as AsyncFiles
diff --git a/src/qca/forward/resources/drives.py b/src/qca/forward/resources/drives.py
new file mode 100644
index 0000000..b303ba0
--- /dev/null
+++ b/src/qca/forward/resources/drives.py
@@ -0,0 +1,270 @@
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import quote_plus
+
+import httpx
+
+from qca.common._resource import AsyncAPIResource, SyncAPIResource
+from qca.common._types import NOT_GIVEN, NotGiven
+from qca.common._utils import make_request_options
+from qca.forward.types.drive import (
+ DriveClearResponse,
+ DriveDeleteEntryResponse,
+ DriveDownloadURL,
+ DriveListEntriesResponse,
+ DriveUploadURL,
+)
+
+
+def _drive_query(pairs: list[tuple[str, str]]) -> str:
+ # Only path uses literal slashes; opaque cursor values stay fully encoded.
+ return "&".join(
+ quote_plus(key) + "=" + quote_plus(value, safe="/" if key == "path" else "") for key, value in pairs
+ )
+
+
+class Drives(SyncAPIResource):
+ """Persistent Identity files (Beta). Identity SATs also require identity_id."""
+
+ def list_entries(
+ self,
+ *,
+ identity_id: str,
+ path: str | NotGiven = NOT_GIVEN,
+ limit: int | NotGiven = NOT_GIVEN,
+ page_token: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveListEntriesResponse:
+ """GET /drives/entries. Returns one page in service order; reuse next_page_token in the same scope/path."""
+ options = make_request_options(
+ body={},
+ query={"identity_id": identity_id, "path": path, "limit": limit, "page_token": page_token},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ options["query_serializer"] = _drive_query
+ return self._client.request("GET", "/drives/entries", cast_to=DriveListEntriesResponse, options=options)
+
+ def create_upload_url(
+ self,
+ *,
+ identity_id: str,
+ path: str,
+ content_type: str | NotGiven = NOT_GIVEN,
+ idempotency_key: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveUploadURL:
+ """POST /drives/upload-url. Use only the returned headers when uploading; do not send API credentials to storage."""
+ options = make_request_options(
+ body={"path": path, "content_type": content_type},
+ query={"identity_id": identity_id},
+ headers={"Idempotency-Key": idempotency_key},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("POST", "/drives/upload-url", cast_to=DriveUploadURL, options=options)
+
+ def create_download_url(
+ self,
+ *,
+ identity_id: str,
+ path: str,
+ idempotency_key: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveDownloadURL:
+ """POST /drives/download-url. Use a new Idempotency-Key or omit it to refresh expiry; replay does not renew it."""
+ options = make_request_options(
+ body={"path": path},
+ query={"identity_id": identity_id},
+ headers={"Idempotency-Key": idempotency_key},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("POST", "/drives/download-url", cast_to=DriveDownloadURL, options=options)
+
+ def delete_entry(
+ self,
+ *,
+ identity_id: str,
+ path: str,
+ recursive: bool | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveDeleteEntryResponse:
+ """DELETE /drives/entries. Nonempty directories require recursive=True. Partial failures retain error.deleted_count."""
+ options = make_request_options(
+ body={"path": path, "recursive": recursive},
+ query={"identity_id": identity_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("DELETE", "/drives/entries", cast_to=DriveDeleteEntryResponse, options=options)
+
+ def clear(
+ self,
+ *,
+ identity_id: str,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveClearResponse:
+ """DELETE /drives. Deletes all files in the selected Identity Drive."""
+ options = make_request_options(
+ body={},
+ query={"identity_id": identity_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return self._client.request("DELETE", "/drives", cast_to=DriveClearResponse, options=options)
+
+
+class AsyncDrives(AsyncAPIResource):
+ """Persistent Identity files (Beta). Identity SATs also require identity_id."""
+
+ async def list_entries(
+ self,
+ *,
+ identity_id: str,
+ path: str | NotGiven = NOT_GIVEN,
+ limit: int | NotGiven = NOT_GIVEN,
+ page_token: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveListEntriesResponse:
+ """GET /drives/entries. Returns one page in service order; reuse next_page_token in the same scope/path."""
+ options = make_request_options(
+ body={},
+ query={"identity_id": identity_id, "path": path, "limit": limit, "page_token": page_token},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ options["query_serializer"] = _drive_query
+ return await self._client.request("GET", "/drives/entries", cast_to=DriveListEntriesResponse, options=options)
+
+ async def create_upload_url(
+ self,
+ *,
+ identity_id: str,
+ path: str,
+ content_type: str | NotGiven = NOT_GIVEN,
+ idempotency_key: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveUploadURL:
+ """POST /drives/upload-url. Use only the returned headers when uploading; do not send API credentials to storage."""
+ options = make_request_options(
+ body={"path": path, "content_type": content_type},
+ query={"identity_id": identity_id},
+ headers={"Idempotency-Key": idempotency_key},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("POST", "/drives/upload-url", cast_to=DriveUploadURL, options=options)
+
+ async def create_download_url(
+ self,
+ *,
+ identity_id: str,
+ path: str,
+ idempotency_key: str | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveDownloadURL:
+ """POST /drives/download-url. Use a new Idempotency-Key or omit it to refresh expiry; replay does not renew it."""
+ options = make_request_options(
+ body={"path": path},
+ query={"identity_id": identity_id},
+ headers={"Idempotency-Key": idempotency_key},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("POST", "/drives/download-url", cast_to=DriveDownloadURL, options=options)
+
+ async def delete_entry(
+ self,
+ *,
+ identity_id: str,
+ path: str,
+ recursive: bool | NotGiven = NOT_GIVEN,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveDeleteEntryResponse:
+ """DELETE /drives/entries. Nonempty directories require recursive=True. Partial failures retain error.deleted_count."""
+ options = make_request_options(
+ body={"path": path, "recursive": recursive},
+ query={"identity_id": identity_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request(
+ "DELETE", "/drives/entries", cast_to=DriveDeleteEntryResponse, options=options
+ )
+
+ async def clear(
+ self,
+ *,
+ identity_id: str,
+ extra_headers: dict[str, str] | None = None,
+ extra_query: dict[str, Any] | None = None,
+ extra_body: dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> DriveClearResponse:
+ """DELETE /drives. Deletes all files in the selected Identity Drive."""
+ options = make_request_options(
+ body={},
+ query={"identity_id": identity_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return await self._client.request("DELETE", "/drives", cast_to=DriveClearResponse, options=options)
diff --git a/src/qca/forward/resources/templates.py b/src/qca/forward/resources/templates.py
index 2c1e957..271710b 100644
--- a/src/qca/forward/resources/templates.py
+++ b/src/qca/forward/resources/templates.py
@@ -9,6 +9,7 @@
from qca.common._utils import make_request_options, path_template
from qca.common.pagination import AsyncPaginator, SyncPage
from qca.forward.types.git_hub_repository_param import GitHubRepositoryParam
+from qca.forward.types.managed_tool_config_param import ManagedToolConfigParam
from qca.forward.types.mcp_server_param import MCPServerParam
from qca.forward.types.model_config_param import ModelConfigParam
from qca.forward.types.multiagent_config_param import MultiagentConfigParam
@@ -58,6 +59,7 @@ def create(
mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN,
skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN,
multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN,
+ managed_tool_config: Union[ManagedToolConfigParam, None, NotGiven] = NOT_GIVEN,
vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN,
files: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN,
github_repositories: Union[Dict[str, GitHubRepositoryParam], None, NotGiven] = NOT_GIVEN,
@@ -83,6 +85,7 @@ def create(
"mcp_servers": mcp_servers,
"skills": skills,
"multiagent": multiagent,
+ "managed_tool_config": managed_tool_config,
"vaults": vaults,
"files": files,
"github_repositories": github_repositories,
@@ -132,6 +135,7 @@ def update(
mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN,
skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN,
multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN,
+ managed_tool_config: Union[ManagedToolConfigParam, None, NotGiven] = NOT_GIVEN,
environment_id: Union[str, None, NotGiven] = NOT_GIVEN,
vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN,
files: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN,
@@ -157,6 +161,7 @@ def update(
"mcp_servers": mcp_servers,
"skills": skills,
"multiagent": multiagent,
+ "managed_tool_config": managed_tool_config,
"environment_id": environment_id,
"vaults": vaults,
"files": files,
@@ -262,6 +267,7 @@ async def create(
mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN,
skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN,
multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN,
+ managed_tool_config: Union[ManagedToolConfigParam, None, NotGiven] = NOT_GIVEN,
vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN,
files: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN,
github_repositories: Union[Dict[str, GitHubRepositoryParam], None, NotGiven] = NOT_GIVEN,
@@ -287,6 +293,7 @@ async def create(
"mcp_servers": mcp_servers,
"skills": skills,
"multiagent": multiagent,
+ "managed_tool_config": managed_tool_config,
"vaults": vaults,
"files": files,
"github_repositories": github_repositories,
@@ -336,6 +343,7 @@ async def update(
mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN,
skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN,
multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN,
+ managed_tool_config: Union[ManagedToolConfigParam, None, NotGiven] = NOT_GIVEN,
environment_id: Union[str, None, NotGiven] = NOT_GIVEN,
vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN,
files: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN,
@@ -361,6 +369,7 @@ async def update(
"mcp_servers": mcp_servers,
"skills": skills,
"multiagent": multiagent,
+ "managed_tool_config": managed_tool_config,
"environment_id": environment_id,
"vaults": vaults,
"files": files,
diff --git a/src/qca/forward/types/__init__.py b/src/qca/forward/types/__init__.py
index 98fb9bc..029381b 100644
--- a/src/qca/forward/types/__init__.py
+++ b/src/qca/forward/types/__init__.py
@@ -37,6 +37,17 @@
from .deleted_memory_store import DeletedMemoryStore as DeletedMemoryStore
from .deleted_memory_store_mount import DeletedMemoryStoreMount as DeletedMemoryStoreMount
from .deleted_skill_version import DeletedSkillVersion as DeletedSkillVersion
+from .drive import DriveClearParams as DriveClearParams
+from .drive import DriveClearResponse as DriveClearResponse
+from .drive import DriveCreateDownloadURLParams as DriveCreateDownloadURLParams
+from .drive import DriveCreateUploadURLParams as DriveCreateUploadURLParams
+from .drive import DriveDeleteEntryParams as DriveDeleteEntryParams
+from .drive import DriveDeleteEntryResponse as DriveDeleteEntryResponse
+from .drive import DriveDownloadURL as DriveDownloadURL
+from .drive import DriveEntry as DriveEntry
+from .drive import DriveListEntriesParams as DriveListEntriesParams
+from .drive import DriveListEntriesResponse as DriveListEntriesResponse
+from .drive import DriveUploadURL as DriveUploadURL
from .effective_config import EffectiveConfig as EffectiveConfig
from .effective_config_agent import EffectiveConfigAgent as EffectiveConfigAgent
from .effective_config_agent_tools_item import EffectiveConfigAgentToolsItem as EffectiveConfigAgentToolsItem
@@ -79,6 +90,8 @@
from .identity_update_params import IdentityUpdateParams as IdentityUpdateParams
from .identity_usage import IdentityUsage as IdentityUsage
from .image_source_param import ImageSourceParam as ImageSourceParam
+from .managed_tool_config import ManagedToolConfig as ManagedToolConfig
+from .managed_tool_config_param import ManagedToolConfigParam as ManagedToolConfigParam
from .mcp_server import MCPServer as MCPServer
from .mcp_server_override import MCPServerOverride as MCPServerOverride
from .mcp_server_override_param import MCPServerOverrideParam as MCPServerOverrideParam
diff --git a/src/qca/forward/types/drive.py b/src/qca/forward/types/drive.py
new file mode 100644
index 0000000..8434525
--- /dev/null
+++ b/src/qca/forward/types/drive.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+
+from typing_extensions import Required, TypedDict
+
+from qca.common._models import BaseModel
+
+
+class DriveEntry(BaseModel):
+ path: str
+ name: str
+ type: Literal["file", "directory"]
+ # Zero for an empty file; absent for directories.
+ size: int | None = None
+ etag: str | None = None
+ last_modified: datetime | None = None
+
+
+class DriveListEntriesResponse(BaseModel):
+ """One page in service order; pass next_page_token unchanged to continue."""
+
+ entries: list[DriveEntry]
+ next_page_token: str | None = None
+
+
+class DriveUploadURL(BaseModel):
+ """Signed storage URL valid for 15 minutes. Treat url as a credential."""
+
+ url: str
+ method: Literal["PUT"]
+ headers: dict[str, str]
+ expires_at: datetime
+
+
+class DriveDownloadURL(BaseModel):
+ url: str
+ method: Literal["GET"]
+ headers: dict[str, str]
+ expires_at: datetime
+ # May be omitted for zero-byte files.
+ size: int | None = None
+ etag: str | None = None
+ content_type: str | None = None
+
+
+class DriveDeleteEntryResponse(BaseModel):
+ deleted_count: int
+
+
+class DriveClearResponse(BaseModel):
+ deleted_count: int
+
+
+class DriveListEntriesParams(TypedDict, total=False):
+ identity_id: Required[str]
+ path: str
+ limit: int
+ page_token: str
+
+
+class DriveCreateUploadURLParams(TypedDict, total=False):
+ identity_id: Required[str]
+ path: Required[str]
+ content_type: str
+ idempotency_key: str
+
+
+class DriveCreateDownloadURLParams(TypedDict, total=False):
+ identity_id: Required[str]
+ path: Required[str]
+ idempotency_key: str
+
+
+class DriveDeleteEntryParams(TypedDict, total=False):
+ identity_id: Required[str]
+ path: Required[str]
+ recursive: bool
+
+
+class DriveClearParams(TypedDict):
+ identity_id: str
diff --git a/src/qca/forward/types/managed_tool_config.py b/src/qca/forward/types/managed_tool_config.py
new file mode 100644
index 0000000..d673cdc
--- /dev/null
+++ b/src/qca/forward/types/managed_tool_config.py
@@ -0,0 +1,7 @@
+from qca.common._models import BaseModel
+
+
+class ManagedToolConfig(BaseModel):
+ """Forward-managed tools, distinct from tools[].enabled_tools."""
+
+ enabled_tools: list[str]
diff --git a/src/qca/forward/types/managed_tool_config_param.py b/src/qca/forward/types/managed_tool_config_param.py
new file mode 100644
index 0000000..8e6ffdd
--- /dev/null
+++ b/src/qca/forward/types/managed_tool_config_param.py
@@ -0,0 +1,7 @@
+from typing_extensions import TypedDict
+
+
+class ManagedToolConfigParam(TypedDict):
+ """Replaces the complete enabled list; [] disables all managed tools."""
+
+ enabled_tools: list[str]
diff --git a/src/qca/forward/types/template.py b/src/qca/forward/types/template.py
index 46a9edf..92d14cf 100644
--- a/src/qca/forward/types/template.py
+++ b/src/qca/forward/types/template.py
@@ -7,6 +7,7 @@
if TYPE_CHECKING:
from .git_hub_repository import GitHubRepository
+ from .managed_tool_config import ManagedToolConfig
from .mcp_server import MCPServer
from .model_config import ModelConfig
from .multiagent_config import MultiagentConfig
@@ -18,6 +19,7 @@
class Template(BaseModel):
+ managed_tool_config: Optional[ManagedToolConfig] = None
type: Optional[str] = None
id: Optional[str] = None
name: Optional[str] = None
diff --git a/src/qca/forward/types/template_create_params.py b/src/qca/forward/types/template_create_params.py
index 1ffbe00..0e652d7 100644
--- a/src/qca/forward/types/template_create_params.py
+++ b/src/qca/forward/types/template_create_params.py
@@ -6,6 +6,7 @@
if TYPE_CHECKING:
from .git_hub_repository_param import GitHubRepositoryParam
+ from .managed_tool_config_param import ManagedToolConfigParam
from .mcp_server_param import MCPServerParam
from .model_config_param import ModelConfigParam
from .multiagent_config_param import MultiagentConfigParam
@@ -17,6 +18,7 @@
class TemplateCreateParams(TypedDict, total=False):
+ managed_tool_config: Optional[ManagedToolConfigParam]
name: Required[str]
model: Required[Union[str, ModelConfigParam]]
environment_id: Required[str]
diff --git a/src/qca/forward/types/template_update_params.py b/src/qca/forward/types/template_update_params.py
index 6c7ab7b..64f8b4d 100644
--- a/src/qca/forward/types/template_update_params.py
+++ b/src/qca/forward/types/template_update_params.py
@@ -6,6 +6,7 @@
if TYPE_CHECKING:
from .git_hub_repository_param import GitHubRepositoryParam
+ from .managed_tool_config_param import ManagedToolConfigParam
from .mcp_server_param import MCPServerParam
from .model_config_param import ModelConfigParam
from .multiagent_config_param import MultiagentConfigParam
@@ -17,6 +18,7 @@
class TemplateUpdateParams(TypedDict, total=False):
+ managed_tool_config: Optional[ManagedToolConfigParam]
name: Optional[str]
description: Optional[str]
model: Union[str, ModelConfigParam]
diff --git a/tests/fixtures/api-contracts.json b/tests/fixtures/api-contracts.json
index 2359db4..b38d33a 100644
--- a/tests/fixtures/api-contracts.json
+++ b/tests/fixtures/api-contracts.json
@@ -1988,6 +1988,54 @@
],
"kind": "model",
"source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke"
+ },
+ {
+ "id": "forward.drives.list_entries",
+ "method": "GET",
+ "path": "/drives/entries",
+ "required": [
+ "identity_id"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.drives.create_upload_url",
+ "method": "POST",
+ "path": "/drives/upload-url",
+ "required": [
+ "identity_id",
+ "path"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.drives.create_download_url",
+ "method": "POST",
+ "path": "/drives/download-url",
+ "required": [
+ "identity_id",
+ "path"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.drives.delete_entry",
+ "method": "DELETE",
+ "path": "/drives/entries",
+ "required": [
+ "identity_id",
+ "path"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.drives.clear",
+ "method": "DELETE",
+ "path": "/drives",
+ "required": [
+ "identity_id"
+ ],
+ "kind": "model"
}
],
"extensions": [
diff --git a/tests/integration/test_drive.py b/tests/integration/test_drive.py
new file mode 100644
index 0000000..9d33175
--- /dev/null
+++ b/tests/integration/test_drive.py
@@ -0,0 +1,178 @@
+"""Opt-in Drive lifecycle on isolated Identities; never clears an existing Drive."""
+
+from __future__ import annotations
+
+import inspect
+import os
+from contextlib import AsyncExitStack
+from uuid import uuid4
+
+import httpx
+import pytest
+
+from qca import APIStatusError, AsyncForward, Forward
+from tests.support.harness import read_env
+
+pytestmark = pytest.mark.integration
+
+
+async def call(method, *args, **kwargs):
+ result = method(*args, **kwargs)
+ return await result if inspect.isawaitable(result) else result
+
+
+async def refused(action, status):
+ try:
+ await action()
+ except APIStatusError as error:
+ assert error.status_code == status
+ else:
+ raise AssertionError(f"Expected HTTP {status}")
+
+
+def safe_error(error):
+ if isinstance(error, APIStatusError):
+ return f"HTTP {error.status_code} code={error.code} request_id={error.request_id}"
+ # Never include storage URLs or token responses in failures.
+ return type(error).__name__
+
+
+@pytest.mark.parametrize("region", ["cn", "global"])
+@pytest.mark.parametrize("auth", ["pat", "admin_sat", "identity_sat"])
+@pytest.mark.parametrize("async_mode", [False, True], ids=["sync", "async"])
+async def test_drive_lifecycle(region, auth, async_mode):
+ values = {**read_env(os.environ.get("QODER_LIVE_ENV_FILE", ".env.live")), **os.environ}
+ if values.get("QODER_DRIVE_RUN_LIVE") != "1":
+ pytest.skip("Set QODER_DRIVE_RUN_LIVE=1 to run Drive tests")
+ assert values.get("QODER_DRIVE_LIVE_ALLOW_WRITE") == "true"
+ host = "https://api.qoder.com.cn" if region == "cn" else "https://api.qoder.com"
+ Client = AsyncForward if async_mode else Forward
+ options = dict(base_url=f"{host}/api/v1/forward/", max_retries=0, timeout=30, _strict_response_validation=True)
+ cleanups = []
+ stage = "setup"
+ async with AsyncExitStack() as stack:
+
+ async def client(**authentication):
+ instance = Client(**options, **authentication)
+ return await stack.enter_async_context(instance) if async_mode else stack.enter_context(instance)
+
+ try:
+ issuer = await client(auth_mode="sa")
+ key = values.get(f"QODER_SA_{region.upper()}_KEY", "")
+
+ async def issue(identity_id=None):
+ assert key, f"QODER_SA_{region.upper()}_KEY is required"
+ params = dict(sa_key=key, ttl_seconds=600)
+ token = (
+ await call(issuer.identities.service_account_tokens.create, identity_id, **params)
+ if identity_id
+ else await call(issuer.service_account_tokens.create, **params)
+ )
+ cleanups.append(
+ lambda: call(
+ issuer.service_account_tokens.revoke,
+ token.auth_token_id,
+ sa_key=key,
+ target_credential_id=token.credential_id,
+ target_expires_at=token.expires_at,
+ )
+ )
+ return await client(auth_mode="sa", access_token=token.access_token)
+
+ if auth == "pat":
+ pat = values.get(f"QODER_FORWARD_{region.upper()}_PAT")
+ assert pat, f"QODER_FORWARD_{region.upper()}_PAT is required"
+ admin = await client(auth_mode="pat", pat=pat)
+ else:
+ admin = await issue()
+ identity = await call(admin.identities.create, external_id=f"sdk-drive-{uuid4().hex}")
+ cleanups.append(lambda: call(admin.identities.delete, identity.id))
+ # This ID was created above and is never supplied by the operator.
+ cleanups.append(lambda: call(admin.drives.clear, identity_id=identity.id))
+ runtime = await issue(identity.id) if auth == "identity_sat" else admin
+ args = dict(identity_id=identity.id)
+ print(f"Drive {region}/{auth}/{'async' if async_mode else 'sync'} identity_id={identity.id}", flush=True)
+ assert (await call(runtime.drives.list_entries, **args)).entries == []
+
+ stage = "upload"
+ async with httpx.AsyncClient(timeout=30, follow_redirects=False) as storage:
+ paths = ["sdk 目录/a+b.txt", "sdk 目录/zero.txt", "sdk 目录/nested/child.txt"]
+ content = b"SDK Drive verification"
+ for path in paths:
+ signed = await call(runtime.drives.create_upload_url, **args, path=path, content_type="text/plain")
+ assert signed.method == "PUT"
+ assert not any(key.lower() == "authorization" for key in signed.headers)
+ response = await storage.request(
+ signed.method, signed.url, headers=signed.headers, content=b"" if "zero" in path else content
+ )
+ assert 200 <= response.status_code < 300, f"Storage PUT HTTP {response.status_code}"
+ stage = "pagination"
+ entries, seen = [], set()
+ cursor = None
+ while True:
+ page = await call(
+ runtime.drives.list_entries,
+ **args,
+ path="sdk 目录",
+ limit=1,
+ **({"page_token": cursor} if cursor else {}),
+ )
+ entries.extend(page.entries)
+ cursor = page.next_page_token
+ if not cursor:
+ break
+ assert cursor not in seen, "Repeated cursor"
+ seen.add(cursor)
+ assert len(seen) < 10, "Pagination did not finish"
+ assert {entry.path for entry in entries} == {paths[0], paths[1], "sdk 目录/nested"}
+ assert next(entry for entry in entries if entry.path == paths[1]).size == 0
+ assert next(entry for entry in entries if entry.type == "directory").size is None
+ stage = "download"
+ signed = await call(runtime.drives.create_download_url, **args, path=paths[0])
+ assert signed.method == "GET"
+ assert not any(key.lower() == "authorization" for key in signed.headers)
+ response = await storage.request(signed.method, signed.url, headers=signed.headers)
+ assert response.status_code == 200 and response.content == content, "Storage download differs"
+ stage = "deletion"
+ await refused(lambda: call(runtime.drives.delete_entry, **args, path="sdk 目录"), 409)
+ assert (await call(runtime.drives.delete_entry, **args, path=paths[0])).deleted_count == 1
+ assert (await call(runtime.drives.delete_entry, **args, path=paths[0])).deleted_count == 0
+ assert (
+ await call(runtime.drives.delete_entry, **args, path="sdk 目录/nested", recursive=True)
+ ).deleted_count == 1
+ assert (await call(runtime.drives.clear, **args)).deleted_count == 1
+ assert (await call(runtime.drives.clear, **args)).deleted_count == 0
+ if auth == "identity_sat":
+ stage = "Identity isolation"
+ other = await call(admin.identities.create, external_id=f"sdk-drive-other-{uuid4().hex}")
+ cleanups.append(lambda: call(admin.identities.delete, other.id))
+ await refused(lambda: call(runtime.drives.list_entries, identity_id=other.id), 403)
+ else:
+ stage = "Template managed tools"
+ env = await call(
+ admin.environments.create, name=f"sdk-drive-{uuid4().hex[:12]}", config={"type": "cloud"}
+ )
+ cleanups.append(lambda: call(admin.environments.archive, env.id))
+ template = await call(
+ admin.templates.create,
+ name=f"sdk-drive-{uuid4().hex[:12]}",
+ model="auto",
+ environment_id=env.id,
+ managed_tool_config={"enabled_tools": ["drive"]},
+ )
+ cleanups.append(lambda: call(admin.templates.archive, template.id))
+ current = await call(admin.templates.retrieve, template.id)
+ assert current.managed_tool_config.enabled_tools == ["drive"]
+ current = await call(admin.templates.update, template.id, managed_tool_config={"enabled_tools": []})
+ assert current.managed_tool_config is None or current.managed_tool_config.enabled_tools == []
+ except Exception as error:
+ # Suppress exception chains that can contain signed URLs / credentials.
+ pytest.fail(f"{stage}: {safe_error(error)}", pytrace=False)
+ finally:
+ errors = []
+ for action in reversed(cleanups):
+ try:
+ await action()
+ except Exception as error:
+ errors.append(safe_error(error))
+ assert not errors, f"Drive cleanup failed: {errors}"
diff --git a/tests/test_drive.py b/tests/test_drive.py
new file mode 100644
index 0000000..118ef21
--- /dev/null
+++ b/tests/test_drive.py
@@ -0,0 +1,201 @@
+"""Drive wire behavior from the public API contract, for both client modes."""
+
+import inspect
+import json
+
+import httpx
+import pytest
+
+from qca import AsyncForward, Forward, InternalServerError
+
+
+async def resolve(value):
+ return await value if inspect.isawaitable(value) else value
+
+
+@pytest.mark.parametrize("async_", [False, True])
+async def test_drive_paths_pages_and_typed_metadata(async_):
+ requests = []
+ cursor = "opaque/+%=&"
+
+ def handler(request):
+ requests.append(request)
+ assert request.headers["authorization"] == "Bearer test-sat"
+ assert request.url.params["identity_id"] == "idn_test"
+ assert request.url.params["path"] == "目录/a b+~*"
+ assert b"path=%E7%9B%AE%E5%BD%95/a+b%2B~%2A" in request.url.query
+ assert b"extra=a%2Fb" in request.url.query
+ if len(requests) == 1:
+ return httpx.Response(
+ 200,
+ json={
+ "entries": [
+ {"path": "a.txt", "name": "a.txt", "type": "file", "size": 0},
+ {"path": "a", "name": "a", "type": "directory"},
+ ],
+ "next_page_token": cursor,
+ },
+ )
+ assert request.url.params["page_token"] == cursor
+ return httpx.Response(200, json={"entries": []})
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handler))
+ client = (AsyncForward if async_ else Forward)(
+ auth_mode="sa",
+ access_token="test-sat",
+ http_client=http,
+ default_query={"extra": "a/b", "path": "replaced"},
+ _strict_response_validation=True,
+ )
+ try:
+ first = await resolve(
+ client.drives.list_entries(
+ identity_id="idn_test", path="replaced-again", extra_query={"path": "目录/a b+~*"}
+ )
+ )
+ assert [entry.path for entry in first.entries] == ["a.txt", "a"]
+ assert first.entries[0].size == 0
+ assert first.entries[1].size is None
+ last = await resolve(
+ client.drives.list_entries(identity_id="idn_test", path="目录/a b+~*", page_token=first.next_page_token)
+ )
+ assert last.entries == [] and last.next_page_token is None
+ finally:
+ await resolve(client.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+async def test_drive_mutation_bodies_signed_headers_and_partial_error(async_):
+ requests = []
+
+ def handler(request):
+ requests.append(request)
+ assert request.url.params.multi_items() == [("identity_id", "idn_test")]
+ route = request.url.path.rsplit("/", 1)[-1]
+ if route.endswith("-url"):
+ assert request.method == "POST"
+ assert request.headers["Idempotency-Key"] == "retry-one"
+ assert json.loads(request.content) == (
+ {"path": "reports/readme.txt", "content_type": "text/plain"}
+ if route == "upload-url"
+ else {"path": "reports/readme.txt"}
+ )
+ return httpx.Response(
+ 200,
+ json={
+ "url": "https://storage.test/signed",
+ "method": "PUT" if route == "upload-url" else "GET",
+ "headers": {"Content-Type": "text/plain"},
+ "expires_at": "2026-10-14T02:15:00Z",
+ },
+ )
+ assert request.method == "DELETE"
+ if route == "entries":
+ assert json.loads(request.content) == {"path": "reports", "recursive": False}
+ return httpx.Response(200, json={"deleted_count": 0})
+ assert request.content == b""
+ return httpx.Response(
+ 503,
+ json={
+ "request_id": "req_partial",
+ "error": {"code": "drive_delete_partial", "deleted_count": 7, "message": "partial"},
+ },
+ )
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handler))
+ client = (AsyncForward if async_ else Forward)(pat="test-pat", http_client=http, max_retries=2)
+ try:
+ upload = await resolve(
+ client.drives.create_upload_url(
+ identity_id="idn_test",
+ path="reports/readme.txt",
+ content_type="text/plain",
+ idempotency_key="retry-one",
+ )
+ )
+ assert upload.headers == {"Content-Type": "text/plain"} and upload.method == "PUT"
+ download = await resolve(
+ client.drives.create_download_url(
+ identity_id="idn_test", path="reports/readme.txt", idempotency_key="retry-one"
+ )
+ )
+ assert download.method == "GET" and download.size is None
+ deleted = await resolve(client.drives.delete_entry(identity_id="idn_test", path="reports", recursive=False))
+ assert deleted.deleted_count == 0
+ with pytest.raises(InternalServerError) as raised:
+ await resolve(client.drives.clear(identity_id="idn_test"))
+ assert raised.value.code == "drive_delete_partial"
+ assert raised.value.request_id == "req_partial"
+ assert raised.value.body["error"]["deleted_count"] == 7
+ assert len(requests) == 4
+ finally:
+ await resolve(client.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+async def test_template_managed_tools_preserves_omission_null_and_empty(async_):
+ bodies = []
+
+ def handler(request):
+ bodies.append(json.loads(request.content))
+ return httpx.Response(200, json={"managed_tool_config": {"enabled_tools": ["drive", "schedule"]}})
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handler))
+ client = (AsyncForward if async_ else Forward)(pat="test-pat", http_client=http)
+ try:
+ await resolve(client.templates.update("tmpl_test"))
+ await resolve(client.templates.update("tmpl_test", managed_tool_config=None))
+ await resolve(client.templates.update("tmpl_test", managed_tool_config={"enabled_tools": []}))
+ result = await resolve(
+ client.templates.create(
+ name="drive",
+ model="auto",
+ environment_id="env_test",
+ managed_tool_config={"enabled_tools": ["drive", "schedule"]},
+ )
+ )
+ assert bodies[:3] == [{}, {"managed_tool_config": None}, {"managed_tool_config": {"enabled_tools": []}}]
+ assert bodies[3]["managed_tool_config"] == {"enabled_tools": ["drive", "schedule"]}
+ assert result.managed_tool_config.enabled_tools == ["drive", "schedule"]
+ finally:
+ await resolve(client.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+async def test_drive_does_not_normalize_paths_or_clamp_limits(async_):
+ from qca import BadRequestError
+
+ expected = {}
+
+ def handler(request):
+ assert request.url.params["path"] == expected["path"]
+ assert request.url.params["limit"] == str(expected["limit"])
+ assert request.url.params.get_list("identity_id") == ["idn_test"]
+ assert request.content == b""
+ if expected["path"] == "" and expected["limit"] in (1, 100):
+ return httpx.Response(200, json={"entries": []})
+ return httpx.Response(400, json={"error": {"code": "invalid_argument", "message": "invalid Drive query"}})
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handler))
+ client = (AsyncForward if async_ else Forward)(pat="test-pat", http_client=http)
+ try:
+ for limit in (1, 100):
+ expected.update(path="", limit=limit)
+ assert (
+ await resolve(client.drives.list_entries(identity_id="idn_test", path="", limit=limit))
+ ).entries == []
+ for path, limit in [
+ ("/absolute", 20),
+ ("../other", 20),
+ ("a\\b", 20),
+ ("a%2Fb", 20),
+ (" a ", 20),
+ ("", 0),
+ ("", 101),
+ ]:
+ expected.update(path=path, limit=limit)
+ with pytest.raises(BadRequestError) as raised:
+ await resolve(client.drives.list_entries(identity_id="idn_test", path=path, limit=limit))
+ assert raised.value.code == "invalid_argument"
+ finally:
+ await resolve(client.close())
diff --git a/tests/test_resource_surface.py b/tests/test_resource_surface.py
index 163bd8d..5fd3568 100644
--- a/tests/test_resource_surface.py
+++ b/tests/test_resource_surface.py
@@ -39,7 +39,7 @@ def test_contract_fixture_has_pinned_provenance():
"commit": "bc5bc655d70b67fcd88623320ac01896a7360d8a",
"captured_at": "2026-09-22",
}
- assert len(CONTRACT_FIXTURE["contracts"]) == 216
+ assert len(CONTRACT_FIXTURE["contracts"]) == 221
@pytest.mark.parametrize("endpoint", ENDPOINTS, ids=endpoint_id)