From 3d7df10010d82ed07a701a8215c8e08ca6d040ee Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 14 Oct 2026 15:43:17 +0800
Subject: [PATCH] feat(forward): support Drive files and managed tool
configuration
---
.env.live.example | 5 +
CHANGELOG.md | 5 +
Makefile | 4 +
README.md | 55 ++
docs/api/reference.md | 602 ++++++++++++++++++++++
examples/forward/drive/main.go | 98 ++++
forward/client.go | 2 +
forward/client_test.go | 4 +-
forward/configuration_types.go | 27 +
forward/drive.go | 260 ++++++++++
forward/drive_test.go | 193 +++++++
forward/template.go | 6 +
forward/test_helpers_test.go | 4 +-
forward/testdata/api-contracts.json | 113 +++-
forward/testdata/api-operation-cases.json | 184 ++++++-
integration/drive_live_test.go | 224 ++++++++
16 files changed, 1776 insertions(+), 10 deletions(-)
create mode 100644 examples/forward/drive/main.go
create mode 100644 forward/drive.go
create mode 100644 forward/drive_test.go
create mode 100644 integration/drive_live_test.go
diff --git a/.env.live.example b/.env.live.example
index bb15197..51afe40 100644
--- a/.env.live.example
+++ b/.env.live.example
@@ -47,3 +47,8 @@ QODER_REALTIME_LIVE_ALLOW_WRITE=false
QODER_REALTIME_LIVE_ALLOW_EXECUTION=false
# Optional raw speech PCM16 LE, 16 kHz mono, at most 30s including trailing silence.
QODER_REALTIME_LIVE_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 625bb65..03487d6 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 311b150..9c06aef 100644
--- a/Makefile
+++ b/Makefile
@@ -137,3 +137,7 @@ test-live-realtime:
test-live-sa-tokens:
LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run '^TestForwardServiceAccountTokensLive$$' -timeout=10m ./integration
+
+.PHONY: test-live-drive
+test-live-drive:
+ LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run '^TestDriveLive$$' -timeout=15m ./integration
diff --git a/README.md b/README.md
index c8c14cb..c8d73eb 100644
--- a/README.md
+++ b/README.md
@@ -658,6 +658,61 @@ For Session, Schedule, Batch, Deployment and Dream, the response to a create or
Server-side windows and local deadlines are independent: a Batch `completion_window: "24h"` is submitted to the server, while the examples' `-timeout 5m` is only how long the local program waits. A Batch sitting in `queued` may not have started, and waiting longer does not change how the server schedules it. The examples cancel the work they created when they time out, so that run's output cannot be used to judge later execution.
+## 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.
+
+```go
+page, err := client.Drives.ListEntries(ctx, forward.DriveListEntriesParams{
+ IdentityID: identityID, Path: forward.String("reports"), Limit: forward.Int(20),
+})
+if err != nil { return err }
+for _, entry := range page.Entries { fmt.Println(entry.Path) }
+if page.NextPageToken != "" {
+ page, err = client.Drives.ListEntries(ctx, forward.DriveListEntriesParams{
+ IdentityID: identityID, Path: forward.String("reports"),
+ PageToken: forward.String(page.NextPageToken),
+ })
+}
+// Replace the complete managed-tool list, retaining any other tools you need.
+_, err = client.Templates.Update(ctx, templateID, forward.TemplateUpdateParams{
+ ManagedToolConfig: forward.ManagedToolConfigParam{EnabledTools: []string{"drive"}},
+})
+```
+
+Run the [file lifecycle example](examples/forward/drive/main.go) with
+`go run ./examples/forward/drive -env .env.live -region cn` (or `global`).
+It supports the existing PAT/SA configuration and creates a fresh Identity.
+`DriveEntry.JSON.Size.Valid()` distinguishes zero-byte files from directories.
+For maintainers, `make 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 and downloads
Upload parameters accept an `io.Reader`. Use `convention.UploadFile` to set the filename and media type:
diff --git a/docs/api/reference.md b/docs/api/reference.md
index 67c7651..3d62b28 100644
--- a/docs/api/reference.md
+++ b/docs/api/reference.md
@@ -135,6 +135,49 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
- [type DeletedSkillVersion](<#DeletedSkillVersion>)
- [func \(r DeletedSkillVersion\) RawJSON\(\) string](<#DeletedSkillVersion.RawJSON>)
- [func \(r \*DeletedSkillVersion\) UnmarshalJSON\(data \[\]byte\) error](<#DeletedSkillVersion.UnmarshalJSON>)
+- [type DriveClearParams](<#DriveClearParams>)
+ - [func \(r DriveClearParams\) URLQuery\(\) \(url.Values, error\)](<#DriveClearParams.URLQuery>)
+ - [func \(r \*DriveClearParams\) UnmarshalJSON\(data \[\]byte\) error](<#DriveClearParams.UnmarshalJSON>)
+- [type DriveClearResponse](<#DriveClearResponse>)
+ - [func \(r DriveClearResponse\) RawJSON\(\) string](<#DriveClearResponse.RawJSON>)
+ - [func \(r \*DriveClearResponse\) UnmarshalJSON\(data \[\]byte\) error](<#DriveClearResponse.UnmarshalJSON>)
+- [type DriveCreateDownloadURLParams](<#DriveCreateDownloadURLParams>)
+ - [func \(r DriveCreateDownloadURLParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#DriveCreateDownloadURLParams.MarshalJSON>)
+ - [func \(r DriveCreateDownloadURLParams\) URLQuery\(\) \(url.Values, error\)](<#DriveCreateDownloadURLParams.URLQuery>)
+ - [func \(r \*DriveCreateDownloadURLParams\) UnmarshalJSON\(data \[\]byte\) error](<#DriveCreateDownloadURLParams.UnmarshalJSON>)
+- [type DriveCreateUploadURLParams](<#DriveCreateUploadURLParams>)
+ - [func \(r DriveCreateUploadURLParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#DriveCreateUploadURLParams.MarshalJSON>)
+ - [func \(r DriveCreateUploadURLParams\) URLQuery\(\) \(url.Values, error\)](<#DriveCreateUploadURLParams.URLQuery>)
+ - [func \(r \*DriveCreateUploadURLParams\) UnmarshalJSON\(data \[\]byte\) error](<#DriveCreateUploadURLParams.UnmarshalJSON>)
+- [type DriveDeleteEntryParams](<#DriveDeleteEntryParams>)
+ - [func \(r DriveDeleteEntryParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#DriveDeleteEntryParams.MarshalJSON>)
+ - [func \(r DriveDeleteEntryParams\) URLQuery\(\) \(url.Values, error\)](<#DriveDeleteEntryParams.URLQuery>)
+ - [func \(r \*DriveDeleteEntryParams\) UnmarshalJSON\(data \[\]byte\) error](<#DriveDeleteEntryParams.UnmarshalJSON>)
+- [type DriveDeleteEntryResponse](<#DriveDeleteEntryResponse>)
+ - [func \(r DriveDeleteEntryResponse\) RawJSON\(\) string](<#DriveDeleteEntryResponse.RawJSON>)
+ - [func \(r \*DriveDeleteEntryResponse\) UnmarshalJSON\(data \[\]byte\) error](<#DriveDeleteEntryResponse.UnmarshalJSON>)
+- [type DriveDownloadURL](<#DriveDownloadURL>)
+ - [func \(r DriveDownloadURL\) RawJSON\(\) string](<#DriveDownloadURL.RawJSON>)
+ - [func \(r \*DriveDownloadURL\) UnmarshalJSON\(data \[\]byte\) error](<#DriveDownloadURL.UnmarshalJSON>)
+- [type DriveEntry](<#DriveEntry>)
+ - [func \(r DriveEntry\) RawJSON\(\) string](<#DriveEntry.RawJSON>)
+ - [func \(r \*DriveEntry\) UnmarshalJSON\(data \[\]byte\) error](<#DriveEntry.UnmarshalJSON>)
+- [type DriveListEntriesParams](<#DriveListEntriesParams>)
+ - [func \(r DriveListEntriesParams\) URLQuery\(\) \(url.Values, error\)](<#DriveListEntriesParams.URLQuery>)
+ - [func \(r \*DriveListEntriesParams\) UnmarshalJSON\(data \[\]byte\) error](<#DriveListEntriesParams.UnmarshalJSON>)
+- [type DriveListEntriesResponse](<#DriveListEntriesResponse>)
+ - [func \(r DriveListEntriesResponse\) RawJSON\(\) string](<#DriveListEntriesResponse.RawJSON>)
+ - [func \(r \*DriveListEntriesResponse\) UnmarshalJSON\(data \[\]byte\) error](<#DriveListEntriesResponse.UnmarshalJSON>)
+- [type DriveService](<#DriveService>)
+ - [func NewDriveService\(opts ...option.RequestOption\) DriveService](<#NewDriveService>)
+ - [func \(r \*DriveService\) Clear\(ctx context.Context, params DriveClearParams, opts ...option.RequestOption\) \(res \*DriveClearResponse, err error\)](<#DriveService.Clear>)
+ - [func \(r \*DriveService\) CreateDownloadURL\(ctx context.Context, params DriveCreateDownloadURLParams, opts ...option.RequestOption\) \(res \*DriveDownloadURL, err error\)](<#DriveService.CreateDownloadURL>)
+ - [func \(r \*DriveService\) CreateUploadURL\(ctx context.Context, params DriveCreateUploadURLParams, opts ...option.RequestOption\) \(res \*DriveUploadURL, err error\)](<#DriveService.CreateUploadURL>)
+ - [func \(r \*DriveService\) DeleteEntry\(ctx context.Context, params DriveDeleteEntryParams, opts ...option.RequestOption\) \(res \*DriveDeleteEntryResponse, err error\)](<#DriveService.DeleteEntry>)
+ - [func \(r \*DriveService\) ListEntries\(ctx context.Context, params DriveListEntriesParams, opts ...option.RequestOption\) \(res \*DriveListEntriesResponse, err error\)](<#DriveService.ListEntries>)
+- [type DriveUploadURL](<#DriveUploadURL>)
+ - [func \(r DriveUploadURL\) RawJSON\(\) string](<#DriveUploadURL.RawJSON>)
+ - [func \(r \*DriveUploadURL\) UnmarshalJSON\(data \[\]byte\) error](<#DriveUploadURL.UnmarshalJSON>)
- [type EffectiveConfig](<#EffectiveConfig>)
- [func \(r EffectiveConfig\) RawJSON\(\) string](<#EffectiveConfig.RawJSON>)
- [func \(r \*EffectiveConfig\) UnmarshalJSON\(data \[\]byte\) error](<#EffectiveConfig.UnmarshalJSON>)
@@ -321,6 +364,12 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
- [type MCPServerParam](<#MCPServerParam>)
- [func \(r MCPServerParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#MCPServerParam.MarshalJSON>)
- [func \(r \*MCPServerParam\) UnmarshalJSON\(data \[\]byte\) error](<#MCPServerParam.UnmarshalJSON>)
+- [type ManagedToolConfig](<#ManagedToolConfig>)
+ - [func \(r ManagedToolConfig\) RawJSON\(\) string](<#ManagedToolConfig.RawJSON>)
+ - [func \(r \*ManagedToolConfig\) UnmarshalJSON\(data \[\]byte\) error](<#ManagedToolConfig.UnmarshalJSON>)
+- [type ManagedToolConfigParam](<#ManagedToolConfigParam>)
+ - [func \(r ManagedToolConfigParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#ManagedToolConfigParam.MarshalJSON>)
+ - [func \(r \*ManagedToolConfigParam\) UnmarshalJSON\(data \[\]byte\) error](<#ManagedToolConfigParam.UnmarshalJSON>)
- [type Memory](<#Memory>)
- [func \(r Memory\) RawJSON\(\) string](<#Memory.RawJSON>)
- [func \(r \*Memory\) UnmarshalJSON\(data \[\]byte\) error](<#Memory.UnmarshalJSON>)
@@ -2145,6 +2194,7 @@ type Client struct {
Channels ChannelService
ChannelPairings ChannelPairingService
Environments EnvironmentService
+ Drives DriveService
Files FileService
Skills SkillService
Vaults VaultService
@@ -2518,6 +2568,488 @@ func (r *DeletedSkillVersion) UnmarshalJSON(data []byte) error
+
+## type [DriveClearParams]()
+
+
+
+```go
+type DriveClearParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ // contains filtered or unexported fields
+}
+```
+
+
+### func \(DriveClearParams\) [URLQuery]()
+
+```go
+func (r DriveClearParams) URLQuery() (url.Values, error)
+```
+
+
+
+
+### func \(\*DriveClearParams\) [UnmarshalJSON]()
+
+```go
+func (r *DriveClearParams) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveClearResponse]()
+
+
+
+```go
+type DriveClearResponse struct {
+ DeletedCount int64 `json:"deleted_count"`
+ JSON struct {
+ DeletedCount respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(DriveClearResponse\) [RawJSON]()
+
+```go
+func (r DriveClearResponse) RawJSON() string
+```
+
+
+
+
+### func \(\*DriveClearResponse\) [UnmarshalJSON]()
+
+```go
+func (r *DriveClearResponse) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveCreateDownloadURLParams]()
+
+
+
+```go
+type DriveCreateDownloadURLParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ Path string `json:"path" api:"required"`
+ // contains filtered or unexported fields
+}
+```
+
+
+### func \(DriveCreateDownloadURLParams\) [MarshalJSON]()
+
+```go
+func (r DriveCreateDownloadURLParams) MarshalJSON() ([]byte, error)
+```
+
+
+
+
+### func \(DriveCreateDownloadURLParams\) [URLQuery]()
+
+```go
+func (r DriveCreateDownloadURLParams) URLQuery() (url.Values, error)
+```
+
+
+
+
+### func \(\*DriveCreateDownloadURLParams\) [UnmarshalJSON]()
+
+```go
+func (r *DriveCreateDownloadURLParams) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveCreateUploadURLParams]()
+
+
+
+```go
+type DriveCreateUploadURLParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ Path string `json:"path" api:"required"`
+ ContentType param.Opt[string] `json:"content_type,omitzero"`
+ // contains filtered or unexported fields
+}
+```
+
+
+### func \(DriveCreateUploadURLParams\) [MarshalJSON]()
+
+```go
+func (r DriveCreateUploadURLParams) MarshalJSON() ([]byte, error)
+```
+
+
+
+
+### func \(DriveCreateUploadURLParams\) [URLQuery]()
+
+```go
+func (r DriveCreateUploadURLParams) URLQuery() (url.Values, error)
+```
+
+
+
+
+### func \(\*DriveCreateUploadURLParams\) [UnmarshalJSON]()
+
+```go
+func (r *DriveCreateUploadURLParams) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveDeleteEntryParams]()
+
+
+
+```go
+type DriveDeleteEntryParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ Path string `json:"path" api:"required"`
+ Recursive param.Opt[bool] `json:"recursive,omitzero"`
+ // contains filtered or unexported fields
+}
+```
+
+
+### func \(DriveDeleteEntryParams\) [MarshalJSON]()
+
+```go
+func (r DriveDeleteEntryParams) MarshalJSON() ([]byte, error)
+```
+
+
+
+
+### func \(DriveDeleteEntryParams\) [URLQuery]()
+
+```go
+func (r DriveDeleteEntryParams) URLQuery() (url.Values, error)
+```
+
+
+
+
+### func \(\*DriveDeleteEntryParams\) [UnmarshalJSON]()
+
+```go
+func (r *DriveDeleteEntryParams) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveDeleteEntryResponse]()
+
+
+
+```go
+type DriveDeleteEntryResponse struct {
+ DeletedCount int64 `json:"deleted_count"`
+ JSON struct {
+ DeletedCount respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(DriveDeleteEntryResponse\) [RawJSON]()
+
+```go
+func (r DriveDeleteEntryResponse) RawJSON() string
+```
+
+
+
+
+### func \(\*DriveDeleteEntryResponse\) [UnmarshalJSON]()
+
+```go
+func (r *DriveDeleteEntryResponse) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveDownloadURL]()
+
+
+
+```go
+type DriveDownloadURL struct {
+ URL string `json:"url"`
+ Method string `json:"method"`
+ Headers map[string]string `json:"headers"`
+ ExpiresAt time.Time `json:"expires_at" format:"date-time"`
+ Size int64 `json:"size"`
+ ETag string `json:"etag"`
+ ContentType string `json:"content_type"`
+ JSON struct {
+ URL respjson.Field
+ Method respjson.Field
+ Headers respjson.Field
+ ExpiresAt respjson.Field
+ Size respjson.Field
+ ETag respjson.Field
+ ContentType respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(DriveDownloadURL\) [RawJSON]()
+
+```go
+func (r DriveDownloadURL) RawJSON() string
+```
+
+
+
+
+### func \(\*DriveDownloadURL\) [UnmarshalJSON]()
+
+```go
+func (r *DriveDownloadURL) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveEntry]()
+
+DriveEntry represents a file or directory. JSON.Size.Valid\(\) distinguishes a zero\-byte file from a directory.
+
+```go
+type DriveEntry struct {
+ Path string `json:"path"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Size int64 `json:"size"`
+ ETag string `json:"etag"`
+ LastModified time.Time `json:"last_modified" format:"date-time"`
+ JSON struct {
+ Path respjson.Field
+ Name respjson.Field
+ Type respjson.Field
+ Size respjson.Field
+ ETag respjson.Field
+ LastModified respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(DriveEntry\) [RawJSON]()
+
+```go
+func (r DriveEntry) RawJSON() string
+```
+
+
+
+
+### func \(\*DriveEntry\) [UnmarshalJSON]()
+
+```go
+func (r *DriveEntry) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveListEntriesParams]()
+
+
+
+```go
+type DriveListEntriesParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ Path param.Opt[string] `query:"path,omitzero" json:"-"`
+ Limit param.Opt[int64] `query:"limit,omitzero" json:"-"`
+ PageToken param.Opt[string] `query:"page_token,omitzero" json:"-"`
+ // contains filtered or unexported fields
+}
+```
+
+
+### func \(DriveListEntriesParams\) [URLQuery]()
+
+```go
+func (r DriveListEntriesParams) URLQuery() (url.Values, error)
+```
+
+
+
+
+### func \(\*DriveListEntriesParams\) [UnmarshalJSON]()
+
+```go
+func (r *DriveListEntriesParams) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveListEntriesResponse]()
+
+
+
+```go
+type DriveListEntriesResponse struct {
+ Entries []DriveEntry `json:"entries"`
+ NextPageToken string `json:"next_page_token"`
+ JSON struct {
+ Entries respjson.Field
+ NextPageToken respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(DriveListEntriesResponse\) [RawJSON]()
+
+```go
+func (r DriveListEntriesResponse) RawJSON() string
+```
+
+
+
+
+### func \(\*DriveListEntriesResponse\) [UnmarshalJSON]()
+
+```go
+func (r *DriveListEntriesResponse) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [DriveService]()
+
+DriveService manages persistent Identity files. Drive is a Beta API. Every method requires IdentityID, including when using an Identity SAT.
+
+```go
+type DriveService struct{ Options []option.RequestOption }
+```
+
+
+### func [NewDriveService]()
+
+```go
+func NewDriveService(opts ...option.RequestOption) DriveService
+```
+
+
+
+
+### func \(\*DriveService\) [Clear]()
+
+```go
+func (r *DriveService) Clear(ctx context.Context, params DriveClearParams, opts ...option.RequestOption) (res *DriveClearResponse, err error)
+```
+
+Clear clears the selected Identity Drive. Use only with an Identity whose files you intend to delete.
+
+
+### func \(\*DriveService\) [CreateDownloadURL]()
+
+```go
+func (r *DriveService) CreateDownloadURL(ctx context.Context, params DriveCreateDownloadURLParams, opts ...option.RequestOption) (res *DriveDownloadURL, err error)
+```
+
+CreateDownloadURL returns a short\-lived GET URL. Use a new Idempotency\-Key or omit it to refresh an expired URL.
+
+
+### func \(\*DriveService\) [CreateUploadURL]()
+
+```go
+func (r *DriveService) CreateUploadURL(ctx context.Context, params DriveCreateUploadURLParams, opts ...option.RequestOption) (res *DriveUploadURL, err error)
+```
+
+CreateUploadURL returns a short\-lived PUT URL. Use only its returned headers for the storage request. Replaying an Idempotency\-Key does not renew expiry.
+
+
+### func \(\*DriveService\) [DeleteEntry]()
+
+```go
+func (r *DriveService) DeleteEntry(ctx context.Context, params DriveDeleteEntryParams, opts ...option.RequestOption) (res *DriveDeleteEntryResponse, err error)
+```
+
+DeleteEntry deletes a file or directory. Recursive must be true for a nonempty directory. Partial failures retain deleted\_count in the API error raw body.
+
+
+### func \(\*DriveService\) [ListEntries]()
+
+```go
+func (r *DriveService) ListEntries(ctx context.Context, params DriveListEntriesParams, opts ...option.RequestOption) (res *DriveListEntriesResponse, err error)
+```
+
+ListEntries returns one directory page in service order. Pass NextPageToken unchanged as PageToken to continue within the same Identity and directory.
+
+
+## type [DriveUploadURL]()
+
+
+
+```go
+type DriveUploadURL struct {
+ URL string `json:"url"`
+ Method string `json:"method"`
+ Headers map[string]string `json:"headers"`
+ ExpiresAt time.Time `json:"expires_at" format:"date-time"`
+ JSON struct {
+ URL respjson.Field
+ Method respjson.Field
+ Headers respjson.Field
+ ExpiresAt respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(DriveUploadURL\) [RawJSON]()
+
+```go
+func (r DriveUploadURL) RawJSON() string
+```
+
+
+
+
+### func \(\*DriveUploadURL\) [UnmarshalJSON]()
+
+```go
+func (r *DriveUploadURL) UnmarshalJSON(data []byte) error
+```
+
+
+
## type [EffectiveConfig]()
@@ -4836,6 +5368,70 @@ func (r *MCPServerParam) UnmarshalJSON(data []byte) error
+
+## type [ManagedToolConfig]()
+
+
+
+```go
+type ManagedToolConfig struct {
+ EnabledTools []string `json:"enabled_tools"`
+ JSON struct {
+ EnabledTools respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(ManagedToolConfig\) [RawJSON]()
+
+```go
+func (r ManagedToolConfig) RawJSON() string
+```
+
+
+
+
+### func \(\*ManagedToolConfig\) [UnmarshalJSON]()
+
+```go
+func (r *ManagedToolConfig) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [ManagedToolConfigParam]()
+
+ManagedToolConfigParam replaces the complete enabled tool list. Include other tools you want to keep. An empty list disables all managed tools.
+
+```go
+type ManagedToolConfigParam struct {
+ EnabledTools []string `json:"enabled_tools" api:"required"`
+ // contains filtered or unexported fields
+}
+```
+
+
+### func \(ManagedToolConfigParam\) [MarshalJSON]()
+
+```go
+func (r ManagedToolConfigParam) MarshalJSON() ([]byte, error)
+```
+
+
+
+
+### func \(\*ManagedToolConfigParam\) [UnmarshalJSON]()
+
+```go
+func (r *ManagedToolConfigParam) UnmarshalJSON(data []byte) error
+```
+
+
+
## type [Memory]()
@@ -10187,6 +10783,7 @@ func (r *SystemOverrideParam) UnmarshalJSON(data []byte) error
```go
type Template struct {
+ ManagedToolConfig ManagedToolConfig `json:"managed_tool_config"`
// Always `template`.
Type string `json:"type"`
// Template ID.
@@ -10215,6 +10812,7 @@ type Template struct {
// Custom metadata.
Metadata map[string]any `json:"metadata"`
JSON struct {
+ ManagedToolConfig respjson.Field
Type respjson.Field
ID respjson.Field
Name respjson.Field
@@ -10350,6 +10948,8 @@ func (r TemplateListParams) URLQuery() (url.Values, error)
```go
type TemplateNewParams struct {
+ // Replaces the complete managed tool list; use an empty list to disable all.
+ ManagedToolConfig ManagedToolConfigParam `json:"managed_tool_config,omitzero" api:"nullable"`
// Template name, 1-256 characters, unique per user.
Name string `json:"name" api:"required"`
// Model identifier. Accepts a string (such as `"ultimate"`), or an Agent model
@@ -10498,6 +11098,8 @@ Update Template
```go
type TemplateUpdateParams struct {
+ // Replaces the complete managed tool list; use an empty list to disable all.
+ ManagedToolConfig ManagedToolConfigParam `json:"managed_tool_config,omitzero" api:"nullable"`
// New Template name.
Name param.Opt[string] `json:"name,omitzero"`
// New Template description.
diff --git a/examples/forward/drive/main.go b/examples/forward/drive/main.go
new file mode 100644
index 0000000..212bc63
--- /dev/null
+++ b/examples/forward/drive/main.go
@@ -0,0 +1,98 @@
+// Drive Beta: upload, list, download and delete with a new isolated Identity.
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/examples/internal/forwardutil"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/examples/internal/live"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
+)
+
+func main() {
+ config, err := live.Load("forward")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, live.Safe(err))
+ os.Exit(1)
+ }
+ s := &forwardutil.Example{Client: forward.NewClient(config.Options()...), Config: config}
+ os.Exit(live.Execute(config, live.Scenario{Name: "drive", Description: "Drive 文件上传、分页、下载和清理。", Run: func(ctx context.Context, r *live.Run) error { return run(ctx, r, s) }}))
+}
+
+func run(ctx context.Context, r *live.Run, s *forwardutil.Example) error {
+ id, err := s.Identity(ctx, r)
+ if err != nil {
+ return err
+ }
+ path := "sdk-example/说明.txt"
+ r.Track("drive directory", "sdk-example", func(ctx context.Context) error {
+ _, err := s.Client.Drives.DeleteEntry(ctx, forward.DriveDeleteEntryParams{IdentityID: id, Path: "sdk-example", Recursive: forward.Bool(true)})
+ return err
+ })
+ // Independent storage client: do not copy QCA authentication or middleware.
+ storage := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ defer storage.CloseIdleConnections()
+ transfer := func(method, url string, headers map[string]string, body io.Reader) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, method, url, body)
+ if err != nil {
+ return nil, fmt.Errorf("invalid storage request")
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ res, err := storage.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("storage transfer failed")
+ }
+ defer res.Body.Close()
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ return nil, fmt.Errorf("storage HTTP %d", res.StatusCode)
+ }
+ return io.ReadAll(io.LimitReader(res.Body, 1024))
+ }
+ r.Step("申请签名 URL,上传示例文件")
+ upload, err := s.Client.Drives.CreateUploadURL(ctx, forward.DriveCreateUploadURLParams{IdentityID: id, Path: path, ContentType: forward.String("text/plain")})
+ if err != nil {
+ return err
+ }
+ if _, err = transfer(upload.Method, upload.URL, upload.Headers, strings.NewReader("Hello from QCA Drive")); err != nil {
+ return err
+ }
+ r.Step("使用 Drive 游标列出目录")
+ params := forward.DriveListEntriesParams{IdentityID: id, Path: forward.String("sdk-example"), Limit: forward.Int(20)}
+ for {
+ page, err := s.Client.Drives.ListEntries(ctx, params)
+ if err != nil {
+ return err
+ }
+ for _, entry := range page.Entries {
+ r.Log("drive_entry", entry.Type+": "+entry.Path)
+ }
+ if page.NextPageToken == "" {
+ break
+ }
+ params.PageToken = forward.String(page.NextPageToken)
+ }
+ r.Step("下载文件,然后删除示例条目")
+ download, err := s.Client.Drives.CreateDownloadURL(ctx, forward.DriveCreateDownloadURLParams{IdentityID: id, Path: path})
+ if err != nil {
+ return err
+ }
+ data, err := transfer(download.Method, download.URL, download.Headers, nil)
+ if err != nil {
+ return err
+ }
+ r.Log("download", string(data))
+ deleted, err := s.Client.Drives.DeleteEntry(ctx, forward.DriveDeleteEntryParams{IdentityID: id, Path: path})
+ if err != nil {
+ return err
+ }
+ r.Log("deleted_count", fmt.Sprint(deleted.DeletedCount))
+ return nil
+}
diff --git a/forward/client.go b/forward/client.go
index 28a9650..b0138d5 100644
--- a/forward/client.go
+++ b/forward/client.go
@@ -22,6 +22,7 @@ type Client struct {
Channels ChannelService
ChannelPairings ChannelPairingService
Environments EnvironmentService
+ Drives DriveService
Files FileService
Skills SkillService
Vaults VaultService
@@ -53,6 +54,7 @@ func NewClient(opts ...option.RequestOption) Client {
Channels: NewChannelService(opts...),
ChannelPairings: NewChannelPairingService(opts...),
Environments: NewEnvironmentService(opts...),
+ Drives: NewDriveService(opts...),
Files: NewFileService(opts...),
Skills: NewSkillService(opts...),
Vaults: NewVaultService(opts...),
diff --git a/forward/client_test.go b/forward/client_test.go
index ea67f71..67e33ee 100644
--- a/forward/client_test.go
+++ b/forward/client_test.go
@@ -34,8 +34,8 @@ func TestForwardAPIInventory(t *testing.T) {
}
}
walk(root)
- if len(actual) != 118 {
- t.Fatalf("HTTP methods: %d want 118", len(actual))
+ if len(actual) != 123 {
+ t.Fatalf("HTTP methods: %d want 123", len(actual))
}
baseline := map[string]bool{}
for _, c := range operations(t) {
diff --git a/forward/configuration_types.go b/forward/configuration_types.go
index 2c8c78f..a0f7781 100644
--- a/forward/configuration_types.go
+++ b/forward/configuration_types.go
@@ -596,3 +596,30 @@ func (r SessionEventParam) MarshalJSON() ([]byte, error) {
return param.MarshalObject(r, (*shadow)(&r))
}
func (r *SessionEventParam) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
+
+type ManagedToolConfig struct {
+ EnabledTools []string `json:"enabled_tools"`
+ JSON struct {
+ EnabledTools respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r ManagedToolConfig) RawJSON() string { return r.JSON.raw }
+func (r *ManagedToolConfig) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
+
+// ManagedToolConfigParam replaces the complete enabled tool list. Include other
+// tools you want to keep. An empty list disables all managed tools.
+type ManagedToolConfigParam struct {
+ EnabledTools []string `json:"enabled_tools" api:"required"`
+ paramObj
+}
+
+func (r ManagedToolConfigParam) MarshalJSON() ([]byte, error) {
+ type shadow ManagedToolConfigParam
+ return param.MarshalObject(r, (*shadow)(&r))
+}
+func (r *ManagedToolConfigParam) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
diff --git a/forward/drive.go b/forward/drive.go
new file mode 100644
index 0000000..9c98c9f
--- /dev/null
+++ b/forward/drive.go
@@ -0,0 +1,260 @@
+package forward
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apijson"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apiquery"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/respjson"
+)
+
+// DriveService manages persistent Identity files. Drive is a Beta API.
+// Every method requires IdentityID, including when using an Identity SAT.
+type DriveService struct{ Options []option.RequestOption }
+
+func NewDriveService(opts ...option.RequestOption) DriveService {
+ return DriveService{Options: slices.Clone(opts)}
+}
+
+// ListEntries returns one directory page in service order. Pass NextPageToken
+// unchanged as PageToken to continue within the same Identity and directory.
+func (r *DriveService) ListEntries(ctx context.Context, params DriveListEntriesParams, opts ...option.RequestOption) (res *DriveListEntriesResponse, err error) {
+ cfg, err := convention.NewRequestConfig(ctx, http.MethodGet, "drives/entries", params, &res, slices.Concat(r.Options, opts)...)
+ if err != nil {
+ return nil, err
+ }
+ // Drive requires literal slashes in the path query, after request options
+ // have been applied. Other values (especially cursors) stay encoded.
+ fields := strings.Split(cfg.Request.URL.RawQuery, "&")
+ for i, field := range fields {
+ key, value, _ := strings.Cut(field, "=")
+ if key != "path" {
+ continue
+ }
+ decoded, decodeErr := url.QueryUnescape(value)
+ if decodeErr != nil {
+ return nil, decodeErr
+ }
+ fields[i] = "path=" + strings.ReplaceAll(url.QueryEscape(decoded), "%2F", "/")
+ }
+ cfg.Request.URL.RawQuery = strings.Join(fields, "&")
+ err = cfg.Execute()
+ return res, err
+}
+
+// CreateUploadURL returns a short-lived PUT URL. Use only its returned headers for the storage request. Replaying an Idempotency-Key does not renew expiry.
+func (r *DriveService) CreateUploadURL(ctx context.Context, params DriveCreateUploadURLParams, opts ...option.RequestOption) (res *DriveUploadURL, err error) {
+ err = convention.ExecuteNewRequest(ctx, http.MethodPost, "drives/upload-url", params, &res, slices.Concat(r.Options, opts)...)
+ return res, err
+}
+
+// CreateDownloadURL returns a short-lived GET URL. Use a new Idempotency-Key or omit it to refresh an expired URL.
+func (r *DriveService) CreateDownloadURL(ctx context.Context, params DriveCreateDownloadURLParams, opts ...option.RequestOption) (res *DriveDownloadURL, err error) {
+ err = convention.ExecuteNewRequest(ctx, http.MethodPost, "drives/download-url", params, &res, slices.Concat(r.Options, opts)...)
+ return res, err
+}
+
+// DeleteEntry deletes a file or directory. Recursive must be true for a nonempty directory. Partial failures retain deleted_count in the API error raw body.
+func (r *DriveService) DeleteEntry(ctx context.Context, params DriveDeleteEntryParams, opts ...option.RequestOption) (res *DriveDeleteEntryResponse, err error) {
+ err = convention.ExecuteNewRequest(ctx, http.MethodDelete, "drives/entries", params, &res, slices.Concat(r.Options, opts)...)
+ return res, err
+}
+
+// Clear clears the selected Identity Drive. Use only with an Identity whose files you intend to delete.
+func (r *DriveService) Clear(ctx context.Context, params DriveClearParams, opts ...option.RequestOption) (res *DriveClearResponse, err error) {
+ err = convention.ExecuteNewRequest(ctx, http.MethodDelete, "drives", params, &res, slices.Concat(r.Options, opts)...)
+ return res, err
+}
+
+type DriveListEntriesParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ Path param.Opt[string] `query:"path,omitzero" json:"-"`
+ Limit param.Opt[int64] `query:"limit,omitzero" json:"-"`
+ PageToken param.Opt[string] `query:"page_token,omitzero" json:"-"`
+ paramObj
+}
+
+func (r *DriveListEntriesParams) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+func (r DriveListEntriesParams) URLQuery() (url.Values, error) {
+ return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
+}
+
+type DriveCreateUploadURLParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ Path string `json:"path" api:"required"`
+ ContentType param.Opt[string] `json:"content_type,omitzero"`
+ paramObj
+}
+
+func (r DriveCreateUploadURLParams) MarshalJSON() ([]byte, error) {
+ type shadow DriveCreateUploadURLParams
+ return param.MarshalObject(r, (*shadow)(&r))
+}
+func (r *DriveCreateUploadURLParams) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+func (r DriveCreateUploadURLParams) URLQuery() (url.Values, error) {
+ return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
+}
+
+type DriveCreateDownloadURLParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ Path string `json:"path" api:"required"`
+ paramObj
+}
+
+func (r DriveCreateDownloadURLParams) MarshalJSON() ([]byte, error) {
+ type shadow DriveCreateDownloadURLParams
+ return param.MarshalObject(r, (*shadow)(&r))
+}
+func (r *DriveCreateDownloadURLParams) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+func (r DriveCreateDownloadURLParams) URLQuery() (url.Values, error) {
+ return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
+}
+
+type DriveDeleteEntryParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ Path string `json:"path" api:"required"`
+ Recursive param.Opt[bool] `json:"recursive,omitzero"`
+ paramObj
+}
+
+func (r DriveDeleteEntryParams) MarshalJSON() ([]byte, error) {
+ type shadow DriveDeleteEntryParams
+ return param.MarshalObject(r, (*shadow)(&r))
+}
+func (r *DriveDeleteEntryParams) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+func (r DriveDeleteEntryParams) URLQuery() (url.Values, error) {
+ return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
+}
+
+type DriveClearParams struct {
+ IdentityID string `query:"identity_id" json:"-" api:"required"`
+ paramObj
+}
+
+func (r *DriveClearParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
+func (r DriveClearParams) URLQuery() (url.Values, error) {
+ return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
+}
+
+// DriveEntry represents a file or directory. JSON.Size.Valid() distinguishes a zero-byte file from a directory.
+type DriveEntry struct {
+ Path string `json:"path"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Size int64 `json:"size"`
+ ETag string `json:"etag"`
+ LastModified time.Time `json:"last_modified" format:"date-time"`
+ JSON struct {
+ Path respjson.Field
+ Name respjson.Field
+ Type respjson.Field
+ Size respjson.Field
+ ETag respjson.Field
+ LastModified respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r DriveEntry) RawJSON() string { return r.JSON.raw }
+func (r *DriveEntry) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
+
+type DriveListEntriesResponse struct {
+ Entries []DriveEntry `json:"entries"`
+ NextPageToken string `json:"next_page_token"`
+ JSON struct {
+ Entries respjson.Field
+ NextPageToken respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r DriveListEntriesResponse) RawJSON() string { return r.JSON.raw }
+func (r *DriveListEntriesResponse) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+type DriveUploadURL struct {
+ URL string `json:"url"`
+ Method string `json:"method"`
+ Headers map[string]string `json:"headers"`
+ ExpiresAt time.Time `json:"expires_at" format:"date-time"`
+ JSON struct {
+ URL respjson.Field
+ Method respjson.Field
+ Headers respjson.Field
+ ExpiresAt respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r DriveUploadURL) RawJSON() string { return r.JSON.raw }
+func (r *DriveUploadURL) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
+
+type DriveDownloadURL struct {
+ URL string `json:"url"`
+ Method string `json:"method"`
+ Headers map[string]string `json:"headers"`
+ ExpiresAt time.Time `json:"expires_at" format:"date-time"`
+ Size int64 `json:"size"`
+ ETag string `json:"etag"`
+ ContentType string `json:"content_type"`
+ JSON struct {
+ URL respjson.Field
+ Method respjson.Field
+ Headers respjson.Field
+ ExpiresAt respjson.Field
+ Size respjson.Field
+ ETag respjson.Field
+ ContentType respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r DriveDownloadURL) RawJSON() string { return r.JSON.raw }
+func (r *DriveDownloadURL) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
+
+type DriveDeleteEntryResponse struct {
+ DeletedCount int64 `json:"deleted_count"`
+ JSON struct {
+ DeletedCount respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r DriveDeleteEntryResponse) RawJSON() string { return r.JSON.raw }
+func (r *DriveDeleteEntryResponse) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+type DriveClearResponse struct {
+ DeletedCount int64 `json:"deleted_count"`
+ JSON struct {
+ DeletedCount respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r DriveClearResponse) RawJSON() string { return r.JSON.raw }
+func (r *DriveClearResponse) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
diff --git a/forward/drive_test.go b/forward/drive_test.go
new file mode 100644
index 0000000..c728e88
--- /dev/null
+++ b/forward/drive_test.go
@@ -0,0 +1,193 @@
+package forward_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apierror"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
+)
+
+func TestDriveAPIContracts(t *testing.T) {
+ for _, status := range []int{200, 400} {
+ t.Run(statusName(status), func(t *testing.T) {
+ ctx := context.Background()
+ client0 := contractClient(t, "listDriveEntries", status)
+ a, err := client0.Drives.ListEntries(ctx, contractParams[forward.DriveListEntriesParams](t, "listDriveEntries"))
+ checkError(t, status, err)
+ if status == 200 {
+ checkDecoded(t, a)
+ }
+ client1 := contractClient(t, "createDriveUploadURL", status)
+ b, err := client1.Drives.CreateUploadURL(ctx, contractParams[forward.DriveCreateUploadURLParams](t, "createDriveUploadURL"))
+ checkError(t, status, err)
+ if status == 200 {
+ checkDecoded(t, b)
+ }
+ client2 := contractClient(t, "createDriveDownloadURL", status)
+ c, err := client2.Drives.CreateDownloadURL(ctx, contractParams[forward.DriveCreateDownloadURLParams](t, "createDriveDownloadURL"))
+ checkError(t, status, err)
+ if status == 200 {
+ checkDecoded(t, c)
+ }
+ client3 := contractClient(t, "deleteDriveEntry", status)
+ d, err := client3.Drives.DeleteEntry(ctx, contractParams[forward.DriveDeleteEntryParams](t, "deleteDriveEntry"))
+ checkError(t, status, err)
+ if status == 200 {
+ checkDecoded(t, d)
+ }
+ client4 := contractClient(t, "clearDrive", status)
+ e, err := client4.Drives.Clear(ctx, contractParams[forward.DriveClearParams](t, "clearDrive"))
+ checkError(t, status, err)
+ if status == 200 {
+ checkDecoded(t, e)
+ }
+ })
+ }
+}
+
+func TestDriveEncodingAndPaginationPreserveScopeAndOrder(t *testing.T) {
+ cursor := "opaque/+%=&"
+ calls := 0
+ client := testClient(func(r *http.Request) (*http.Response, error) {
+ calls++
+ if r.URL.Query().Get("identity_id") != "idn_test" || r.URL.Query().Get("path") != "目录/a b+~*" || r.URL.Query().Get("extra") != "a/b" {
+ t.Fatalf("query: %s", r.URL.RawQuery)
+ }
+ if !strings.Contains(r.URL.RawQuery, "path=%E7%9B%AE%E5%BD%95/a+b%2B~%2A") || !strings.Contains(r.URL.RawQuery, "extra=a%2Fb") {
+ t.Fatalf("wire encoding: %s", r.URL.RawQuery)
+ }
+ if calls == 1 {
+ return reply(r, 200, `{"entries":[{"path":"a.txt","name":"a.txt","type":"file","size":0},{"path":"a","name":"a","type":"directory"}],"next_page_token":"opaque/+%=&"}`), nil
+ }
+ if r.URL.Query().Get("page_token") != cursor {
+ t.Fatal("cursor modified")
+ }
+ return reply(r, 200, `{"entries":[]}`), nil
+ }, option.WithAuthMode("sa"), option.WithAuthToken("test-sat"))
+ p := forward.DriveListEntriesParams{IdentityID: "idn_test", Path: forward.String("replaced"), Limit: forward.Int(2)}
+ opts := []option.RequestOption{option.WithQuery("path", "目录/a b+~*"), option.WithQuery("extra", "a/b")}
+ first, err := client.Drives.ListEntries(context.Background(), p, opts...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.Entries[0].Path != "a.txt" || first.Entries[1].Path != "a" {
+ t.Fatal("service order changed")
+ }
+ if !first.Entries[0].JSON.Size.Valid() || first.Entries[1].JSON.Size.Valid() {
+ t.Fatal("zero-byte file and directory indistinguishable")
+ }
+ p.PageToken = forward.String(first.NextPageToken)
+ last, err := client.Drives.ListEntries(context.Background(), p, opts...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if last.NextPageToken != "" || len(last.Entries) != 0 || calls != 2 {
+ t.Fatal("unexpected final page")
+ }
+}
+
+func TestDriveClearHasNoBodyAndPartialDeletionIsNotRetried(t *testing.T) {
+ calls := 0
+ client := testClient(func(r *http.Request) (*http.Response, error) {
+ calls++
+ if r.Method != "DELETE" || r.URL.Path != "/api/v1/forward/drives" || r.URL.Query().Get("identity_id") != "idn_test" {
+ t.Fatalf("request: %s %s", r.Method, r.URL)
+ }
+ if r.Body != nil {
+ b, _ := io.ReadAll(r.Body)
+ if len(b) != 0 {
+ t.Fatalf("unexpected body %s", b)
+ }
+ }
+ return reply(r, 503, `{"request_id":"req_partial","error":{"type":"api_error","code":"drive_delete_partial","deleted_count":7,"message":"partial"}}`), nil
+ }, option.WithMaxRetries(2))
+ _, err := client.Drives.Clear(context.Background(), forward.DriveClearParams{IdentityID: "idn_test"})
+ var api *apierror.Error
+ if !errors.As(err, &api) || api.Code != "drive_delete_partial" || api.RequestID != "req_partial" || !strings.Contains(api.RawJSON(), `"deleted_count":7`) || calls != 1 {
+ t.Fatalf("partial error: %v calls=%d", err, calls)
+ }
+}
+
+func TestTemplateManagedToolConfigPresence(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ config forward.ManagedToolConfigParam
+ expected string
+ }{
+ {"omitted", forward.ManagedToolConfigParam{}, "{}"},
+ {"enabled", forward.ManagedToolConfigParam{EnabledTools: []string{"drive", "schedule"}}, `{"managed_tool_config":{"enabled_tools":["drive","schedule"]}}`},
+ {"empty", forward.ManagedToolConfigParam{EnabledTools: []string{}}, `{"managed_tool_config":{"enabled_tools":[]}}`},
+ {"null", param.NullStruct[forward.ManagedToolConfigParam](), `{"managed_tool_config":null}`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ client := testClient(func(r *http.Request) (*http.Response, error) {
+ var actual, expected any
+ if err := json.NewDecoder(r.Body).Decode(&actual); err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal([]byte(tc.expected), &expected); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(actual, expected) {
+ t.Fatalf("body=%v expected=%v", actual, expected)
+ }
+ return reply(r, 200, `{"managed_tool_config":{"enabled_tools":["drive","schedule"]}}`), nil
+ })
+ result, err := client.Templates.Update(context.Background(), "tmpl_test", forward.TemplateUpdateParams{ManagedToolConfig: tc.config})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(result.ManagedToolConfig.EnabledTools, []string{"drive", "schedule"}) {
+ t.Fatal("typed response missing")
+ }
+ })
+ }
+}
+
+func TestDrivePreservesRejectedPathsAndLimits(t *testing.T) {
+ for _, tc := range []struct {
+ path string
+ limit int64
+ ok bool
+ }{
+ {"", 1, true}, {"", 100, true}, {"/absolute", 20, false}, {"../other", 20, false}, {`a\b`, 20, false}, {"a%2Fb", 20, false}, {" a ", 20, false}, {"", 0, false}, {"", 101, false},
+ } {
+ client := testClient(func(r *http.Request) (*http.Response, error) {
+ q := r.URL.Query()
+ if q.Get("path") != tc.path || q.Get("limit") != fmt.Sprint(tc.limit) || len(q["identity_id"]) != 1 {
+ t.Fatalf("changed query %s", r.URL.RawQuery)
+ }
+ if r.Body != nil {
+ body, _ := io.ReadAll(r.Body)
+ if len(body) > 0 {
+ t.Fatal("GET body")
+ }
+ }
+ if tc.ok {
+ return reply(r, 200, `{"entries":[]}`), nil
+ }
+ return reply(r, 400, `{"error":{"code":"invalid_argument","message":"invalid Drive query"}}`), nil
+ })
+ _, err := client.Drives.ListEntries(context.Background(), forward.DriveListEntriesParams{IdentityID: "idn_test", Path: forward.String(tc.path), Limit: forward.Int(tc.limit)})
+ if tc.ok {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ var api *apierror.Error
+ if !errors.As(err, &api) || api.StatusCode != 400 || api.Code != "invalid_argument" {
+ t.Fatalf("wrong error: %v", err)
+ }
+ }
+ }
+}
diff --git a/forward/template.go b/forward/template.go
index 7efaa17..388d562 100644
--- a/forward/template.go
+++ b/forward/template.go
@@ -78,6 +78,8 @@ func (r *TemplateService) New(ctx context.Context, params TemplateNewParams, opt
}
type TemplateNewParams struct {
+ // Replaces the complete managed tool list; use an empty list to disable all.
+ ManagedToolConfig ManagedToolConfigParam `json:"managed_tool_config,omitzero" api:"nullable"`
// Template name, 1-256 characters, unique per user.
Name string `json:"name" api:"required"`
// Model identifier. Accepts a string (such as `"ultimate"`), or an Agent model
@@ -153,6 +155,8 @@ func (r *TemplateService) Update(ctx context.Context, templateID string, params
}
type TemplateUpdateParams struct {
+ // Replaces the complete managed tool list; use an empty list to disable all.
+ ManagedToolConfig ManagedToolConfigParam `json:"managed_tool_config,omitzero" api:"nullable"`
// New Template name.
Name param.Opt[string] `json:"name,omitzero"`
// New Template description.
@@ -257,6 +261,7 @@ func (r TemplateCloneParams) MarshalJSON() ([]byte, error) {
func (r *TemplateCloneParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
type Template struct {
+ ManagedToolConfig ManagedToolConfig `json:"managed_tool_config"`
// Always `template`.
Type string `json:"type"`
// Template ID.
@@ -285,6 +290,7 @@ type Template struct {
// Custom metadata.
Metadata map[string]any `json:"metadata"`
JSON struct {
+ ManagedToolConfig respjson.Field
Type respjson.Field
ID respjson.Field
Name respjson.Field
diff --git a/forward/test_helpers_test.go b/forward/test_helpers_test.go
index f4835e4..9e2a66a 100644
--- a/forward/test_helpers_test.go
+++ b/forward/test_helpers_test.go
@@ -86,8 +86,8 @@ func operations(t *testing.T) []operationCase {
Cases []operationCase `json:"cases"`
}
readJSON(t, "testdata/api-operation-cases.json", &fixture)
- if fixture.Count != 118 || len(fixture.Cases) != 118 {
- t.Fatal("Forward API scope must contain 118 operations")
+ if fixture.Count != 123 || len(fixture.Cases) != 123 {
+ t.Fatal("Forward API scope must contain 123 operations")
}
return fixture.Cases
}
diff --git a/forward/testdata/api-contracts.json b/forward/testdata/api-contracts.json
index 03196d7..a84cd9a 100644
--- a/forward/testdata/api-contracts.json
+++ b/forward/testdata/api-contracts.json
@@ -80,7 +80,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"
+ ]
+ }
},
"stream": false,
"download": false,
@@ -121,7 +127,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"
+ ]
+ }
},
"stream": false,
"download": false,
@@ -186,7 +198,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"
+ ]
+ }
},
"stream": false,
"download": false,
@@ -3372,5 +3390,94 @@
"download": false,
"raw": false,
"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,
+ "service": "DriveService",
+ "name": "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,
+ "service": "DriveService",
+ "name": "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,
+ "service": "DriveService",
+ "name": "CreateDownloadURL"
+ },
+ {
+ "operation_id": "deleteDriveEntry",
+ "entry": "Drives",
+ "response": {
+ "deleted_count": 2
+ },
+ "empty": false,
+ "stream": false,
+ "download": false,
+ "raw": false,
+ "service": "DriveService",
+ "name": "DeleteEntry"
+ },
+ {
+ "operation_id": "clearDrive",
+ "entry": "Drives",
+ "response": {
+ "deleted_count": 0
+ },
+ "empty": false,
+ "stream": false,
+ "download": false,
+ "raw": false,
+ "service": "DriveService",
+ "name": "Clear"
}
]
diff --git a/forward/testdata/api-operation-cases.json b/forward/testdata/api-operation-cases.json
index dc72c97..afd7bfa 100644
--- a/forward/testdata/api-operation-cases.json
+++ b/forward/testdata/api-operation-cases.json
@@ -1,6 +1,6 @@
{
"schema_version": 1,
- "operation_count": 118,
+ "operation_count": 123,
"cases": [
{
"operation_id": "addSessionResource",
@@ -884,7 +884,13 @@
"value": {
"name": "sdk-contract",
"model": "ultimate",
- "environment_id": "env_contract"
+ "environment_id": "env_contract",
+ "managed_tool_config": {
+ "enabled_tools": [
+ "drive",
+ "schedule"
+ ]
+ }
}
}
},
@@ -4646,7 +4652,14 @@
"python_name": "update_template_request",
"typescript_name": "updateTemplateRequest",
"go_name": "UpdateTemplateRequest",
- "value": {}
+ "value": {
+ "managed_tool_config": {
+ "enabled_tools": [
+ "drive",
+ "schedule"
+ ]
+ }
+ }
}
},
{
@@ -5055,6 +5068,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/integration/drive_live_test.go b/integration/drive_live_test.go
new file mode 100644
index 0000000..84912d0
--- /dev/null
+++ b/integration/drive_live_test.go
@@ -0,0 +1,224 @@
+//go:build live
+
+package integration_test
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "os"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/internal/testsupport"
+)
+
+// TestDriveLive only clears newly created Identities; no model execution is needed.
+func TestDriveLive(t *testing.T) {
+ environment := map[string]string{}
+ for _, entry := range os.Environ() {
+ key, value, _ := strings.Cut(entry, "=")
+ environment[key] = value
+ }
+ values, err := liveEnvironment(os.Getenv("LIVE_ENV_FILE"), environment)
+ saCheck(t, err)
+ if values["QODER_DRIVE_LIVE_ALLOW_WRITE"] != "true" {
+ t.Fatal("QODER_DRIVE_LIVE_ALLOW_WRITE=true is required")
+ }
+ for _, region := range []string{"cn", "global"} {
+ for _, auth := range []string{"pat", "admin_sat", "identity_sat"} {
+ t.Run(region+"/"+auth, func(t *testing.T) {
+ host := "https://api.qoder.com"
+ if region == "cn" {
+ host = "https://api.qoder.com.cn"
+ }
+ opts := []option.RequestOption{option.WithBaseURL(host + "/api/v1/forward/"), option.WithMaxRetries(0), option.WithRequestTimeout(30 * time.Second), testsupport.RequestLog(t)}
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
+ defer cancel()
+ issuer := forward.NewClient(slices.Concat(opts, []option.RequestOption{option.WithAuthMode("sa")})...)
+ key := values["QODER_SA_"+strings.ToUpper(region)+"_KEY"]
+ issue := func(identityID string) forward.Client {
+ if key == "" {
+ t.Fatal("region SA key is required")
+ }
+ params := forward.ServiceAccountTokenNewParams{SAKey: key, TTLSeconds: forward.Int(600)}
+ var token *forward.ServiceAccountToken
+ var err error
+ if identityID == "" {
+ token, err = issuer.ServiceAccountTokens.New(ctx, params)
+ } else {
+ token, err = issuer.Identities.ServiceAccountTokens.New(ctx, identityID, params)
+ }
+ saCheck(t, err)
+ saCleanup(t, "SAT "+token.AuthTokenID, func(ctx context.Context) error {
+ _, err := issuer.ServiceAccountTokens.Revoke(ctx, token.AuthTokenID, forward.ServiceAccountTokenRevokeParams{SAKey: key, TargetCredentialID: forward.String(token.CredentialID), TargetExpiresAt: param.NewOpt(token.ExpiresAt)})
+ return err
+ })
+ return forward.NewClient(slices.Concat(opts, []option.RequestOption{option.WithAuthMode("sa"), option.WithAuthToken(token.AccessToken)})...)
+ }
+ var admin forward.Client
+ if auth == "pat" {
+ pat := values["QODER_FORWARD_"+strings.ToUpper(region)+"_PAT"]
+ if pat == "" {
+ t.Fatal("region PAT is required")
+ }
+ admin = forward.NewClient(slices.Concat(opts, []option.RequestOption{option.WithAuthMode("pat"), option.WithPAT(pat)})...)
+ } else {
+ admin = issue("")
+ }
+ identity, err := admin.Identities.New(ctx, forward.IdentityNewParams{ExternalID: "sdk-drive-" + testsupport.Marker(t)})
+ saCheck(t, err)
+ id := identity.ID
+ saCleanup(t, "identity "+id, func(ctx context.Context) error { _, err := admin.Identities.Delete(ctx, id); return err })
+ saCleanup(t, "isolated Drive "+id, func(ctx context.Context) error {
+ _, err := admin.Drives.Clear(ctx, forward.DriveClearParams{IdentityID: id})
+ return err
+ })
+ runtime := admin
+ if auth == "identity_sat" {
+ runtime = issue(id)
+ }
+ page, err := runtime.Drives.ListEntries(ctx, forward.DriveListEntriesParams{IdentityID: id})
+ saCheck(t, err)
+ if len(page.Entries) != 0 {
+ t.Fatal("new Drive is not empty")
+ }
+ paths := []string{"sdk 目录/a+b.txt", "sdk 目录/zero.txt", "sdk 目录/nested/child.txt"}
+ content := "SDK Drive verification"
+ storage := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ defer storage.CloseIdleConnections()
+ transfer := func(method, url string, headers map[string]string, body io.Reader) []byte {
+ req, err := http.NewRequestWithContext(ctx, method, url, body)
+ if err != nil {
+ t.Fatal("invalid storage request")
+ }
+ for k, v := range headers {
+ if strings.EqualFold(k, "Authorization") {
+ t.Fatal("unexpected storage Authorization")
+ }
+ req.Header.Set(k, v)
+ }
+ response, err := storage.Do(req)
+ if err != nil {
+ t.Fatal("storage transport failed")
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ t.Fatalf("storage HTTP %d", response.StatusCode)
+ }
+ data, err := io.ReadAll(io.LimitReader(response.Body, 1024))
+ saCheck(t, err)
+ return data
+ }
+ for _, path := range paths {
+ signed, err := runtime.Drives.CreateUploadURL(ctx, forward.DriveCreateUploadURLParams{IdentityID: id, Path: path, ContentType: forward.String("text/plain")})
+ saCheck(t, err)
+ if signed.Method != "PUT" {
+ t.Fatal("upload method")
+ }
+ body := content
+ if strings.Contains(path, "zero") {
+ body = ""
+ }
+ transfer(signed.Method, signed.URL, signed.Headers, strings.NewReader(body))
+ }
+ var entries []forward.DriveEntry
+ cursor := ""
+ seen := map[string]bool{}
+ for {
+ params := forward.DriveListEntriesParams{IdentityID: id, Path: forward.String("sdk 目录"), Limit: forward.Int(1)}
+ if cursor != "" {
+ params.PageToken = forward.String(cursor)
+ }
+ page, err := runtime.Drives.ListEntries(ctx, params)
+ saCheck(t, err)
+ entries = append(entries, page.Entries...)
+ cursor = page.NextPageToken
+ if cursor == "" {
+ break
+ }
+ if seen[cursor] || len(seen) >= 10 {
+ t.Fatal("pagination did not finish")
+ }
+ seen[cursor] = true
+ }
+ if len(entries) != 3 {
+ t.Fatalf("directory has %d entries", len(entries))
+ }
+ found := map[string]bool{}
+ for _, entry := range entries {
+ found[entry.Path] = true
+ if entry.Path == paths[1] && (!entry.JSON.Size.Valid() || entry.Size != 0) {
+ t.Fatal("zero-byte file size")
+ }
+ if entry.Type == "directory" && entry.JSON.Size.Valid() {
+ t.Fatal("directory has file size")
+ }
+ }
+ for _, path := range []string{paths[0], paths[1], "sdk 目录/nested"} {
+ if !found[path] {
+ t.Fatal("missing directory entry")
+ }
+ }
+ signed, err := runtime.Drives.CreateDownloadURL(ctx, forward.DriveCreateDownloadURLParams{IdentityID: id, Path: paths[0]})
+ saCheck(t, err)
+ if signed.Method != "GET" || string(transfer(signed.Method, signed.URL, signed.Headers, nil)) != content {
+ t.Fatal("download differs")
+ }
+ _, err = runtime.Drives.DeleteEntry(ctx, forward.DriveDeleteEntryParams{IdentityID: id, Path: "sdk 目录"})
+ saTokenDenied(t, err, 409)
+ for _, count := range []int64{1, 0} {
+ result, err := runtime.Drives.DeleteEntry(ctx, forward.DriveDeleteEntryParams{IdentityID: id, Path: paths[0]})
+ saCheck(t, err)
+ if result.DeletedCount != count {
+ t.Fatal("delete count")
+ }
+ }
+ deleted, err := runtime.Drives.DeleteEntry(ctx, forward.DriveDeleteEntryParams{IdentityID: id, Path: "sdk 目录/nested", Recursive: forward.Bool(true)})
+ saCheck(t, err)
+ if deleted.DeletedCount != 1 {
+ t.Fatal("recursive count")
+ }
+ for _, count := range []int64{1, 0} {
+ result, err := runtime.Drives.Clear(ctx, forward.DriveClearParams{IdentityID: id})
+ saCheck(t, err)
+ if result.DeletedCount != count {
+ t.Fatal("clear count")
+ }
+ }
+ if auth == "identity_sat" {
+ other, err := admin.Identities.New(ctx, forward.IdentityNewParams{ExternalID: "sdk-drive-other-" + testsupport.Marker(t)})
+ saCheck(t, err)
+ saCleanup(t, "other identity "+other.ID, func(ctx context.Context) error { _, err := admin.Identities.Delete(ctx, other.ID); return err })
+ _, err = runtime.Drives.ListEntries(ctx, forward.DriveListEntriesParams{IdentityID: other.ID})
+ saTokenDenied(t, err, 403)
+ } else {
+ env, err := admin.Environments.New(ctx, forward.EnvironmentNewParams{Name: "sdk-drive-" + testsupport.Marker(t), Config: map[string]any{"type": "cloud"}})
+ saCheck(t, err)
+ saCleanup(t, "environment "+env.ID, func(ctx context.Context) error { _, err := admin.Environments.Archive(ctx, env.ID); return err })
+ template, err := admin.Templates.New(ctx, forward.TemplateNewParams{Name: "sdk-drive-" + testsupport.Marker(t), Model: forward.ModelConfigUnionParam{OfString: forward.String("auto")}, EnvironmentID: env.ID, ManagedToolConfig: forward.ManagedToolConfigParam{EnabledTools: []string{"drive"}}})
+ saCheck(t, err)
+ saCleanup(t, "template "+template.ID, func(ctx context.Context) error {
+ _, err := admin.Templates.Archive(ctx, template.ID, forward.TemplateArchiveParams{})
+ return err
+ })
+ current, err := admin.Templates.Get(ctx, template.ID)
+ saCheck(t, err)
+ if !slices.Equal(current.ManagedToolConfig.EnabledTools, []string{"drive"}) {
+ t.Fatal("template config readback")
+ }
+ current, err = admin.Templates.Update(ctx, template.ID, forward.TemplateUpdateParams{ManagedToolConfig: forward.ManagedToolConfigParam{EnabledTools: []string{}}})
+ saCheck(t, err)
+ if len(current.ManagedToolConfig.EnabledTools) != 0 {
+ t.Fatal("template disable")
+ }
+ }
+ })
+ }
+ }
+}