diff --git a/.env.live.example b/.env.live.example index 2de5712..48a09d1 100644 --- a/.env.live.example +++ b/.env.live.example @@ -56,3 +56,8 @@ QODER_FORWARD_GLOBAL_PAT= # Optional PCM/work scenarios (not run by basic connection checks). QODER_REALTIME_PCM_FILE= QODER_REALTIME_WORK_PROMPT= + +# 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 4784cc9..d371b7c 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/README.md b/README.md index 58f982f..f64a72b 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,56 @@ for await (const event of stream) { The resumable stream does not query event history, clear an invalid cursor, or deduplicate IDs: delta frames may legitimately share an event ID. It stops after `[DONE]`, `session.status_terminated`, or `session.deleted`. Stop it early with `stream.controller.abort()`, an `AbortSignal` in `RequestOptions`, or `await stream.close()`. +## 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. + +```ts +const identityID = 'idn_...'; +const templateID = 'tmpl_...'; +let page = await client.drives.listEntries({ identity_id: identityID, path: 'reports', limit: 20 }); +for (const entry of page.entries) console.log(entry.path); +if (page.next_page_token) { + page = await client.drives.listEntries({ + identity_id: identityID, path: 'reports', page_token: page.next_page_token, + }); +} +// Replace the complete managed-tool list, retaining any other tools you need. +await client.templates.update(templateID, { managed_tool_config: { enabled_tools: ['drive'] } }); +``` + +Run the [file lifecycle example](examples/forward/drive.mjs) with +`node examples/forward/drive.mjs`. It uses the shared PAT/SA configuration and +creates a fresh Identity. A directory has no `size`; a zero-byte file has `size=0`. +For maintainers, `npm run test:live:drive` tests CN/Global × PAT/admin SAT/Identity SAT. + +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 File parameters accept a `File`, a `Blob`, a byte array, a `Response`, a `ReadableStream` or an async iterable — including a Node file stream. `toFile` wraps any of those with an explicit filename. diff --git a/docs/api/forward/README.md b/docs/api/forward/README.md index 5b6ed4d..d81a5a7 100644 --- a/docs/api/forward/README.md +++ b/docs/api/forward/README.md @@ -13,6 +13,7 @@ - [ChannelPairings](classes/ChannelPairings.md) - [ChannelQRSessions](classes/ChannelQRSessions.md) - [Channels](classes/Channels.md) +- [Drives](classes/Drives.md) - [Environments](classes/Environments.md) - [Files](classes/Files.md) - [Identities](classes/Identities.md) @@ -72,6 +73,17 @@ - [DeletedMemoryStore](interfaces/DeletedMemoryStore.md) - [DeletedMemoryStoreMount](interfaces/DeletedMemoryStoreMount.md) - [DeletedSkillVersion](interfaces/DeletedSkillVersion.md) +- [DriveClearParams](interfaces/DriveClearParams.md) +- [DriveClearResponse](interfaces/DriveClearResponse.md) +- [DriveCreateDownloadURLParams](interfaces/DriveCreateDownloadURLParams.md) +- [DriveCreateUploadURLParams](interfaces/DriveCreateUploadURLParams.md) +- [DriveDeleteEntryParams](interfaces/DriveDeleteEntryParams.md) +- [DriveDeleteEntryResponse](interfaces/DriveDeleteEntryResponse.md) +- [DriveDownloadURL](interfaces/DriveDownloadURL.md) +- [DriveEntry](interfaces/DriveEntry.md) +- [DriveListEntriesParams](interfaces/DriveListEntriesParams.md) +- [DriveListEntriesResponse](interfaces/DriveListEntriesResponse.md) +- [DriveUploadURL](interfaces/DriveUploadURL.md) - [EffectiveConfig](interfaces/EffectiveConfig.md) - [EffectiveConfigAgent](interfaces/EffectiveConfigAgent.md) - [EffectiveConfigAgentToolsItem](interfaces/EffectiveConfigAgentToolsItem.md) @@ -111,6 +123,7 @@ - [IdentityUsage](interfaces/IdentityUsage.md) - [ImageSource](interfaces/ImageSource.md) - [ImageSourceParam](interfaces/ImageSourceParam.md) +- [ManagedToolConfig](interfaces/ManagedToolConfig.md) - [MCPServer](interfaces/MCPServer.md) - [MCPServerOverride](interfaces/MCPServerOverride.md) - [MCPServerOverrideParam](interfaces/MCPServerOverrideParam.md) diff --git a/docs/api/forward/classes/Drives.md b/docs/api/forward/classes/Drives.md new file mode 100644 index 0000000..8028883 --- /dev/null +++ b/docs/api/forward/classes/Drives.md @@ -0,0 +1,155 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / Drives + +# Class: Drives + +Persistent Identity files (Beta). Identity SATs also require identity_id. + +## Extends + +- `APIResource` + +## Constructors + +### Constructor + +> **new Drives**(`_client`): `Drives` + +#### Parameters + +##### \_client + +[`APIClient`](../../index/classes/APIClient.md) + +#### Returns + +`Drives` + +#### Inherited from + +`APIResource.constructor` + +## Properties + +### \_client + +> `protected` `readonly` **\_client**: [`APIClient`](../../index/classes/APIClient.md) + +#### Inherited from + +`APIResource._client` + +## Methods + +### clear() + +> **clear**(`params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveClearResponse`](../interfaces/DriveClearResponse.md)\> + +Clear the selected Identity Drive. All its files are deleted. + +#### Parameters + +##### params + +[`DriveClearParams`](../interfaces/DriveClearParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveClearResponse`](../interfaces/DriveClearResponse.md)\> + +*** + +### createDownloadUrl() + +> **createDownloadUrl**(`params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveDownloadURL`](../interfaces/DriveDownloadURL.md)\> + +Request a GET URL. Use a new Idempotency-Key or omit it to refresh expiry. + +#### Parameters + +##### params + +[`DriveCreateDownloadURLParams`](../interfaces/DriveCreateDownloadURLParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveDownloadURL`](../interfaces/DriveDownloadURL.md)\> + +*** + +### createUploadUrl() + +> **createUploadUrl**(`params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveUploadURL`](../interfaces/DriveUploadURL.md)\> + +Request a PUT URL. Only its returned headers belong on the storage request. +Replaying an Idempotency-Key does not renew the URL's expiry. + +#### Parameters + +##### params + +[`DriveCreateUploadURLParams`](../interfaces/DriveCreateUploadURLParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveUploadURL`](../interfaces/DriveUploadURL.md)\> + +*** + +### deleteEntry() + +> **deleteEntry**(`params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveDeleteEntryResponse`](../interfaces/DriveDeleteEntryResponse.md)\> + +Delete a file or directory. Nonempty directories require recursive: true. +Partial failures preserve error.deleted_count in the API error body. + +#### Parameters + +##### params + +[`DriveDeleteEntryParams`](../interfaces/DriveDeleteEntryParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveDeleteEntryResponse`](../interfaces/DriveDeleteEntryResponse.md)\> + +*** + +### listEntries() + +> **listEntries**(`params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveListEntriesResponse`](../interfaces/DriveListEntriesResponse.md)\> + +One page in service order. Pass next_page_token unchanged to page_token. + +#### Parameters + +##### params + +[`DriveListEntriesParams`](../interfaces/DriveListEntriesParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`DriveListEntriesResponse`](../interfaces/DriveListEntriesResponse.md)\> diff --git a/docs/api/forward/interfaces/DriveClearParams.md b/docs/api/forward/interfaces/DriveClearParams.md new file mode 100644 index 0000000..70c361e --- /dev/null +++ b/docs/api/forward/interfaces/DriveClearParams.md @@ -0,0 +1,13 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveClearParams + +# Interface: DriveClearParams + +## Properties + +### identity\_id + +> **identity\_id**: `string` diff --git a/docs/api/forward/interfaces/DriveClearResponse.md b/docs/api/forward/interfaces/DriveClearResponse.md new file mode 100644 index 0000000..ed7b71f --- /dev/null +++ b/docs/api/forward/interfaces/DriveClearResponse.md @@ -0,0 +1,17 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveClearResponse + +# Interface: DriveClearResponse + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### deleted\_count + +> **deleted\_count**: `number` diff --git a/docs/api/forward/interfaces/DriveCreateDownloadURLParams.md b/docs/api/forward/interfaces/DriveCreateDownloadURLParams.md new file mode 100644 index 0000000..578e6c4 --- /dev/null +++ b/docs/api/forward/interfaces/DriveCreateDownloadURLParams.md @@ -0,0 +1,19 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveCreateDownloadURLParams + +# Interface: DriveCreateDownloadURLParams + +## Properties + +### identity\_id + +> **identity\_id**: `string` + +*** + +### path + +> **path**: `string` diff --git a/docs/api/forward/interfaces/DriveCreateUploadURLParams.md b/docs/api/forward/interfaces/DriveCreateUploadURLParams.md new file mode 100644 index 0000000..a7c0bfc --- /dev/null +++ b/docs/api/forward/interfaces/DriveCreateUploadURLParams.md @@ -0,0 +1,27 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveCreateUploadURLParams + +# Interface: DriveCreateUploadURLParams + +## Properties + +### content\_type? + +> `optional` **content\_type?**: `string` + +*** + +### identity\_id + +> **identity\_id**: `string` + +Query parameter, not part of the JSON body. + +*** + +### path + +> **path**: `string` diff --git a/docs/api/forward/interfaces/DriveDeleteEntryParams.md b/docs/api/forward/interfaces/DriveDeleteEntryParams.md new file mode 100644 index 0000000..3b5dda8 --- /dev/null +++ b/docs/api/forward/interfaces/DriveDeleteEntryParams.md @@ -0,0 +1,25 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveDeleteEntryParams + +# Interface: DriveDeleteEntryParams + +## Properties + +### identity\_id + +> **identity\_id**: `string` + +*** + +### path + +> **path**: `string` + +*** + +### recursive? + +> `optional` **recursive?**: `boolean` diff --git a/docs/api/forward/interfaces/DriveDeleteEntryResponse.md b/docs/api/forward/interfaces/DriveDeleteEntryResponse.md new file mode 100644 index 0000000..f28008c --- /dev/null +++ b/docs/api/forward/interfaces/DriveDeleteEntryResponse.md @@ -0,0 +1,17 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveDeleteEntryResponse + +# Interface: DriveDeleteEntryResponse + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### deleted\_count + +> **deleted\_count**: `number` diff --git a/docs/api/forward/interfaces/DriveDownloadURL.md b/docs/api/forward/interfaces/DriveDownloadURL.md new file mode 100644 index 0000000..08a31e1 --- /dev/null +++ b/docs/api/forward/interfaces/DriveDownloadURL.md @@ -0,0 +1,55 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveDownloadURL + +# Interface: DriveDownloadURL + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### content\_type? + +> `optional` **content\_type?**: `string` + +*** + +### etag? + +> `optional` **etag?**: `string` + +*** + +### expires\_at + +> **expires\_at**: `string` + +*** + +### headers + +> **headers**: `Record`\<`string`, `string`\> + +*** + +### method + +> **method**: `"GET"` + +*** + +### size? + +> `optional` **size?**: `number` + +May be omitted for a zero-byte file. + +*** + +### url + +> **url**: `string` diff --git a/docs/api/forward/interfaces/DriveEntry.md b/docs/api/forward/interfaces/DriveEntry.md new file mode 100644 index 0000000..328c98c --- /dev/null +++ b/docs/api/forward/interfaces/DriveEntry.md @@ -0,0 +1,51 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveEntry + +# Interface: DriveEntry + +One file or virtual directory in an Identity Drive. + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### etag? + +> `optional` **etag?**: `string` + +*** + +### last\_modified? + +> `optional` **last\_modified?**: `string` + +*** + +### name + +> **name**: `string` + +*** + +### path + +> **path**: `string` + +*** + +### size? + +> `optional` **size?**: `number` + +Zero for an empty file; omitted for directories. + +*** + +### type + +> **type**: `"file"` \| `"directory"` diff --git a/docs/api/forward/interfaces/DriveListEntriesParams.md b/docs/api/forward/interfaces/DriveListEntriesParams.md new file mode 100644 index 0000000..040a34b --- /dev/null +++ b/docs/api/forward/interfaces/DriveListEntriesParams.md @@ -0,0 +1,37 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveListEntriesParams + +# Interface: DriveListEntriesParams + +## Properties + +### identity\_id + +> **identity\_id**: `string` + +Query parameter; required even with an Identity SAT. + +*** + +### limit? + +> `optional` **limit?**: `number` + +Page size, 1–100 (default 20). + +*** + +### page\_token? + +> `optional` **page\_token?**: `string` + +*** + +### path? + +> `optional` **path?**: `string` + +Relative directory; omit or use an empty string for root. diff --git a/docs/api/forward/interfaces/DriveListEntriesResponse.md b/docs/api/forward/interfaces/DriveListEntriesResponse.md new file mode 100644 index 0000000..4fbb6af --- /dev/null +++ b/docs/api/forward/interfaces/DriveListEntriesResponse.md @@ -0,0 +1,27 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveListEntriesResponse + +# Interface: DriveListEntriesResponse + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### entries + +> **entries**: [`DriveEntry`](DriveEntry.md)[] + +Immediate children, in the order returned by the service. + +*** + +### next\_page\_token? + +> `optional` **next\_page\_token?**: `string` + +Omitted on the last page. Do not alter or reuse in another scope/path. diff --git a/docs/api/forward/interfaces/DriveUploadURL.md b/docs/api/forward/interfaces/DriveUploadURL.md new file mode 100644 index 0000000..b52488a --- /dev/null +++ b/docs/api/forward/interfaces/DriveUploadURL.md @@ -0,0 +1,37 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / DriveUploadURL + +# Interface: DriveUploadURL + +A signed storage URL, valid for 15 minutes. Treat the URL as a credential. + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### expires\_at + +> **expires\_at**: `string` + +*** + +### headers + +> **headers**: `Record`\<`string`, `string`\> + +*** + +### method + +> **method**: `"PUT"` + +*** + +### url + +> **url**: `string` diff --git a/docs/api/forward/interfaces/ManagedToolConfig.md b/docs/api/forward/interfaces/ManagedToolConfig.md new file mode 100644 index 0000000..abf1e89 --- /dev/null +++ b/docs/api/forward/interfaces/ManagedToolConfig.md @@ -0,0 +1,21 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / ManagedToolConfig + +# Interface: ManagedToolConfig + +Forward-managed tools. This is distinct from tools[].enabled_tools. + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### enabled\_tools + +> **enabled\_tools**: `string`[] + +The complete enabled list; [] disables all managed tools. diff --git a/docs/api/forward/interfaces/Template.md b/docs/api/forward/interfaces/Template.md index 62522c4..b1ca1c2 100644 --- a/docs/api/forward/interfaces/Template.md +++ b/docs/api/forward/interfaces/Template.md @@ -60,6 +60,14 @@ Template ID。 *** +### managed\_tool\_config? + +> `optional` **managed\_tool\_config?**: [`ManagedToolConfig`](ManagedToolConfig.md) \| `null` + +Replaces the complete managed tool list. Null or an empty list disables it. + +*** + ### mcp\_servers > **mcp\_servers**: [`MCPServer`](MCPServer.md)[] diff --git a/docs/api/forward/interfaces/TemplateNewParams.md b/docs/api/forward/interfaces/TemplateNewParams.md index ba0e86c..875197c 100644 --- a/docs/api/forward/interfaces/TemplateNewParams.md +++ b/docs/api/forward/interfaces/TemplateNewParams.md @@ -62,6 +62,14 @@ Template 描述,最多 2048 个字符。 *** +### managed\_tool\_config? + +> `optional` **managed\_tool\_config?**: [`ManagedToolConfig`](ManagedToolConfig.md) \| `null` + +Replaces the complete managed tool list. Null or an empty list disables it. + +*** + ### mcp\_servers? > `optional` **mcp\_servers?**: [`MCPServerParam`](MCPServerParam.md)[] \| `null` diff --git a/docs/api/forward/interfaces/TemplateUpdateParams.md b/docs/api/forward/interfaces/TemplateUpdateParams.md index 8abf9f9..4b4aaeb 100644 --- a/docs/api/forward/interfaces/TemplateUpdateParams.md +++ b/docs/api/forward/interfaces/TemplateUpdateParams.md @@ -62,6 +62,14 @@ Additional fields are preserved verbatim by the SDK. *** +### managed\_tool\_config? + +> `optional` **managed\_tool\_config?**: [`ManagedToolConfig`](ManagedToolConfig.md) \| `null` + +Replaces the complete managed tool list. Null or an empty list disables it. + +*** + ### mcp\_servers? > `optional` **mcp\_servers?**: [`MCPServerParam`](MCPServerParam.md)[] \| `null` diff --git a/docs/api/index/classes/ForwardClient.md b/docs/api/index/classes/ForwardClient.md index 8651a25..c676041 100644 --- a/docs/api/index/classes/ForwardClient.md +++ b/docs/api/index/classes/ForwardClient.md @@ -80,6 +80,12 @@ *** +### drives + +> `readonly` **drives**: [`Drives`](../../forward/classes/Drives.md) + +*** + ### environments > `readonly` **environments**: [`Environments`](../../forward/classes/Environments.md) diff --git a/docs/api/index/interfaces/APIRequestOptions.md b/docs/api/index/interfaces/APIRequestOptions.md index a11d4c9..27394fb 100644 --- a/docs/api/index/interfaces/APIRequestOptions.md +++ b/docs/api/index/interfaces/APIRequestOptions.md @@ -74,6 +74,26 @@ *** +### querySerializer? + +> `optional` **querySerializer?**: (`query`) => `string` + +**`Internal`** + +Endpoint-specific query encoding, after defaults and overrides are merged. + +#### Parameters + +##### query + +`object` + +#### Returns + +`string` + +*** + ### responseType? > `optional` **responseType?**: `"response"` \| `"stream"` \| `"json"` \| `"binary"` \| `"void"` diff --git a/examples/forward/drive.mjs b/examples/forward/drive.mjs new file mode 100644 index 0000000..c01e4db --- /dev/null +++ b/examples/forward/drive.mjs @@ -0,0 +1,41 @@ +// Drive Beta. Uses shared PAT/SA configuration and a new isolated Identity. +// Run: node examples/forward/drive.mjs +import { isMain, runForwardExample } from '../lib/forward.mjs'; + +async function transfer(signed, body) { + // Independent storage request: no QCA client/default headers or Authorization. + let response; + try { + response = await fetch(signed.url, { method: signed.method, headers: signed.headers, body, + redirect: 'error', signal: AbortSignal.timeout(30_000) }); + } catch { throw new Error('Storage transfer failed'); } + if (!response.ok) throw new Error(`Storage HTTP ${response.status}`); + return response; +} + +async function main() { + await runForwardExample(async s => { + const identity = await s.identity(); + const query = { identity_id: identity.id }; + const path = 'sdk-example/说明.txt'; + s.track('Drive example directory', () => s.client.drives.deleteEntry({ ...query, path: 'sdk-example', recursive: true })); + s.step('申请签名 URL,向存储服务上传文件'); + const upload = await s.client.drives.createUploadUrl({ ...query, path, content_type: 'text/plain' }); + const uploaded = await transfer(upload, 'Hello from QCA Drive'); + await uploaded.arrayBuffer(); + s.step('按服务端顺序列出目录,使用独立的 Drive 游标翻页'); + let pageToken; + do { + const page = await s.client.drives.listEntries({ ...query, path: 'sdk-example', limit: 20, page_token: pageToken }); + for (const entry of page.entries) s.info(`${entry.type}: ${entry.path} (${entry.size ?? 'directory'} bytes)`); + pageToken = page.next_page_token; + } while (pageToken); + s.step('下载文件并删除示例条目'); + const download = await s.client.drives.createDownloadUrl({ ...query, path }); + s.info(await (await transfer(download)).text()); + const result = await s.client.drives.deleteEntry({ ...query, path }); + s.info(`deleted_count=${result.deleted_count}`); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/package.json b/package.json index 1ad698e..d26a915 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "test:live:sa:execution": "npm run build && node scripts/run-sa-live.mjs execution", "test:live:sa:check": "node --check tests/sa/auth-live.mjs && node --check tests/sa/execution-live.mjs && node --check tests/realtime/live.mjs && node --check scripts/run-realtime-live.mjs", "test:live:realtime": "npm run build && node scripts/run-realtime-live.mjs", - "test:live:sa:tokens": "npm run build && node scripts/run-sa-live.mjs tokens" + "test:live:sa:tokens": "npm run build && node scripts/run-sa-live.mjs tokens", + "test:live:drive": "npm run build && node scripts/run-drive-live.mjs" }, "devDependencies": { "@types/ws": "^8.18.2", diff --git a/scripts/run-drive-live.mjs b/scripts/run-drive-live.mjs new file mode 100644 index 0000000..8bc9616 --- /dev/null +++ b/scripts/run-drive-live.mjs @@ -0,0 +1,16 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +try { + const file = process.env.LIVE_ENV_FILE ?? '.env.live'; + if (existsSync(file)) process.loadEnvFile(file); + if (process.env.QODER_DRIVE_LIVE_ALLOW_WRITE !== 'true') throw new Error('QODER_DRIVE_LIVE_ALLOW_WRITE=true is required'); + for (const key of ['QODER_FORWARD_CN_PAT', 'QODER_FORWARD_GLOBAL_PAT', 'QODER_SA_CN_KEY', 'QODER_SA_GLOBAL_KEY']) { + if (!process.env[key]?.trim()) throw new Error(`${key} is required`); + } + const run = spawnSync(process.execPath, ['--test', 'tests/live/drive-live.mjs'], { stdio: 'inherit' }); + process.exitCode = run.status ?? 1; +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/src/core/client.ts b/src/core/client.ts index bc78807..a2d4326 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -36,6 +36,8 @@ export interface APIRequestOptions extends RequestOptions { method: string; path: string; responseType?: 'json' | 'stream' | 'binary' | 'response' | 'void'; + /** @internal Endpoint-specific query encoding, after defaults and overrides are merged. */ + querySerializer?: (query: object) => string; } export function mergeHeaders(...sources: (HeadersLike | undefined)[]): Headers { @@ -249,7 +251,9 @@ export class APIClient { if (callerSignal.aborted) throw abortError(callerSignal, false); const url = storage ? new URL(options.path) : new URL(`${this.baseURL}/${options.path.replace(/^\/+/, '')}`); if (!storage) { - const query = stringifyQuery({ ...this.options.defaultQuery, ...options.query }, this.mode === 'managed' ? 'brackets' : 'repeat'); + const values = { ...this.options.defaultQuery, ...options.query }; + const query = options.querySerializer ? options.querySerializer(values) + : stringifyQuery(values, this.mode === 'managed' ? 'brackets' : 'repeat'); if (query) url.search += `${url.search ? '&' : ''}${query}`; } const method = options.method.toUpperCase(); diff --git a/src/forward/api-inventory.json b/src/forward/api-inventory.json index 92a3ac5..b04a304 100644 --- a/src/forward/api-inventory.json +++ b/src/forward/api-inventory.json @@ -3984,6 +3984,13 @@ "location": "header", "required": false, "type": "string | null" + }, + { + "name": "managed_tool_config", + "wireName": "managed_tool_config", + "location": "body", + "required": false, + "type": "ManagedToolConfig | null" } ] }, @@ -4141,6 +4148,13 @@ "location": "header", "required": false, "type": "string | null" + }, + { + "name": "managed_tool_config", + "wireName": "managed_tool_config", + "location": "body", + "required": false, + "type": "ManagedToolConfig | null" } ] }, @@ -4755,5 +4769,171 @@ "responseType": "history", "itemType": "RealtimeHistoryPage", "path": "/realtime/conversations/{conversation_id}/history" + }, + { + "operationId": "listDriveEntries", + "entry": "drives", + "method": "listEntries", + "httpMethod": "GET", + "path": "/drives/entries", + "parameters": [], + "paramsType": "DriveListEntriesParams", + "paramsOptional": false, + "paramsLocation": "query", + "responseType": "json", + "itemType": "DriveListEntriesResponse", + "parameterFields": [ + { + "name": "identity_id", + "wireName": "identity_id", + "location": "query", + "required": true, + "type": "string" + }, + { + "name": "path", + "wireName": "path", + "location": "query", + "required": false, + "type": "string" + }, + { + "name": "limit", + "wireName": "limit", + "location": "query", + "required": false, + "type": "number" + }, + { + "name": "page_token", + "wireName": "page_token", + "location": "query", + "required": false, + "type": "string" + } + ] + }, + { + "operationId": "createDriveUploadURL", + "entry": "drives", + "method": "createUploadUrl", + "httpMethod": "POST", + "path": "/drives/upload-url", + "parameters": [], + "paramsType": "DriveCreateUploadURLParams", + "paramsOptional": false, + "paramsLocation": "body", + "responseType": "json", + "itemType": "DriveUploadURL", + "parameterFields": [ + { + "name": "identity_id", + "wireName": "identity_id", + "location": "query", + "required": true, + "type": "string" + }, + { + "name": "path", + "wireName": "path", + "location": "body", + "required": true, + "type": "string" + }, + { + "name": "content_type", + "wireName": "content_type", + "location": "body", + "required": false, + "type": "string" + } + ] + }, + { + "operationId": "createDriveDownloadURL", + "entry": "drives", + "method": "createDownloadUrl", + "httpMethod": "POST", + "path": "/drives/download-url", + "parameters": [], + "paramsType": "DriveCreateDownloadURLParams", + "paramsOptional": false, + "paramsLocation": "body", + "responseType": "json", + "itemType": "DriveDownloadURL", + "parameterFields": [ + { + "name": "identity_id", + "wireName": "identity_id", + "location": "query", + "required": true, + "type": "string" + }, + { + "name": "path", + "wireName": "path", + "location": "body", + "required": true, + "type": "string" + } + ] + }, + { + "operationId": "deleteDriveEntry", + "entry": "drives", + "method": "deleteEntry", + "httpMethod": "DELETE", + "path": "/drives/entries", + "parameters": [], + "paramsType": "DriveDeleteEntryParams", + "paramsOptional": false, + "paramsLocation": "body", + "responseType": "json", + "itemType": "DriveDeleteEntryResponse", + "parameterFields": [ + { + "name": "identity_id", + "wireName": "identity_id", + "location": "query", + "required": true, + "type": "string" + }, + { + "name": "path", + "wireName": "path", + "location": "body", + "required": true, + "type": "string" + }, + { + "name": "recursive", + "wireName": "recursive", + "location": "body", + "required": false, + "type": "boolean" + } + ] + }, + { + "operationId": "clearDrive", + "entry": "drives", + "method": "clear", + "httpMethod": "DELETE", + "path": "/drives", + "parameters": [], + "paramsType": "DriveClearParams", + "paramsOptional": false, + "paramsLocation": "query", + "responseType": "json", + "itemType": "DriveClearResponse", + "parameterFields": [ + { + "name": "identity_id", + "wireName": "identity_id", + "location": "query", + "required": true, + "type": "string" + } + ] } ] diff --git a/src/forward/drive.ts b/src/forward/drive.ts new file mode 100644 index 0000000..d364a48 --- /dev/null +++ b/src/forward/drive.ts @@ -0,0 +1,51 @@ +import { APIResource } from '../core/resource.js'; +import { stringifyQuery, type RequestOptions } from '../core/client.js'; +import type { APIPromise } from '../core/api-promise.js'; +import type { DriveListEntriesParams, DriveListEntriesResponse, DriveCreateUploadURLParams, DriveUploadURL, DriveCreateDownloadURLParams, DriveDownloadURL, DriveDeleteEntryParams, DriveDeleteEntryResponse, DriveClearParams, DriveClearResponse } from './types.js'; + +function driveQuery(query: object): string { + // Match the Drive canonical form encoding without touching cursor values. + return stringifyQuery(query).split('&').map(field => { + if (!field.startsWith('path=')) return field; + const value = decodeURIComponent(field.slice(5).replace(/\+/g, ' ')); + return 'path=' + encodeURIComponent(value).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/%20/g, '+').replace(/%2F/g, '/'); + }).join('&'); +} + +/** Persistent Identity files (Beta). Identity SATs also require identity_id. */ +export class Drives extends APIResource { + /** One page in service order. Pass next_page_token unchanged to page_token. */ + listEntries(params: DriveListEntriesParams, options?: RequestOptions): APIPromise { + return this._client.request({ method: 'GET', path: 'drives/entries', ...options, + query: { ...params, ...options?.query }, querySerializer: driveQuery }); + } + + /** Request a PUT URL. Only its returned headers belong on the storage request. + * Replaying an Idempotency-Key does not renew the URL's expiry. */ + createUploadUrl(params: DriveCreateUploadURLParams, options?: RequestOptions): APIPromise { + const { identity_id, ...body } = params; + return this._client.request({ method: 'POST', path: 'drives/upload-url', body, ...options, + query: { identity_id, ...options?.query } }); + } + + /** Request a GET URL. Use a new Idempotency-Key or omit it to refresh expiry. */ + createDownloadUrl(params: DriveCreateDownloadURLParams, options?: RequestOptions): APIPromise { + const { identity_id, ...body } = params; + return this._client.request({ method: 'POST', path: 'drives/download-url', body, ...options, + query: { identity_id, ...options?.query } }); + } + + /** Delete a file or directory. Nonempty directories require recursive: true. + * Partial failures preserve error.deleted_count in the API error body. */ + deleteEntry(params: DriveDeleteEntryParams, options?: RequestOptions): APIPromise { + const { identity_id, ...body } = params; + return this._client.request({ method: 'DELETE', path: 'drives/entries', body, ...options, + query: { identity_id, ...options?.query } }); + } + + /** Clear the selected Identity Drive. All its files are deleted. */ + clear(params: DriveClearParams, options?: RequestOptions): APIPromise { + return this._client.request({ method: 'DELETE', path: 'drives', ...options, + query: { ...params, ...options?.query } }); + } +} diff --git a/src/forward/forwardClient.ts b/src/forward/forwardClient.ts index 1564cef..5c19d35 100644 --- a/src/forward/forwardClient.ts +++ b/src/forward/forwardClient.ts @@ -10,6 +10,7 @@ import { Channels } from './channel.js'; import { ChannelPairings } from './channel-pairing.js'; import { Environments } from './environment.js'; import { Files } from './file.js'; +import { Drives } from './drive.js'; import { Skills } from './skill.js'; import { Vaults } from './vault.js'; import { MemoryStores } from './memory-store.js'; @@ -32,6 +33,7 @@ export class ForwardClient extends APIClient { readonly channelPairings: ChannelPairings = new ChannelPairings(this); readonly environments: Environments = new Environments(this); readonly files: Files = new Files(this); + readonly drives: Drives = new Drives(this); readonly skills: Skills = new Skills(this); readonly vaults: Vaults = new Vaults(this); readonly memoryStores: MemoryStores = new MemoryStores(this); diff --git a/src/forward/index.ts b/src/forward/index.ts index 14ec406..5de4440 100644 --- a/src/forward/index.ts +++ b/src/forward/index.ts @@ -48,3 +48,5 @@ export * from './realtime/types.js'; export { ServiceAccountTokens } from './service-account-token.js'; export { IdentityServiceAccountTokens } from './identity-service-account-token.js'; + +export { Drives } from './drive.js'; diff --git a/src/forward/types.ts b/src/forward/types.ts index 9f0b93a..e33d55f 100644 --- a/src/forward/types.ts +++ b/src/forward/types.ts @@ -2803,6 +2803,8 @@ export interface TemplateListParams { } export interface TemplateNewParams { + /** Replaces the complete managed tool list. Null or an empty list disables it. */ + managed_tool_config?: ManagedToolConfig | null; /** * Template 名称,1-256 个字符,租户内唯一。 */ @@ -2872,6 +2874,8 @@ export interface TemplateNewParams { } export interface TemplateUpdateParams { + /** Replaces the complete managed tool list. Null or an empty list disables it. */ + managed_tool_config?: ManagedToolConfig | null; /** * 新的 Template 名称。 */ @@ -2967,6 +2971,8 @@ export interface TemplateCloneParams { } export interface Template { + /** Replaces the complete managed tool list. Null or an empty list disables it. */ + managed_tool_config?: ManagedToolConfig | null; /** * 固定为 `template`。 */ @@ -3254,3 +3260,83 @@ export interface TemplateUsage { active_seconds: number; credits: number; } + +/** Forward-managed tools. This is distinct from tools[].enabled_tools. */ +export interface ManagedToolConfig { + /** The complete enabled list; [] disables all managed tools. */ + enabled_tools: Array; + [key: string]: unknown; +} + +/** One file or virtual directory in an Identity Drive. */ +export interface DriveEntry { + path: string; + name: string; + type: 'file' | 'directory'; + /** Zero for an empty file; omitted for directories. */ + size?: number; + etag?: string; + last_modified?: string; + [key: string]: unknown; +} +export interface DriveListEntriesResponse { + /** Immediate children, in the order returned by the service. */ + entries: Array; + /** Omitted on the last page. Do not alter or reuse in another scope/path. */ + next_page_token?: string; + [key: string]: unknown; +} +export interface DriveListEntriesParams { + /** Query parameter; required even with an Identity SAT. */ + identity_id: string; + /** Relative directory; omit or use an empty string for root. */ + path?: string; + /** Page size, 1–100 (default 20). */ + limit?: number; + page_token?: string; +} +export interface DriveCreateUploadURLParams { + /** Query parameter, not part of the JSON body. */ + identity_id: string; + path: string; + content_type?: string; +} +export interface DriveCreateDownloadURLParams { + identity_id: string; + path: string; +} +export interface DriveDeleteEntryParams { + identity_id: string; + path: string; + recursive?: boolean; +} +export interface DriveClearParams { + identity_id: string; +} +/** A signed storage URL, valid for 15 minutes. Treat the URL as a credential. */ +export interface DriveUploadURL { + url: string; + method: 'PUT'; + headers: Record; + expires_at: string; + [key: string]: unknown; +} +export interface DriveDownloadURL { + url: string; + method: 'GET'; + headers: Record; + expires_at: string; + /** May be omitted for a zero-byte file. */ + size?: number; + etag?: string; + content_type?: string; + [key: string]: unknown; +} +export interface DriveDeleteEntryResponse { + deleted_count: number; + [key: string]: unknown; +} +export interface DriveClearResponse { + deleted_count: number; + [key: string]: unknown; +} diff --git a/tests/api-contracts.test.mjs b/tests/api-contracts.test.mjs index 8c49a0b..e55ca6e 100644 --- a/tests/api-contracts.test.mjs +++ b/tests/api-contracts.test.mjs @@ -9,7 +9,7 @@ const handwrittenMethods = { }; for (const mode of ['forward', 'managed']) { - test(`${mode}: public resource inventory exactly matches the frozen API surface plus handwritten helpers (${mode === 'forward' ? 118 : 98} APIs)`, () => { + test(`${mode}: public resource inventory exactly matches the frozen API surface plus handwritten helpers (${mode === 'forward' ? 123 : 98} APIs)`, () => { const client = testClient(mode, () => { throw Error('inventory must not call network'); }); const expectedRoots = [...new Set(methods[mode].map(m => m.entry.split('.')[0]))]; const actualRoots = Object.entries(client).filter(([, value]) => value && typeof value === 'object' && Object.hasOwn(value, '_client')).map(([key]) => key); @@ -26,7 +26,7 @@ for (const mode of ['forward', 'managed']) { }; for (const entry of [...new Set(methods[mode].map(m => m.entry.split('.')[0]))]) walk(client[entry], entry); const expected = methods[mode].map(m => `${m.entry}.${m.method}`); - assert.equal(expected.length, mode === 'forward' ? 118 : 98); + assert.equal(expected.length, mode === 'forward' ? 123 : 98); assert.equal(new Set(expected).size, expected.length); assert.deepEqual(actual.sort(), [...expected, ...handwrittenMethods[mode]].sort()); }); diff --git a/tests/drive.test.mjs b/tests/drive.test.mjs new file mode 100644 index 0000000..06a209d --- /dev/null +++ b/tests/drive.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { testClient, response, sdk } from './helpers.mjs'; + +test('Drive preserves service order, canonical path encoding and opaque cursors', async () => { + const cursor = 'opaque/+%=&'; + let calls = 0; + const client = testClient('forward', request => { + calls++; + const url = new URL(request.url); + assert.equal(request.headers.get('authorization'), 'Bearer test-sat'); + assert.equal(url.searchParams.get('identity_id'), 'idn_test'); + assert.equal(url.searchParams.get('path'), '目录/a b+~*'); + assert.ok(url.search.includes('path=%E7%9B%AE%E5%BD%95/a+b%2B~%2A')); + assert.ok(url.search.includes('extra=a%2Fb')); + if (calls === 1) return response({ entries: [{ path: 'a.txt', name: 'a.txt', type: 'file', size: 0 }, { path: 'a', name: 'a', type: 'directory' }], next_page_token: cursor }); + assert.equal(url.searchParams.get('page_token'), cursor); + return response({ entries: [] }); + }, { authMode: 'sa', pat: undefined, accessToken: 'test-sat', defaultQuery: { path: 'replaced', extra: 'a/b' } }); + const first = await client.drives.listEntries({ identity_id: 'idn_test', path: 'replaced-again' }, { query: { path: '目录/a b+~*' } }); + assert.deepEqual(first.entries.map(row => row.path), ['a.txt', 'a']); + assert.equal(first.entries[0].size, 0); + assert.equal(first.entries[1].size, undefined); + const last = await client.drives.listEntries({ identity_id: 'idn_test', path: '目录/a b+~*', page_token: first.next_page_token }); + assert.deepEqual(last.entries, []); + assert.equal(last.next_page_token, undefined); + assert.equal(calls, 2); +}); + +test('Drive separates query/body and retains signed headers and partial deletion errors', async () => { + let calls = 0; + const client = testClient('forward', async request => { + calls++; + const url = new URL(request.url); + assert.deepEqual([...url.searchParams], [['identity_id', 'idn_test']]); + if (url.pathname.endsWith('-url')) { + assert.equal(request.method, 'POST'); + assert.equal(request.headers.get('idempotency-key'), 'retry-one'); + const upload = url.pathname.endsWith('upload-url'); + assert.deepEqual(await request.json(), upload ? { path: 'reports/readme.txt', content_type: 'text/plain' } : { path: 'reports/readme.txt' }); + return response({ url: 'https://storage.test/signed', method: upload ? 'PUT' : 'GET', headers: { 'Content-Type': 'text/plain' }, expires_at: '2026-10-14T02:15:00Z' }); + } + assert.equal(request.method, 'DELETE'); + if (url.pathname.endsWith('/entries')) { + assert.deepEqual(await request.json(), { path: 'reports', recursive: false }); + return response({ deleted_count: 0 }); + } + assert.equal(await request.text(), ''); + return response({ request_id: 'req_partial', error: { code: 'drive_delete_partial', deleted_count: 7, message: 'partial' } }, 503); + }, { maxRetries: 2 }); + const upload = await client.drives.createUploadUrl({ identity_id: 'idn_test', path: 'reports/readme.txt', content_type: 'text/plain' }, { idempotencyKey: 'retry-one' }); + assert.deepEqual(upload.headers, { 'Content-Type': 'text/plain' }); + const download = await client.drives.createDownloadUrl({ identity_id: 'idn_test', path: 'reports/readme.txt' }, { idempotencyKey: 'retry-one' }); + assert.equal(download.method, 'GET'); + assert.equal(download.size, undefined); + assert.equal((await client.drives.deleteEntry({ identity_id: 'idn_test', path: 'reports', recursive: false })).deleted_count, 0); + await assert.rejects(client.drives.clear({ identity_id: 'idn_test' }), error => { + assert.ok(error instanceof sdk.InternalServerError); + assert.equal(error.code, 'drive_delete_partial'); + assert.equal(error.request_id, 'req_partial'); + assert.equal(error.error.error.deleted_count, 7); + return true; + }); + assert.equal(calls, 4); +}); + +test('Template managed tools preserves omitted, null and empty configurations', async () => { + const bodies = []; + const client = testClient('forward', async request => { + bodies.push(await request.json()); + return response({ managed_tool_config: { enabled_tools: ['drive', 'schedule'] } }); + }); + await client.templates.update('tmpl_test'); + await client.templates.update('tmpl_test', { managed_tool_config: null }); + await client.templates.update('tmpl_test', { managed_tool_config: { enabled_tools: [] } }); + const result = await client.templates.create({ name: 'drive', model: 'auto', environment_id: 'env_test', managed_tool_config: { enabled_tools: ['drive', 'schedule'] } }); + assert.deepEqual(bodies.slice(0, 3), [{}, { managed_tool_config: null }, { managed_tool_config: { enabled_tools: [] } }]); + assert.deepEqual(bodies[3].managed_tool_config, { enabled_tools: ['drive', 'schedule'] }); + assert.deepEqual(result.managed_tool_config.enabled_tools, ['drive', 'schedule']); +}); + +test('Drive does not normalize rejected paths or clamp limits, and accepts an explicit root', async () => { + let expected; + const client = testClient('forward', async request => { + const query = new URL(request.url).searchParams; + assert.equal(query.get('path'), expected.path); + assert.equal(query.get('limit'), String(expected.limit)); + assert.deepEqual(query.getAll('identity_id'), ['idn_test']); + assert.equal(await request.text(), ''); + if (expected.path === '' && [1, 100].includes(expected.limit)) return response({ entries: [] }); + return response({ error: { code: 'invalid_argument', message: 'invalid Drive query' } }, 400); + }); + for (const limit of [1, 100]) { + expected = { path: '', limit }; + assert.deepEqual((await client.drives.listEntries({ identity_id: 'idn_test', ...expected })).entries, []); + } + for (const [path, limit] of [['/absolute', 20], ['../other', 20], ['a\\b', 20], ['a%2Fb', 20], [' a ', 20], ['', 0], ['', 101]]) { + expected = { path, limit }; + await assert.rejects(client.drives.listEntries({ identity_id: 'idn_test', ...expected }), error => error instanceof sdk.BadRequestError && error.code === 'invalid_argument'); + } +}); diff --git a/tests/fixtures/forward/api-contracts.json b/tests/fixtures/forward/api-contracts.json index ca41240..e427cd9 100644 --- a/tests/fixtures/forward/api-contracts.json +++ b/tests/fixtures/forward/api-contracts.json @@ -75,7 +75,13 @@ }, "metadata": {}, "created_at": "2026-06-18T10:00:00Z", - "updated_at": "2026-06-18T10:00:00Z" + "updated_at": "2026-06-18T10:00:00Z", + "managed_tool_config": { + "enabled_tools": [ + "drive", + "schedule" + ] + } }, "empty": false }, @@ -112,7 +118,13 @@ }, "metadata": {}, "created_at": "2026-06-18T10:00:00Z", - "updated_at": "2026-06-18T10:00:00Z" + "updated_at": "2026-06-18T10:00:00Z", + "managed_tool_config": { + "enabled_tools": [ + "drive", + "schedule" + ] + } }, "empty": false }, @@ -173,7 +185,13 @@ }, "metadata": {}, "created_at": "2026-06-18T10:00:00Z", - "updated_at": "2026-06-18T10:30:00Z" + "updated_at": "2026-06-18T10:30:00Z", + "managed_tool_config": { + "enabled_tools": [ + "drive", + "schedule" + ] + } }, "empty": false }, @@ -2907,5 +2925,89 @@ "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke", "id": "serviceAccountTokens.revoke", "empty": false + }, + { + "operation_id": "listDriveEntries", + "entry": "Drives", + "response": { + "entries": [ + { + "path": "reports/a.txt", + "name": "a.txt", + "type": "file", + "size": 0 + }, + { + "path": "reports/a", + "name": "a", + "type": "directory" + } + ], + "next_page_token": "opaque-page-2" + }, + "empty": false, + "stream": false, + "download": false, + "raw": false, + "id": "drives.listEntries" + }, + { + "operation_id": "createDriveUploadURL", + "entry": "Drives", + "response": { + "url": "https://storage.test/drive?signature=example", + "headers": { + "Content-Type": "text/plain" + }, + "expires_at": "2026-10-14T02:15:00Z", + "method": "PUT" + }, + "empty": false, + "stream": false, + "download": false, + "raw": false, + "id": "drives.createUploadUrl" + }, + { + "operation_id": "createDriveDownloadURL", + "entry": "Drives", + "response": { + "url": "https://storage.test/drive?signature=example", + "headers": {}, + "expires_at": "2026-10-14T02:15:00Z", + "method": "GET", + "size": 12, + "etag": "etag-contract", + "content_type": "text/plain" + }, + "empty": false, + "stream": false, + "download": false, + "raw": false, + "id": "drives.createDownloadUrl" + }, + { + "operation_id": "deleteDriveEntry", + "entry": "Drives", + "response": { + "deleted_count": 2 + }, + "empty": false, + "stream": false, + "download": false, + "raw": false, + "id": "drives.deleteEntry" + }, + { + "operation_id": "clearDrive", + "entry": "Drives", + "response": { + "deleted_count": 0 + }, + "empty": false, + "stream": false, + "download": false, + "raw": false, + "id": "drives.clear" } ] diff --git a/tests/fixtures/forward/api-methods.json b/tests/fixtures/forward/api-methods.json index 41714de..4b3efea 100644 --- a/tests/fixtures/forward/api-methods.json +++ b/tests/fixtures/forward/api-methods.json @@ -3048,5 +3048,127 @@ } ], "paginated": false + }, + { + "id": "drives.listEntries", + "entry": "drives", + "method": "listEntries", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "identity_id", + "location": "query" + }, + { + "wire": "path", + "location": "query" + }, + { + "wire": "limit", + "location": "query" + }, + { + "wire": "page_token", + "location": "query" + } + ] + } + ], + "paginated": false + }, + { + "id": "drives.createUploadUrl", + "entry": "drives", + "method": "createUploadUrl", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "identity_id", + "location": "query" + }, + { + "wire": "path", + "location": "body" + }, + { + "wire": "content_type", + "location": "body" + } + ] + } + ], + "paginated": false + }, + { + "id": "drives.createDownloadUrl", + "entry": "drives", + "method": "createDownloadUrl", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "identity_id", + "location": "query" + }, + { + "wire": "path", + "location": "body" + } + ] + } + ], + "paginated": false + }, + { + "id": "drives.deleteEntry", + "entry": "drives", + "method": "deleteEntry", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "identity_id", + "location": "query" + }, + { + "wire": "path", + "location": "body" + }, + { + "wire": "recursive", + "location": "body" + } + ] + } + ], + "paginated": false + }, + { + "id": "drives.clear", + "entry": "drives", + "method": "clear", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "identity_id", + "location": "query" + } + ] + } + ], + "paginated": false } ] diff --git a/tests/fixtures/forward/api-operation-cases.json b/tests/fixtures/forward/api-operation-cases.json index e5724a1..1f4fec0 100644 --- a/tests/fixtures/forward/api-operation-cases.json +++ b/tests/fixtures/forward/api-operation-cases.json @@ -1,7 +1,7 @@ { "schema_version": 1, "openapi_sha256": "cfc0d3556f61838f65174b4c8ae92eded0d7c9fc00dcbd58d07dd6365e711f96", - "operation_count": 118, + "operation_count": 123, "cases": [ { "operation_id": "addSessionResource", @@ -885,7 +885,13 @@ "value": { "name": "sdk-contract", "model": "ultimate", - "environment_id": "env_contract" + "environment_id": "env_contract", + "managed_tool_config": { + "enabled_tools": [ + "drive", + "schedule" + ] + } } } }, @@ -4647,7 +4653,14 @@ "python_name": "update_template_request", "typescript_name": "updateTemplateRequest", "go_name": "UpdateTemplateRequest", - "value": {} + "value": { + "managed_tool_config": { + "enabled_tools": [ + "drive", + "schedule" + ] + } + } } }, { @@ -5075,6 +5088,171 @@ "reason": "session ended" } } + }, + { + "operation_id": "listDriveEntries", + "resource": "Drives", + "clients": { + "go": "Drives", + "typescript": "drives", + "python": "drives" + }, + "methods": { + "go": "ListEntries", + "typescript": "listEntries", + "python": "list_entries" + }, + "http_method": "GET", + "path": "/drives/entries", + "parameters": [ + { + "wire_name": "identity_id", + "location": "query", + "required": true, + "value": "idn_contract" + }, + { + "wire_name": "path", + "location": "query", + "required": false, + "value": "reports" + }, + { + "wire_name": "limit", + "location": "query", + "required": false, + "value": 2 + }, + { + "wire_name": "page_token", + "location": "query", + "required": false, + "value": "opaque-page-1" + } + ], + "request_body": null, + "source": "https://docs.qoder.com/cloud-agents/api/forward/drives/overview (2026-10-14)" + }, + { + "operation_id": "createDriveUploadURL", + "resource": "Drives", + "clients": { + "go": "Drives", + "typescript": "drives", + "python": "drives" + }, + "methods": { + "go": "CreateUploadURL", + "typescript": "createUploadUrl", + "python": "create_upload_url" + }, + "http_method": "POST", + "path": "/drives/upload-url", + "parameters": [ + { + "wire_name": "identity_id", + "location": "query", + "required": true, + "value": "idn_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "path": "reports/readme.txt", + "content_type": "text/plain" + } + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/drives/overview (2026-10-14)" + }, + { + "operation_id": "createDriveDownloadURL", + "resource": "Drives", + "clients": { + "go": "Drives", + "typescript": "drives", + "python": "drives" + }, + "methods": { + "go": "CreateDownloadURL", + "typescript": "createDownloadUrl", + "python": "create_download_url" + }, + "http_method": "POST", + "path": "/drives/download-url", + "parameters": [ + { + "wire_name": "identity_id", + "location": "query", + "required": true, + "value": "idn_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "path": "reports/readme.txt" + } + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/drives/overview (2026-10-14)" + }, + { + "operation_id": "deleteDriveEntry", + "resource": "Drives", + "clients": { + "go": "Drives", + "typescript": "drives", + "python": "drives" + }, + "methods": { + "go": "DeleteEntry", + "typescript": "deleteEntry", + "python": "delete_entry" + }, + "http_method": "DELETE", + "path": "/drives/entries", + "parameters": [ + { + "wire_name": "identity_id", + "location": "query", + "required": true, + "value": "idn_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "path": "reports", + "recursive": true + } + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/drives/overview (2026-10-14)" + }, + { + "operation_id": "clearDrive", + "resource": "Drives", + "clients": { + "go": "Drives", + "typescript": "drives", + "python": "drives" + }, + "methods": { + "go": "Clear", + "typescript": "clear", + "python": "clear" + }, + "http_method": "DELETE", + "path": "/drives", + "parameters": [ + { + "wire_name": "identity_id", + "location": "query", + "required": true, + "value": "idn_contract" + } + ], + "request_body": null, + "source": "https://docs.qoder.com/cloud-agents/api/forward/drives/overview (2026-10-14)" } ] } diff --git a/tests/live/drive-live.mjs b/tests/live/drive-live.mjs new file mode 100644 index 0000000..8835092 --- /dev/null +++ b/tests/live/drive-live.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { test } from 'node:test'; +import { ForwardClient } from '../../dist/index.js'; + +// Explicit entry point: no model calls; clear is limited to newly created Identities. +for (const region of ['cn', 'global']) for (const auth of ['pat', 'admin_sat', 'identity_sat']) { + test(`Drive ${region}/${auth}`, { timeout: 240_000 }, async () => { + assert.equal(process.env.QODER_DRIVE_LIVE_ALLOW_WRITE, 'true'); + const host = region === 'cn' ? 'https://api.qoder.com.cn' : 'https://api.qoder.com'; + const options = { baseURL: `${host}/api/v1/forward/`, maxRetries: 0, timeout: 30_000 }; + const issuer = new ForwardClient({ ...options, authMode: 'sa' }); + const saKey = process.env[`QODER_SA_${region.toUpperCase()}_KEY`]; + const cleanups = []; + const failures = []; + let stage = 'setup'; + const safeError = error => error.status ? `HTTP ${error.status} code=${error.code ?? ''} request_id=${error.request_id ?? ''}` : error.name; + async function issue(identityID) { + assert.ok(saKey, `QODER_SA_${region.toUpperCase()}_KEY is required`); + const params = { saKey, ttl_seconds: 600 }; + const token = identityID ? await issuer.identities.serviceAccountTokens.create(identityID, params) + : await issuer.serviceAccountTokens.create(params); + cleanups.push(() => issuer.serviceAccountTokens.revoke(token.auth_token_id, { + saKey, target_credential_id: token.credential_id, target_expires_at: token.expires_at, + })); + return new ForwardClient({ ...options, authMode: 'sa', accessToken: token.access_token }); + } + async function storage(signed, body) { + assert.ok(!Object.keys(signed.headers).some(key => key.toLowerCase() === 'authorization')); + return fetch(signed.url, { method: signed.method, headers: signed.headers, body, + redirect: 'error', signal: AbortSignal.timeout(30_000) }); + } + try { + const pat = process.env[`QODER_FORWARD_${region.toUpperCase()}_PAT`]; + if (auth === 'pat') assert.ok(pat, `QODER_FORWARD_${region.toUpperCase()}_PAT is required`); + const admin = auth === 'pat' ? new ForwardClient({ ...options, authMode: 'pat', pat }) : await issue(); + const identity = await admin.identities.create({ external_id: `sdk-drive-${randomUUID()}` }); + cleanups.push(() => admin.identities.delete(identity.id)); + cleanups.push(() => admin.drives.clear({ identity_id: identity.id })); + const runtime = auth === 'identity_sat' ? await issue(identity.id) : admin; + const query = { identity_id: identity.id }; + console.log(`Drive ${region}/${auth} identity_id=${identity.id}`); + assert.deepEqual((await runtime.drives.listEntries(query)).entries, []); + const paths = ['sdk 目录/a+b.txt', 'sdk 目录/zero.txt', 'sdk 目录/nested/child.txt']; + const content = 'SDK Drive verification'; + stage = 'upload'; + for (const path of paths) { + const signed = await runtime.drives.createUploadUrl({ ...query, path, content_type: 'text/plain' }); + assert.equal(signed.method, 'PUT'); + const response = await storage(signed, path.includes('zero') ? '' : content); + assert.ok(response.ok, `Storage PUT HTTP ${response.status}`); + await response.arrayBuffer(); + } + stage = 'pagination'; + const entries = [], seen = new Set(); + let pageToken; + do { + const page = await runtime.drives.listEntries({ ...query, path: 'sdk 目录', limit: 1, page_token: pageToken }); + entries.push(...page.entries); + pageToken = page.next_page_token; + if (pageToken) { + assert.ok(!seen.has(pageToken), 'Repeated cursor'); + seen.add(pageToken); + assert.ok(seen.size < 10, 'Pagination did not finish'); + } + } while (pageToken); + assert.deepEqual(new Set(entries.map(entry => entry.path)), new Set([paths[0], paths[1], 'sdk 目录/nested'])); + assert.equal(entries.find(entry => entry.path === paths[1]).size, 0); + assert.equal(entries.find(entry => entry.type === 'directory').size, undefined); + stage = 'download'; + const signed = await runtime.drives.createDownloadUrl({ ...query, path: paths[0] }); + assert.equal(signed.method, 'GET'); + const download = await storage(signed); + assert.equal(download.status, 200); + assert.equal(await download.text(), content); + stage = 'deletion'; + await assert.rejects(runtime.drives.deleteEntry({ ...query, path: 'sdk 目录' }), error => error.status === 409); + assert.equal((await runtime.drives.deleteEntry({ ...query, path: paths[0] })).deleted_count, 1); + assert.equal((await runtime.drives.deleteEntry({ ...query, path: paths[0] })).deleted_count, 0); + assert.equal((await runtime.drives.deleteEntry({ ...query, path: 'sdk 目录/nested', recursive: true })).deleted_count, 1); + assert.equal((await runtime.drives.clear(query)).deleted_count, 1); + assert.equal((await runtime.drives.clear(query)).deleted_count, 0); + if (auth === 'identity_sat') { + stage = 'Identity isolation'; + const other = await admin.identities.create({ external_id: `sdk-drive-other-${randomUUID()}` }); + cleanups.push(() => admin.identities.delete(other.id)); + await assert.rejects(runtime.drives.listEntries({ identity_id: other.id }), error => error.status === 403); + } else { + stage = 'Template managed tools'; + const env = await admin.environments.create({ name: `sdk-drive-${randomUUID()}`, config: { type: 'cloud' } }); + cleanups.push(() => admin.environments.archive(env.id)); + const template = await admin.templates.create({ name: `sdk-drive-${randomUUID()}`, model: 'auto', environment_id: env.id, + managed_tool_config: { enabled_tools: ['drive'] } }); + cleanups.push(() => admin.templates.archive(template.id, {})); + assert.deepEqual((await admin.templates.retrieve(template.id)).managed_tool_config.enabled_tools, ['drive']); + const updated = await admin.templates.update(template.id, { managed_tool_config: { enabled_tools: [] } }); + assert.deepEqual(updated.managed_tool_config?.enabled_tools ?? [], []); + } + } catch (error) { failures.push(`${stage}: ${safeError(error)}`); } + finally { + for (const action of cleanups.reverse()) { + try { await action(); } catch (error) { failures.push(`cleanup: ${safeError(error)}`); } + } + } + assert.deepEqual(failures, []); + }); +} diff --git a/tests/types-smoke.ts b/tests/types-smoke.ts index ffbcf64..996c737 100644 --- a/tests/types-smoke.ts +++ b/tests/types-smoke.ts @@ -198,3 +198,21 @@ function typedRealtimeEvent(event: RealtimeServerEvent): void { if (isRealtimeEvent(event, 'voice.ready')) { const rate: number = event.payload.input_audio.sample_rate; void rate; } } void typedRealtimeEvent; + +// Drive has an independent entries/next_page_token response. +void forward.drives.listEntries({ identity_id: 'idn', path: 'reports', limit: 1 }).then(page => { + const size: number | undefined = page.entries[0]?.size; void size; + if (page.next_page_token) return forward.drives.listEntries({ identity_id: 'idn', path: 'reports', page_token: page.next_page_token }); +}); +void forward.drives.createUploadUrl({ identity_id: 'idn', path: 'notes.txt', content_type: 'text/plain' }); +void forward.drives.createDownloadUrl({ identity_id: 'idn', path: 'notes.txt' }); +void forward.drives.deleteEntry({ identity_id: 'idn', path: 'reports', recursive: true }); +void forward.drives.clear({ identity_id: 'idn' }); +void forward.templates.update('tmpl', { managed_tool_config: { enabled_tools: ['drive'] } }); +void forward.templates.update('tmpl', { managed_tool_config: null }); +// @ts-expect-error Identity SATs must still pass identity_id. +forward.drives.listEntries({}); +// @ts-expect-error Drive has no ordinary page cursor. +forward.drives.listEntries({ identity_id: 'idn', page: 'cursor' }); +// @ts-expect-error Managed does not expose Forward Drive. +managed.drives;