@zackbart/connecta 0.34.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +418 -0
- package/README.md +65 -105
- package/SECURITY.md +3 -2
- package/bin/connecta.mjs +41 -16
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +2 -1
- package/dist/access-tokens.js +66 -30
- package/dist/activity.d.ts +1 -1
- package/dist/activity.js +2 -0
- package/dist/agent-config.d.ts +23 -0
- package/dist/agent-config.js +59 -0
- package/dist/agent-description.js +16 -0
- package/dist/agent-identity.js +4 -4
- package/dist/agent-observation.js +39 -0
- package/dist/auth/clerk-sdk/client.d.ts +2 -0
- package/dist/auth/clerk-sdk/generated.js +554 -563
- package/dist/auth/clerk-transport.js +70 -3
- package/dist/auth/clerk.d.ts +13 -0
- package/dist/auth/clerk.js +653 -246
- package/dist/auth/cloudflare-access.js +46 -42
- package/dist/auth/downstream-client-metadata.js +2 -2
- package/dist/auth/downstream-oauth.js +2044 -1161
- package/dist/auth/downstream-revocation.js +88 -88
- package/dist/auth/native-call.js +20 -0
- package/dist/auth/native.js +184 -0
- package/dist/auth/oauth-start-reset.js +11 -5
- package/dist/auth/oauth-token-response.js +121 -102
- package/dist/auth/oauth-v2-migration.js +97 -78
- package/dist/auth/renewable-grant.js +10 -0
- package/dist/auth/static-oauth.js +751 -281
- package/dist/auth/vercel-agent.js +266 -0
- package/dist/auth-admission.js +204 -0
- package/dist/auth-elicitation.js +350 -241
- package/dist/authorization-handoff.js +164 -90
- package/dist/call-admission-context.js +3 -10
- package/dist/call-admission.js +2 -0
- package/dist/call-classification.js +9 -0
- package/dist/call-recovery.js +32 -13
- package/dist/canonical-json.js +8 -0
- package/dist/catalog-cache.js +369 -213
- package/dist/catalog-service.js +659 -530
- package/dist/catalog.js +5 -1
- package/dist/concurrency.js +0 -25
- package/dist/config-defaults.d.ts +1 -0
- package/dist/config-defaults.js +1 -0
- package/dist/config-schema.d.ts +3 -1
- package/dist/config-schema.js +13 -3
- package/dist/config.d.ts +40 -6
- package/dist/config.js +40 -26
- package/dist/connector-caller.d.ts +4 -0
- package/dist/connector-caller.js +17 -4
- package/dist/connectors/api-connector.d.ts +29 -7
- package/dist/connectors/api-connector.js +145 -57
- package/dist/connectors/guarded-fetch.d.ts +51 -17
- package/dist/connectors/guarded-fetch.js +215 -121
- package/dist/connectors/negotiation-cache.js +20 -10
- package/dist/connectors/remote-mcp.d.ts +10 -30
- package/dist/connectors/remote-mcp.js +955 -563
- package/dist/d1.js +10 -243
- package/dist/derived-context.js +5 -0
- package/dist/describe-config.d.ts +3 -1
- package/dist/describe-config.js +2 -0
- package/dist/downstream-input.js +347 -260
- package/dist/errors.d.ts +87 -6
- package/dist/errors.js +114 -4
- package/dist/execute.js +87 -50
- package/dist/executor-admission.js +3 -0
- package/dist/executors/quickjs.js +163 -112
- package/dist/guarded-transport.js +50 -0
- package/dist/guest-runtime.js +1 -1
- package/dist/http/client.js +439 -0
- package/dist/http/errors.js +558 -0
- package/dist/http/fields.js +49 -0
- package/dist/http/hatch-paging.js +318 -0
- package/dist/http/hatch.js +1279 -0
- package/dist/http/json-schema.js +267 -0
- package/dist/http/node-transport.js +241 -0
- package/dist/http/oauth.js +162 -0
- package/dist/http/paging.js +227 -0
- package/dist/http/provider.js +221 -0
- package/dist/http/safety.js +82 -0
- package/dist/http/services.js +79 -0
- package/dist/http/tools.js +379 -0
- package/dist/http/vendor-index.js +138 -0
- package/dist/http/vendor.js +448 -0
- package/dist/http/write-request.js +66 -0
- package/dist/http-policy.d.ts +11 -0
- package/dist/http-policy.js +45 -0
- package/dist/inbound-credential.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -0
- package/dist/invocation-auth.js +30 -19
- package/dist/invocation.js +218 -85
- package/dist/mcp-result.js +0 -4
- package/dist/membership.d.ts +4 -0
- package/dist/meta-output.js +1 -1
- package/dist/meta-tools.js +531 -406
- package/dist/native-recovery.js +52 -0
- package/dist/native-tools.js +382 -0
- package/dist/node.d.ts +4 -3
- package/dist/node.js +45 -8
- package/dist/oauth-handoff.js +177 -104
- package/dist/oauth-partition.js +6 -20
- package/dist/oauth-sealing.js +6 -7
- package/dist/operation-vocabulary.js +101 -0
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +17 -4
- package/dist/operator-ui/contract.d.ts +8 -3
- package/dist/operator-ui/generated.js +4 -4
- package/dist/operator-ui/model.d.ts +2 -3
- package/dist/program-writes.js +47 -26
- package/dist/provider-call-facts.d.ts +115 -0
- package/dist/provider-call-facts.js +182 -0
- package/dist/provider-call.d.ts +112 -0
- package/dist/provider-call.js +271 -0
- package/dist/provider.d.ts +65 -31
- package/dist/provider.js +116 -39
- package/dist/providers/_shared/google/hatch.js +91 -0
- package/dist/providers/_shared/google/kit.js +427 -0
- package/dist/providers/_shared/google/result-size.js +15 -2
- package/dist/providers/_shared/google/service-account.d.ts +2 -1
- package/dist/providers/_shared/google/service-account.js +8 -4
- package/dist/providers/_shared/google/workspace.d.ts +6 -210
- package/dist/providers/_shared/google/workspace.js +6 -425
- package/dist/providers/_shared/rest/compact-index.js +86 -0
- package/dist/providers/_shared/rest/kit.js +146 -0
- package/dist/providers/_shared/rest/operation-index.js +230 -14
- package/dist/providers/_shared/rest/response-media.js +11 -0
- package/dist/providers/_shared/rest/value-safety.js +353 -195
- package/dist/providers/_shared/sensitive-fields.js +27 -0
- package/dist/providers/breeze/api.js +584 -0
- package/dist/providers/breeze/index.d.ts +1 -3
- package/dist/providers/breeze/index.js +67 -1420
- package/dist/providers/breeze/openapi.generated.js +60 -0
- package/dist/providers/breeze/skill.generated.js +2 -6
- package/dist/providers/breeze/tools.js +696 -0
- package/dist/providers/ccb/api.js +496 -0
- package/dist/providers/ccb/index.d.ts +15 -37
- package/dist/providers/ccb/index.js +77 -1702
- package/dist/providers/ccb/lib/constants.d.ts +34 -0
- package/dist/providers/ccb/lib/constants.js +74 -0
- package/dist/providers/ccb/lib/guide.js +9 -0
- package/dist/providers/ccb/lib/verdicts.js +387 -0
- package/dist/providers/ccb/openapi.generated.js +397 -0
- package/dist/providers/ccb/safety.js +295 -0
- package/dist/providers/ccb/skill.generated.js +2 -6
- package/dist/providers/ccb/tools.js +853 -0
- package/dist/providers/cloudflare/api.js +1066 -0
- package/dist/providers/cloudflare/index.d.ts +5 -22
- package/dist/providers/cloudflare/index.js +79 -197
- package/dist/providers/cloudflare/lib/config.d.ts +16 -0
- package/dist/providers/cloudflare/lib/config.js +2 -0
- package/dist/providers/cloudflare/lib/discovery.js +29 -0
- package/dist/providers/cloudflare/{graphql.js → lib/graphql.js} +1 -1
- package/dist/providers/cloudflare/lib/hatch.js +103 -0
- package/dist/providers/cloudflare/lib/path-bounds.js +28 -0
- package/dist/providers/cloudflare/lib/response-media.js +75 -0
- package/dist/providers/cloudflare/openapi.generated.js +4 -3
- package/dist/providers/cloudflare/{value-safety.js → safety.js} +487 -0
- package/dist/providers/cloudflare/skill.generated.js +2 -10
- package/dist/providers/cloudflare/tools.js +360 -0
- package/dist/providers/docs/api.js +51 -0
- package/dist/providers/docs/index.d.ts +1 -12
- package/dist/providers/docs/index.js +53 -1165
- package/dist/providers/docs/lib/constants.d.ts +18 -0
- package/dist/providers/docs/lib/constants.js +18 -0
- package/dist/providers/docs/lib/discovery.js +26 -0
- package/dist/providers/docs/lib/requests.js +48 -0
- package/dist/providers/docs/lib/schemas.js +168 -0
- package/dist/providers/docs/openapi.generated.js +16 -0
- package/dist/providers/docs/safety.js +54 -0
- package/dist/providers/docs/skill.generated.js +2 -6
- package/dist/providers/docs/tools.js +801 -0
- package/dist/providers/drive/api.js +177 -0
- package/dist/providers/drive/index.d.ts +1 -12
- package/dist/providers/drive/index.js +38 -1448
- package/dist/providers/drive/lib/constants.d.ts +6 -0
- package/dist/providers/drive/lib/constants.js +6 -0
- package/dist/providers/drive/lib/contracts.js +532 -0
- package/dist/providers/drive/lib/hatch.js +197 -0
- package/dist/providers/drive/openapi.generated.js +77 -0
- package/dist/providers/drive/safety.js +90 -0
- package/dist/providers/drive/skill.generated.js +2 -6
- package/dist/providers/drive/tools.js +1290 -0
- package/dist/providers/forms/api.js +71 -0
- package/dist/providers/forms/index.d.ts +1 -12
- package/dist/providers/forms/index.js +49 -1145
- package/dist/providers/forms/lib/constants.d.ts +14 -0
- package/dist/providers/forms/lib/constants.js +19 -0
- package/dist/providers/forms/lib/hatch.js +72 -0
- package/dist/providers/forms/openapi.generated.js +23 -0
- package/dist/providers/forms/safety.js +34 -0
- package/dist/providers/forms/skill.generated.js +2 -8
- package/dist/providers/forms/tools.js +1023 -0
- package/dist/providers/github/api.js +150 -0
- package/dist/providers/github/index.d.ts +4 -5
- package/dist/providers/github/index.js +30 -87
- package/dist/providers/github/{auth.d.ts → lib/auth.d.ts} +5 -3
- package/dist/providers/github/{auth.js → lib/auth.js} +40 -43
- package/dist/providers/github/lib/discovery.js +132 -0
- package/dist/providers/github/lib/operations.js +58 -0
- package/dist/providers/github/{scope.d.ts → lib/scope.d.ts} +2 -0
- package/dist/providers/github/{scope.js → lib/scope.js} +25 -1
- package/dist/providers/github/openapi.generated.js +1216 -0
- package/dist/providers/github/safety.js +216 -0
- package/dist/providers/github/skill.generated.js +2 -5
- package/dist/providers/github/tools.js +506 -0
- package/dist/providers/gmail/api.js +114 -0
- package/dist/providers/gmail/index.d.ts +1 -12
- package/dist/providers/gmail/index.js +73 -1621
- package/dist/providers/gmail/lib/constants.d.ts +13 -0
- package/dist/providers/gmail/lib/constants.js +16 -0
- package/dist/providers/gmail/lib/index.js +173 -0
- package/dist/providers/gmail/lib/schemas.js +176 -0
- package/dist/providers/gmail/lib/threading.js +120 -0
- package/dist/providers/gmail/openapi.generated.js +92 -0
- package/dist/providers/gmail/safety.js +21 -0
- package/dist/providers/gmail/skill.generated.js +2 -6
- package/dist/providers/gmail/tools.js +1435 -0
- package/dist/providers/overflow/api.js +1008 -0
- package/dist/providers/overflow/index.d.ts +1 -17
- package/dist/providers/overflow/index.js +60 -1488
- package/dist/providers/overflow/skill.generated.js +2 -5
- package/dist/providers/overflow/tools.js +873 -0
- package/dist/providers/planning-center/api.js +467 -0
- package/dist/providers/planning-center/index.d.ts +4 -35
- package/dist/providers/planning-center/index.js +69 -2445
- package/dist/providers/planning-center/lib/constants.d.ts +58 -0
- package/dist/providers/planning-center/lib/constants.js +83 -0
- package/dist/providers/planning-center/lib/discovery.js +40 -0
- package/dist/providers/planning-center/lib/guide.js +39 -0
- package/dist/providers/planning-center/lib/json-api.js +83 -0
- package/dist/providers/planning-center/lib/verdicts.js +1758 -0
- package/dist/providers/planning-center/openapi.generated.js +1764 -0
- package/dist/providers/planning-center/safety.js +188 -0
- package/dist/providers/planning-center/skill.generated.js +2 -27
- package/dist/providers/planning-center/tools.js +1010 -0
- package/dist/providers/quickbooks/api.js +299 -0
- package/dist/providers/quickbooks/index.d.ts +13 -0
- package/dist/providers/quickbooks/index.js +93 -0
- package/dist/providers/quickbooks/lib/query.js +118 -0
- package/dist/providers/quickbooks/lib/writes.js +168 -0
- package/dist/providers/quickbooks/safety.js +222 -0
- package/dist/providers/quickbooks/skill.generated.js +5 -0
- package/dist/providers/quickbooks/tools.js +117 -0
- package/dist/providers/sheets/api.js +133 -0
- package/dist/providers/sheets/index.d.ts +4 -16
- package/dist/providers/sheets/index.js +61 -1172
- package/dist/providers/sheets/lib/constants.d.ts +14 -0
- package/dist/providers/sheets/lib/constants.js +14 -0
- package/dist/providers/sheets/lib/hatch.js +163 -0
- package/dist/providers/sheets/lib/requests.js +105 -0
- package/dist/providers/sheets/lib/schemas.js +138 -0
- package/dist/providers/sheets/openapi.generated.js +30 -0
- package/dist/providers/sheets/safety.js +27 -0
- package/dist/providers/sheets/skill.generated.js +2 -7
- package/dist/providers/sheets/tools.js +733 -0
- package/dist/providers/slides/api.js +147 -0
- package/dist/providers/slides/index.d.ts +2 -10
- package/dist/providers/slides/index.js +48 -2555
- package/dist/providers/slides/lib/constants.d.ts +11 -0
- package/dist/providers/slides/lib/constants.js +11 -0
- package/dist/providers/slides/lib/requests.js +60 -0
- package/dist/providers/slides/lib/schemas.js +565 -0
- package/dist/providers/slides/openapi.generated.js +18 -0
- package/dist/providers/slides/skill.generated.js +2 -6
- package/dist/providers/slides/tools.js +1968 -0
- package/dist/providers/stripe/api.js +401 -0
- package/dist/providers/stripe/index.d.ts +5 -27
- package/dist/providers/stripe/index.js +74 -210
- package/dist/providers/stripe/lib/config.d.ts +4 -0
- package/dist/providers/stripe/lib/config.js +2 -0
- package/dist/providers/stripe/openapi.generated.js +4 -3
- package/dist/providers/stripe/{value-safety.js → safety.js} +5 -5
- package/dist/providers/stripe/skill.generated.js +2 -11
- package/dist/providers/stripe/tools.js +77 -0
- package/dist/providers/tithely/api.js +531 -0
- package/dist/providers/tithely/index.d.ts +17 -0
- package/dist/providers/tithely/index.js +86 -965
- package/dist/providers/tithely/skill.generated.js +2 -5
- package/dist/providers/tithely/tools.js +426 -0
- package/dist/providers/vercel/api.js +551 -0
- package/dist/providers/vercel/index.d.ts +9 -25
- package/dist/providers/vercel/index.js +65 -260
- package/dist/providers/vercel/lib/config.d.ts +2 -0
- package/dist/providers/vercel/lib/config.js +2 -0
- package/dist/providers/vercel/lib/index.js +117 -0
- package/dist/providers/vercel/lib/logs.js +100 -0
- package/dist/providers/vercel/lib/pin.js +270 -0
- package/dist/providers/vercel/openapi.generated.js +10 -5
- package/dist/providers/vercel/{value-safety.js → safety.js} +247 -38
- package/dist/providers/vercel/skill.generated.js +2 -7
- package/dist/providers/vercel/tools.js +642 -0
- package/dist/redaction-identity.js +47 -0
- package/dist/redaction-marker.js +125 -0
- package/dist/registry.d.ts +21 -15
- package/dist/registry.js +409 -376
- package/dist/routes/access-tokens.js +1 -1
- package/dist/routes/actor-labels.js +1 -1
- package/dist/routes/connect.js +1 -1
- package/dist/routes/dispatch.js +103 -0
- package/dist/routes/mcp.js +170 -79
- package/dist/routes/oauth.js +6 -1
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +5 -4
- package/dist/routes/shared.js +244 -177
- package/dist/routes/skills.js +10 -8
- package/dist/routes/ui-config.js +27 -3
- package/dist/routes/ui-data.js +21 -20
- package/dist/routes/ui.js +1 -1
- package/dist/runtime/call-admission.js +0 -26
- package/dist/runtime/call-entry.js +30 -0
- package/dist/runtime/call.js +552 -0
- package/dist/runtime/connector-scope.js +7 -0
- package/dist/runtime/credential-probe.js +34 -0
- package/dist/runtime/executor.js +40 -0
- package/dist/runtime/native-storage.js +63 -0
- package/dist/runtime/registry.js +27 -0
- package/dist/runtime/request-scope.js +72 -0
- package/dist/runtime/run.js +15 -7
- package/dist/runtime/shared-read.js +34 -5
- package/dist/runtime/storage.js +34 -21
- package/dist/sent-secrets.d.ts +194 -37
- package/dist/sent-secrets.js +1743 -340
- package/dist/server.js +5 -56
- package/dist/skills.js +322 -223
- package/dist/sqlite.js +15 -14
- package/dist/storage/d1-copy.js +255 -0
- package/dist/storage/d1-driver.js +15 -0
- package/dist/storage/keys.js +28 -0
- package/dist/storage/sql-driver.js +88 -0
- package/dist/storage/sql-native.js +544 -0
- package/dist/storage/sql-schema.d.ts +6 -0
- package/dist/storage/sql-schema.js +15 -0
- package/dist/storage/sql.d.ts +14 -20
- package/dist/storage/sql.js +29 -535
- package/dist/timeout.js +0 -26
- package/dist/tool-safety.js +20 -10
- package/dist/types.d.ts +9 -109
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +0 -1
- package/dist/upward.js +3 -0
- package/dist/usage-guide.js +3 -1
- package/dist/validate.d.ts +4 -2
- package/dist/validate.js +86 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +312 -292
- package/dist/workspace-subject.js +5 -1
- package/dist/write-outcome.js +454 -34
- package/documentation/README.md +28 -7
- package/documentation/architecture.md +2409 -255
- package/documentation/auth.md +867 -114
- package/documentation/code-mode.md +49 -16
- package/documentation/deploying.md +164 -20
- package/documentation/hosted-mcp-migration.md +460 -0
- package/documentation/integrating.md +129 -144
- package/documentation/meta-tools.md +409 -32
- package/documentation/operating.md +27 -3
- package/documentation/operator-tests.md +3 -0
- package/documentation/operator-ui.md +84 -48
- package/documentation/provider-auth-migration.md +16 -63
- package/documentation/provider-migration-0.29.md +10 -5
- package/documentation/provider-migration-0.35.md +975 -0
- package/documentation/providers.md +1347 -0
- package/examples/worker/AGENTS.md +23 -9
- package/examples/worker/README.md +254 -135
- package/examples/worker/alchemy.run.ts +116 -0
- package/examples/worker/gitignore +10 -0
- package/examples/worker/src/connecta.config.ts +23 -21
- package/examples/worker/wrangler.jsonc +14 -5
- package/guides/connecting-agents.md +157 -0
- package/guides/how-it-works.md +156 -0
- package/guides/safety-and-trust.md +153 -0
- package/package.json +30 -50
- package/templates/node/.env.example +3 -0
- package/templates/node/README.md +22 -2
- package/templates/node/docker-compose.yml +1 -0
- package/templates/node/package.json +3 -2
- package/templates/node/src/connecta.config.ts +8 -0
- package/dist/catalog-drift.d.ts +0 -138
- package/dist/catalog-drift.js +0 -578
- package/dist/operator-ui/agents.d.ts +0 -74
- package/dist/operator-ui/agents.js +0 -104
- package/dist/operator-ui/config-summary.js +0 -565
- package/dist/operator-ui/connection-map.js +0 -376
- package/dist/operator-ui/endpoints.js +0 -221
- package/dist/operator-ui/fix-prompts.d.ts +0 -18
- package/dist/operator-ui/fix-prompts.js +0 -87
- package/dist/operator-ui/health.js +0 -52
- package/dist/operator-ui/member-page.js +0 -97
- package/dist/operator-ui/overview.js +0 -123
- package/dist/operator-ui/setup-commands.js +0 -51
- package/dist/operator-ui/view.d.ts +0 -401
- package/dist/operator-ui/view.js +0 -705
- package/dist/providers/_shared/request-token.js +0 -83
- package/dist/providers/_shared/rest/dispatch.js +0 -41
- package/dist/providers/_shared/rest/operation-index.d.ts +0 -133
- package/dist/providers/_shared/rest/tools.d.ts +0 -141
- package/dist/providers/_shared/rest/tools.js +0 -569
- package/dist/providers/_shared/rest/value-safety.d.ts +0 -201
- package/dist/providers/basecamp/index.d.ts +0 -45
- package/dist/providers/basecamp/index.js +0 -997
- package/dist/providers/basecamp/skill.generated.js +0 -9
- package/dist/providers/cloudflare/rest.d.ts +0 -37
- package/dist/providers/cloudflare/rest.js +0 -1097
- package/dist/providers/github/hosted.js +0 -218
- package/dist/providers/github/rest.js +0 -321
- package/dist/providers/infisical/index.d.ts +0 -7
- package/dist/providers/infisical/index.js +0 -713
- package/dist/providers/infisical/skill.generated.js +0 -8
- package/dist/providers/intercom/index.d.ts +0 -24
- package/dist/providers/intercom/index.js +0 -121
- package/dist/providers/intercom/skill.generated.js +0 -8
- package/dist/providers/linear/index.d.ts +0 -55
- package/dist/providers/linear/index.js +0 -537
- package/dist/providers/linear/skill.generated.js +0 -8
- package/dist/providers/mixpanel/index.d.ts +0 -59
- package/dist/providers/mixpanel/index.js +0 -472
- package/dist/providers/mixpanel/operations.js +0 -20
- package/dist/providers/mixpanel/rest.js +0 -603
- package/dist/providers/mixpanel/skill.generated.js +0 -10
- package/dist/providers/mixpanel/value-safety.js +0 -16
- package/dist/providers/notion/index.d.ts +0 -67
- package/dist/providers/notion/index.js +0 -368
- package/dist/providers/notion/openapi.generated.js +0 -75
- package/dist/providers/notion/rest.d.ts +0 -24
- package/dist/providers/notion/rest.js +0 -1226
- package/dist/providers/notion/skill.generated.js +0 -11
- package/dist/providers/notion/value-safety.js +0 -56
- package/dist/providers/revenuecat/index.d.ts +0 -51
- package/dist/providers/revenuecat/index.js +0 -697
- package/dist/providers/revenuecat/skill.generated.js +0 -11
- package/dist/providers/stripe/rest.d.ts +0 -14
- package/dist/providers/stripe/rest.js +0 -402
- package/dist/providers/vercel/rest.d.ts +0 -20
- package/dist/providers/vercel/rest.js +0 -1020
- package/dist/providers/wispr-flow/index.d.ts +0 -14
- package/dist/providers/wispr-flow/index.js +0 -117
- package/dist/providers/wispr-flow/skill.generated.js +0 -8
- /package/dist/providers/github/{key.js → lib/key.js} +0 -0
|
@@ -25,1420 +25,20 @@ import { skill } from "./skill.generated.js";
|
|
|
25
25
|
* is not. Every write that overwrites content, renames, moves, trashes, or
|
|
26
26
|
* changes sharing is destructive and crosses the host's approval prompt.
|
|
27
27
|
*
|
|
28
|
-
* Drift. `src/providers/drive/drift.json` records the
|
|
28
|
+
* Drift. `src/providers/drive/drift.json` records the 64 indexed methods and the reviewed subset the
|
|
29
29
|
* tools call, and `pnpm run providers:check --provider drive` reports a
|
|
30
30
|
* touched contract that moved or a method that stopped accepting the scope
|
|
31
31
|
* below.
|
|
32
32
|
*/
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
export const DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3";
|
|
43
|
-
/**
|
|
44
|
-
* Exactly the scope this connection requests, and exactly what the Admin
|
|
45
|
-
* console's domain-wide delegation entry must list. Full `drive`, because
|
|
46
|
-
* `drive.file` reaches only files the app itself created and every narrower
|
|
47
|
-
* scope is read-only.
|
|
48
|
-
*/
|
|
49
|
-
export const DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive"];
|
|
50
|
-
/** Rows are one cheap request per page; Drive's own maximum is 1,000. */
|
|
51
|
-
const MAX_PAGE_SIZE = 100;
|
|
52
|
-
const DEFAULT_PAGE_SIZE = 25;
|
|
53
|
-
const DEFAULT_CONTENT_CHARS = 20_000;
|
|
54
|
-
const MAX_CONTENT_CHARS = 100_000;
|
|
55
|
-
/** A binary file is returned as base64 at or under this size, never above. */
|
|
56
|
-
const BINARY_INLINE_BYTES = 1024 * 1024;
|
|
57
|
-
/**
|
|
58
|
-
* Serialized bytes of a whole read result — rows or content, envelope and
|
|
59
|
-
* cursor included. Every read is built to stay under `maxBytes`, measured as
|
|
60
|
-
* the UTF-8 length of its JSON, the way both delivery routes measure it. The
|
|
61
|
-
* default is the shared Workspace budget, three quarters of the 256 KiB a
|
|
62
|
-
* host result may carry into execute_code, so a program always receives a
|
|
63
|
-
* default result. A direct call_tool caller may ask for up to 4 MiB, which the
|
|
64
|
-
* result stash (8 MiB by default) still pages through connecta.result; execute_code
|
|
65
|
-
* cannot receive that much.
|
|
66
|
-
*/
|
|
67
|
-
const DEFAULT_RESULT_BYTES = RESULT_BUDGET_BYTES;
|
|
68
|
-
/**
|
|
69
|
-
* Room for the costliest row — every bounded string at its bound and both
|
|
70
|
-
* capped lists full — beside the longest cursor this connection issues.
|
|
71
|
-
*/
|
|
72
|
-
const MIN_RESULT_BYTES = 64 * 1024;
|
|
73
|
-
const MAX_RESULT_BYTES = 4 * 1024 * 1024;
|
|
74
|
-
/**
|
|
75
|
-
* Strings Drive does not bound for us, cut in JSON bytes so one row or one
|
|
76
|
-
* file always fits under the smallest `maxBytes`: a name in a listing, a
|
|
77
|
-
* write result, or a content result; a name and description in get_file; and
|
|
78
|
-
* labels such as a display name. A cut string ends with "…" and its field is
|
|
79
|
-
* named in the result's `truncatedFields`, so a real ellipsis is never taken
|
|
80
|
-
* for a cut. Identifiers are never cut: one that is not a well-formed Drive id
|
|
81
|
-
* is left out and named there instead.
|
|
82
|
-
*/
|
|
83
|
-
const ROW_NAME_BYTES = 2 * 1024;
|
|
84
|
-
const DETAIL_NAME_BYTES = 32 * 1024;
|
|
85
|
-
const DETAIL_DESCRIPTION_BYTES = 64 * 1024;
|
|
86
|
-
const LABEL_BYTES = 1024;
|
|
87
|
-
/** Owners and parents listed per file; Drive returns one of each today. */
|
|
88
|
-
const MAX_LISTED_IDS = 10;
|
|
89
|
-
/** What a Drive id looks like; anything else is malformed, never echoed. */
|
|
90
|
-
const DRIVE_ID = /^[A-Za-z0-9_-]{1,256}$/;
|
|
91
|
-
/**
|
|
92
|
-
* The longest cursor this connection issues, and the input schema accepts.
|
|
93
|
-
* Drive's page tokens are a few hundred characters; one so long that a cursor
|
|
94
|
-
* carrying it would pass this is refused when Drive sends it, rather than
|
|
95
|
-
* issued and then refused on replay.
|
|
96
|
-
*/
|
|
97
|
-
const MAX_CURSOR_CHARS = 16_384;
|
|
98
|
-
/** Upload ceiling for text, in characters; Drive's multipart limit is 5 MB. */
|
|
99
|
-
const MAX_UPLOAD_CHARS = 1_000_000;
|
|
100
|
-
/** Base64 characters for {@link BINARY_INLINE_BYTES} decoded bytes. */
|
|
101
|
-
const MAX_UPLOAD_BASE64 = 4 * Math.ceil(BINARY_INLINE_BYTES / 3);
|
|
102
|
-
/** Drive's export limit is 10 MB, so an export never nears this. */
|
|
103
|
-
const DRIVE_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
104
|
-
const FOLDER = "application/vnd.google-apps.folder";
|
|
105
|
-
const SHORTCUT = "application/vnd.google-apps.shortcut";
|
|
106
|
-
const NATIVE = "application/vnd.google-apps.";
|
|
107
|
-
/** Google file types the content tool exports, and as what. */
|
|
108
|
-
const EXPORTS = {
|
|
109
|
-
"application/vnd.google-apps.document": { mimeType: "text/markdown", format: "markdown" },
|
|
110
|
-
"application/vnd.google-apps.spreadsheet": {
|
|
111
|
-
mimeType: "text/csv",
|
|
112
|
-
format: "csv",
|
|
113
|
-
note: "CSV of the first sheet only; Drive exports no other tab as CSV.",
|
|
114
|
-
},
|
|
115
|
-
"application/vnd.google-apps.presentation": { mimeType: "text/plain", format: "text" },
|
|
116
|
-
};
|
|
117
|
-
/** `convertTo` values, as the Google type a created file becomes. */
|
|
118
|
-
const CONVERSIONS = {
|
|
119
|
-
document: "application/vnd.google-apps.document",
|
|
120
|
-
spreadsheet: "application/vnd.google-apps.spreadsheet",
|
|
121
|
-
presentation: "application/vnd.google-apps.presentation",
|
|
122
|
-
};
|
|
123
|
-
const SUMMARY_FIELDS = "id,name,mimeType,parents,driveId,size,modifiedTime,webViewLink,trashed,owners(emailAddress),shortcutDetails(targetId,targetMimeType)";
|
|
124
|
-
const DETAIL_FIELDS = `${SUMMARY_FIELDS},description,createdTime,lastModifyingUser(emailAddress,displayName),shared,starred,capabilities(canEdit,canComment,canShare,canDownload,canTrash,canRename,canAddChildren)`;
|
|
125
|
-
const PERMISSION_FIELDS = "id,type,role,emailAddress,domain,displayName,allowFileDiscovery,expirationTime,deleted,permissionDetails(inherited,inheritedFrom)";
|
|
126
|
-
/** Every file request reaches shared drives as well as My Drive. */
|
|
127
|
-
const ALL_DRIVES = { supportsAllDrives: true };
|
|
128
|
-
function asRecord(value) {
|
|
129
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
130
|
-
}
|
|
131
|
-
function asArray(value) {
|
|
132
|
-
return Array.isArray(value) ? value : [];
|
|
133
|
-
}
|
|
134
|
-
function compact(value) {
|
|
135
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
136
|
-
}
|
|
137
|
-
function text(value) {
|
|
138
|
-
return typeof value === "string" && value !== "" ? value : undefined;
|
|
139
|
-
}
|
|
140
|
-
function bool(value) {
|
|
141
|
-
return typeof value === "boolean" ? value : undefined;
|
|
142
|
-
}
|
|
143
|
-
/** Drive reports sizes as int64 strings. */
|
|
144
|
-
function sizeOf(value) {
|
|
145
|
-
const size = typeof value === "string" ? Number(value) : value;
|
|
146
|
-
return typeof size === "number" && Number.isSafeInteger(size) && size >= 0 ? size : undefined;
|
|
147
|
-
}
|
|
148
|
-
/** Well-formed Drive ids in a list, for a decision rather than a result. */
|
|
149
|
-
function idsOf(value) {
|
|
150
|
-
return asArray(value).filter((entry) => typeof entry === "string" && DRIVE_ID.test(entry));
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* What one projection kept, and the name of every field it shortened or left
|
|
154
|
-
* out — reported as `truncatedFields`, never silently.
|
|
155
|
-
*/
|
|
156
|
-
class Cuts {
|
|
157
|
-
cut = new Set();
|
|
158
|
-
/** A string Drive does not bound, cut to `bytes` of JSON with "…". */
|
|
159
|
-
label(field, value, bytes = LABEL_BYTES) {
|
|
160
|
-
const whole = typeof value === "string" ? value : undefined;
|
|
161
|
-
if (whole === undefined || (whole === "" && field !== "name"))
|
|
162
|
-
return undefined;
|
|
163
|
-
const kept = clampText(whole, bytes, () => "…");
|
|
164
|
-
if (kept !== whole)
|
|
165
|
-
this.cut.add(field);
|
|
166
|
-
return kept;
|
|
167
|
-
}
|
|
168
|
-
/** An identifier whole, or left out and named when it is malformed. */
|
|
169
|
-
id(field, value) {
|
|
170
|
-
const whole = text(value);
|
|
171
|
-
if (whole === undefined)
|
|
172
|
-
return undefined;
|
|
173
|
-
if (DRIVE_ID.test(whole))
|
|
174
|
-
return whole;
|
|
175
|
-
this.cut.add(field);
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
/** Up to ten well-formed ids; the rest, and any malformed one, are named. */
|
|
179
|
-
ids(field, value) {
|
|
180
|
-
const all = asArray(value).filter((entry) => typeof entry === "string");
|
|
181
|
-
const kept = idsOf(all);
|
|
182
|
-
if (kept.length !== all.length || kept.length > MAX_LISTED_IDS)
|
|
183
|
-
this.cut.add(field);
|
|
184
|
-
return kept.length > 0 ? kept.slice(0, MAX_LISTED_IDS) : undefined;
|
|
185
|
-
}
|
|
186
|
-
/** Up to ten labels, each cut as {@link label} cuts one. */
|
|
187
|
-
labels(field, values) {
|
|
188
|
-
const present = values.filter((value) => typeof value === "string" && value !== "");
|
|
189
|
-
if (present.length > MAX_LISTED_IDS)
|
|
190
|
-
this.cut.add(field);
|
|
191
|
-
const kept = present.slice(0, MAX_LISTED_IDS).map((value) => this.label(field, value));
|
|
192
|
-
return kept.length > 0 ? kept : undefined;
|
|
193
|
-
}
|
|
194
|
-
get fields() {
|
|
195
|
-
return this.cut.size > 0 ? [...this.cut] : undefined;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
// --- Projections ------------------------------------------------------------------
|
|
199
|
-
/**
|
|
200
|
-
* A file as an agent reads it. `fallbackId` is the id the call already named,
|
|
201
|
-
* for a response that omits it; `nameBytes` bounds the name. Fields cut or
|
|
202
|
-
* left out are named in `truncatedFields`, which `extra` may add to.
|
|
203
|
-
*/
|
|
204
|
-
function projectFile(value, fallbackId, nameBytes = ROW_NAME_BYTES, extra) {
|
|
205
|
-
const file = asRecord(value);
|
|
206
|
-
const shortcut = asRecord(file["shortcutDetails"]);
|
|
207
|
-
const cuts = new Cuts();
|
|
208
|
-
const projected = compact({
|
|
209
|
-
id: cuts.id("id", file["id"]) ?? fallbackId,
|
|
210
|
-
name: cuts.label("name", file["name"], nameBytes),
|
|
211
|
-
mimeType: cuts.label("mimeType", file["mimeType"]),
|
|
212
|
-
parents: cuts.ids("parents", file["parents"]),
|
|
213
|
-
driveId: cuts.id("driveId", file["driveId"]),
|
|
214
|
-
size: sizeOf(file["size"]),
|
|
215
|
-
modifiedTime: cuts.label("modifiedTime", file["modifiedTime"]),
|
|
216
|
-
owners: cuts.labels("owners", asArray(file["owners"]).map((owner) => asRecord(owner)["emailAddress"])),
|
|
217
|
-
trashed: bool(file["trashed"]),
|
|
218
|
-
webViewLink: cuts.label("webViewLink", file["webViewLink"]),
|
|
219
|
-
shortcutTargetId: cuts.id("shortcutTargetId", shortcut["targetId"]),
|
|
220
|
-
shortcutTargetMimeType: cuts.label("shortcutTargetMimeType", shortcut["targetMimeType"]),
|
|
221
|
-
...extra?.(file, cuts),
|
|
222
|
-
});
|
|
223
|
-
return compact({ ...projected, truncatedFields: cuts.fields });
|
|
224
|
-
}
|
|
225
|
-
const CAPABILITIES = [
|
|
226
|
-
"canEdit",
|
|
227
|
-
"canComment",
|
|
228
|
-
"canShare",
|
|
229
|
-
"canDownload",
|
|
230
|
-
"canTrash",
|
|
231
|
-
"canRename",
|
|
232
|
-
"canAddChildren",
|
|
233
|
-
];
|
|
234
|
-
function projectFileDetail(value, fallbackId) {
|
|
235
|
-
return projectFile(value, fallbackId, DETAIL_NAME_BYTES, (file, cuts) => {
|
|
236
|
-
const modifier = asRecord(file["lastModifyingUser"]);
|
|
237
|
-
const granted = asRecord(file["capabilities"]);
|
|
238
|
-
const capabilities = compact(Object.fromEntries(CAPABILITIES.map((name) => [name, bool(granted[name])])));
|
|
239
|
-
return {
|
|
240
|
-
description: cuts.label("description", file["description"], DETAIL_DESCRIPTION_BYTES),
|
|
241
|
-
createdTime: cuts.label("createdTime", file["createdTime"]),
|
|
242
|
-
modifiedBy: cuts.label("modifiedBy", text(modifier["emailAddress"])) ??
|
|
243
|
-
cuts.label("modifiedBy", text(modifier["displayName"])),
|
|
244
|
-
shared: bool(file["shared"]),
|
|
245
|
-
starred: bool(file["starred"]),
|
|
246
|
-
capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined,
|
|
247
|
-
};
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
function projectPermission(value, fallbackId) {
|
|
251
|
-
const permission = asRecord(value);
|
|
252
|
-
const details = asArray(permission["permissionDetails"]).map(asRecord);
|
|
253
|
-
const cuts = new Cuts();
|
|
254
|
-
const inheritedFrom = details
|
|
255
|
-
.map((detail) => cuts.id("inheritedFrom", detail["inheritedFrom"]))
|
|
256
|
-
.find((id) => id !== undefined);
|
|
257
|
-
return compact({
|
|
258
|
-
id: cuts.id("id", permission["id"]) ?? fallbackId,
|
|
259
|
-
type: cuts.label("type", permission["type"]),
|
|
260
|
-
role: cuts.label("role", permission["role"]),
|
|
261
|
-
emailAddress: cuts.label("emailAddress", permission["emailAddress"]),
|
|
262
|
-
domain: cuts.label("domain", permission["domain"]),
|
|
263
|
-
displayName: cuts.label("displayName", permission["displayName"]),
|
|
264
|
-
allowFileDiscovery: bool(permission["allowFileDiscovery"]),
|
|
265
|
-
expirationTime: cuts.label("expirationTime", permission["expirationTime"]),
|
|
266
|
-
// On a shared drive, a role granted by the drive or a parent folder.
|
|
267
|
-
inherited: details.length > 0 ? details.some((detail) => detail["inherited"] === true) : undefined,
|
|
268
|
-
// The folder or shared drive to change instead, since an inherited share
|
|
269
|
-
// cannot be changed or removed here.
|
|
270
|
-
inheritedFrom,
|
|
271
|
-
deleted: permission["deleted"] === true ? true : undefined,
|
|
272
|
-
truncatedFields: cuts.fields,
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
const encoder = new TextEncoder();
|
|
276
|
-
function base64url(bytes) {
|
|
277
|
-
return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
278
|
-
}
|
|
279
|
-
/** A short SHA-256 fingerprint: 96 bits, as 16 base64url characters. */
|
|
280
|
-
async function fingerprint(value) {
|
|
281
|
-
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(JSON.stringify(value))));
|
|
282
|
-
return base64url(digest.subarray(0, 12));
|
|
283
|
-
}
|
|
284
|
-
function encodeCursor(resume) {
|
|
285
|
-
const { token, skip, size, scope, page, last } = resume;
|
|
286
|
-
return base64url(encoder.encode(JSON.stringify([token, skip, size, scope, page, last])));
|
|
287
|
-
}
|
|
288
|
-
const FINGERPRINT = /^[A-Za-z0-9_-]{16}$/;
|
|
289
|
-
function decodeCursor(cursor) {
|
|
290
|
-
try {
|
|
291
|
-
const base = cursor.replace(/-/g, "+").replace(/_/g, "/");
|
|
292
|
-
const json = new TextDecoder().decode(fromBase64(base + "=".repeat((4 - (base.length % 4)) % 4)));
|
|
293
|
-
const [token, skip, size, scope, page, last] = JSON.parse(json);
|
|
294
|
-
const midPage = typeof page === "string" && FINGERPRINT.test(page) && typeof last === "string" && FINGERPRINT.test(last);
|
|
295
|
-
if ((token === null || (typeof token === "string" && token !== "")) &&
|
|
296
|
-
Number.isSafeInteger(skip) &&
|
|
297
|
-
skip >= 0 &&
|
|
298
|
-
Number.isSafeInteger(size) &&
|
|
299
|
-
size >= 1 &&
|
|
300
|
-
size <= MAX_PAGE_SIZE &&
|
|
301
|
-
typeof scope === "string" &&
|
|
302
|
-
FINGERPRINT.test(scope) &&
|
|
303
|
-
// A cursor inside a page proves which page; one at a boundary has nothing to prove.
|
|
304
|
-
(skip > 0 ? midPage : page === null && last === null)) {
|
|
305
|
-
return {
|
|
306
|
-
token: token,
|
|
307
|
-
skip: skip,
|
|
308
|
-
size: size,
|
|
309
|
-
scope,
|
|
310
|
-
page: midPage ? page : null,
|
|
311
|
-
last: midPage ? last : null,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
// Reported below, as any other cursor this connection did not issue.
|
|
317
|
-
}
|
|
318
|
-
throw new ConnectorCallError("invalid_args", "cursor is not a page.nextCursor this connection issued; pass it back unchanged, or omit it to start over.");
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* Whether a cursor carrying this page token stays within
|
|
322
|
-
* {@link MAX_CURSOR_CHARS} wherever it resumes, mid-page fingerprints included.
|
|
323
|
-
*/
|
|
324
|
-
function issuable(token) {
|
|
325
|
-
const widest = "A".repeat(16);
|
|
326
|
-
return (encodeCursor({ token, skip: MAX_PAGE_SIZE, size: MAX_PAGE_SIZE, scope: widest, page: widest, last: widest })
|
|
327
|
-
.length <= MAX_CURSOR_CHARS);
|
|
328
|
-
}
|
|
329
|
-
/**
|
|
330
|
-
* One page of a listing, as many rows as fit `maxBytes` with the whole
|
|
331
|
-
* result — envelope and cursor included — and a cursor that resumes at the
|
|
332
|
-
* first row left out, inside Drive's page or after it.
|
|
333
|
-
*
|
|
334
|
-
* Callers `await` it where they return it: a cursor refused before the first
|
|
335
|
-
* await rejects at once, and workerd reports a rejected promise an async
|
|
336
|
-
* handler merely returns as unhandled before the handler adopts it.
|
|
337
|
-
*/
|
|
338
|
-
async function pageOf(key, args,
|
|
339
|
-
/** The tool and every argument that decides which rows, in which order. */
|
|
340
|
-
binding, fetch) {
|
|
341
|
-
const maxBytes = args["maxBytes"] ?? DEFAULT_RESULT_BYTES;
|
|
342
|
-
const size = args["limit"] ?? DEFAULT_PAGE_SIZE;
|
|
343
|
-
const scope = await fingerprint({ ...binding, size });
|
|
344
|
-
const resume = typeof args["cursor"] === "string"
|
|
345
|
-
? decodeCursor(args["cursor"])
|
|
346
|
-
: { token: null, skip: 0, size, scope, page: null, last: null };
|
|
347
|
-
if (resume.scope !== scope) {
|
|
348
|
-
throw new ConnectorCallError("invalid_args", "This cursor belongs to a different listing — another tool, or other arguments. Pass the same arguments it was issued with, or omit cursor to start over.");
|
|
349
|
-
}
|
|
350
|
-
const fetched = await fetch(resume.token ?? undefined, resume.size);
|
|
351
|
-
if (fetched.next !== undefined && !issuable(fetched.next)) {
|
|
352
|
-
throw new ConnectorCallError("connector_call_failed", `Drive answered with a next-page token of ${fetched.next.length} characters, too long for a cursor this connection can accept back (${MAX_CURSOR_CHARS}), so the listing cannot be resumed past this page. Narrow the query, or raise limit to take more per page.`, { retryable: false });
|
|
353
|
-
}
|
|
354
|
-
// Drive's ids as Drive sent them, so two that differ anywhere differ here.
|
|
355
|
-
const page = await fingerprint(fetched.ids);
|
|
356
|
-
const lasts = await Promise.all(fetched.ids.map((id) => fingerprint(id)));
|
|
357
|
-
if (resume.skip > 0 && (page !== resume.page || lasts[resume.skip - 1] !== resume.last)) {
|
|
358
|
-
throw new ConnectorCallError("conflict", `The Drive page this cursor resumes inside has changed since it was read — ${key} were added, removed, or reordered — so resuming by position could skip or repeat some. Nothing was returned; start the listing again without cursor.`, { retryable: false });
|
|
359
|
-
}
|
|
360
|
-
const rows = fetched.rows.slice(resume.skip);
|
|
361
|
-
const build = (count) => {
|
|
362
|
-
const nextCursor = count < rows.length
|
|
363
|
-
? encodeCursor({ ...resume, skip: resume.skip + count, page, last: lasts[resume.skip + count - 1] ?? "" })
|
|
364
|
-
: fetched.next
|
|
365
|
-
? encodeCursor({ token: fetched.next, skip: 0, size: resume.size, scope, page: null, last: null })
|
|
366
|
-
: null;
|
|
367
|
-
return { [key]: rows.slice(0, count), ...fetched.extra, page: { hasMore: nextCursor !== null, nextCursor } };
|
|
368
|
-
};
|
|
369
|
-
if (jsonBytes(build(rows.length)) <= maxBytes)
|
|
370
|
-
return build(rows.length);
|
|
371
|
-
// The most rows that fit, by binary search; a result's size grows with them.
|
|
372
|
-
let low = 0;
|
|
373
|
-
let high = rows.length - 1;
|
|
374
|
-
while (low < high) {
|
|
375
|
-
const middle = Math.ceil((low + high) / 2);
|
|
376
|
-
if (jsonBytes(build(middle)) <= maxBytes)
|
|
377
|
-
low = middle;
|
|
378
|
-
else
|
|
379
|
-
high = middle - 1;
|
|
380
|
-
}
|
|
381
|
-
if (low === 0) {
|
|
382
|
-
// Every row is bounded well under the smallest maxBytes; only a cursor
|
|
383
|
-
// stuck on a row that never fits could land here, and it must not loop.
|
|
384
|
-
throw new ConnectorCallError("connector_call_failed", `One ${key} row does not fit this result's maxBytes of ${maxBytes}; raise maxBytes.`, { retryable: false });
|
|
385
|
-
}
|
|
386
|
-
return build(low);
|
|
387
|
-
}
|
|
388
|
-
// --- Content ----------------------------------------------------------------------
|
|
389
|
-
const TEXT_TYPES = new Set([
|
|
390
|
-
"application/json",
|
|
391
|
-
"application/xml",
|
|
392
|
-
"application/javascript",
|
|
393
|
-
"application/x-javascript",
|
|
394
|
-
"application/x-yaml",
|
|
395
|
-
"application/yaml",
|
|
396
|
-
"application/x-sh",
|
|
397
|
-
"application/sql",
|
|
398
|
-
"application/csv",
|
|
399
|
-
"application/x-ndjson",
|
|
400
|
-
"image/svg+xml",
|
|
401
|
-
]);
|
|
402
|
-
function isText(mimeType) {
|
|
403
|
-
const type = mimeType.toLowerCase();
|
|
404
|
-
return type.startsWith("text/") || TEXT_TYPES.has(type) || type.endsWith("+json") || type.endsWith("+xml");
|
|
405
|
-
}
|
|
406
|
-
/** Whether the UTF-16 unit at `index` opens a surrogate pair that is whole. */
|
|
407
|
-
function pairAt(value, index) {
|
|
408
|
-
const unit = value.charCodeAt(index);
|
|
409
|
-
return unit >= 0xd800 && unit <= 0xdbff && (value.charCodeAt(index + 1) & 0xfc00) === 0xdc00;
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* The string index just past the first `max` characters, counted as code
|
|
413
|
-
* points — so a cut never splits a surrogate pair — and how many characters
|
|
414
|
-
* the whole string holds.
|
|
415
|
-
*/
|
|
416
|
-
function codePointCut(value, max) {
|
|
417
|
-
let index = 0;
|
|
418
|
-
let total = 0;
|
|
419
|
-
let end = value.length;
|
|
420
|
-
while (index < value.length) {
|
|
421
|
-
if (total === max)
|
|
422
|
-
end = index;
|
|
423
|
-
index += pairAt(value, index) ? 2 : 1;
|
|
424
|
-
total += 1;
|
|
425
|
-
}
|
|
426
|
-
return { end: total > max ? end : value.length, total };
|
|
427
|
-
}
|
|
428
|
-
/**
|
|
429
|
-
* Cut text to what both bounds allow and say so in the text itself: `max`
|
|
430
|
-
* characters (code points, so never inside a pair), and whatever `fits` — the
|
|
431
|
-
* whole result under `maxBytes` — admits, marker included. `partial` is a body
|
|
432
|
-
* read only in part, which is cut whatever its length: there is more past it.
|
|
433
|
-
*/
|
|
434
|
-
function capped(body, max, partial, fits) {
|
|
435
|
-
const { total } = codePointCut(body, max);
|
|
436
|
-
if (total <= max && !partial && fits(body))
|
|
437
|
-
return { content: body, contentTruncated: false };
|
|
438
|
-
const of = partial
|
|
439
|
-
? partial.size !== undefined && partial.size > 0
|
|
440
|
-
? `a ${partial.size}-byte file`
|
|
441
|
-
: "a larger file"
|
|
442
|
-
: `${total} characters`;
|
|
443
|
-
const cut = (count, raise) => `${body.slice(0, codePointCut(body, count).end)}\n[… truncated: ${count} characters of ${of} shown; raise ${raise} to read more]`;
|
|
444
|
-
const byChars = cut(Math.min(total, max), `maxChars (up to ${MAX_CONTENT_CHARS})`);
|
|
445
|
-
if (fits(byChars))
|
|
446
|
-
return { content: byChars, contentTruncated: true };
|
|
447
|
-
// The bytes bind first: the most characters that fit, by binary search.
|
|
448
|
-
const raise = `maxBytes (up to ${MAX_RESULT_BYTES}, for a direct call_tool only)`;
|
|
449
|
-
let low = 0;
|
|
450
|
-
let high = Math.min(total, max);
|
|
451
|
-
while (low < high) {
|
|
452
|
-
const middle = Math.ceil((low + high) / 2);
|
|
453
|
-
if (fits(cut(middle, raise)))
|
|
454
|
-
low = middle;
|
|
455
|
-
else
|
|
456
|
-
high = middle - 1;
|
|
457
|
-
}
|
|
458
|
-
return { content: cut(low, raise), contentTruncated: true };
|
|
459
|
-
}
|
|
460
|
-
function charsetOf(contentType) {
|
|
461
|
-
return /charset="?([^";\s]+)"?/i.exec(contentType ?? "")?.[1] ?? "utf-8";
|
|
462
|
-
}
|
|
463
|
-
/** Bytes as text in the charset the response declared, where the runtime knows it. */
|
|
464
|
-
function decodeIn(bytes, contentType) {
|
|
465
|
-
try {
|
|
466
|
-
return new TextDecoder(charsetOf(contentType)).decode(bytes);
|
|
467
|
-
}
|
|
468
|
-
catch {
|
|
469
|
-
return new TextDecoder().decode(bytes);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
function base64(bytes) {
|
|
473
|
-
let binary = "";
|
|
474
|
-
for (let index = 0; index < bytes.length; index += 0x8000) {
|
|
475
|
-
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
476
|
-
}
|
|
477
|
-
return btoa(binary);
|
|
478
|
-
}
|
|
479
|
-
function fromBase64(value) {
|
|
480
|
-
let binary;
|
|
481
|
-
try {
|
|
482
|
-
binary = atob(value);
|
|
483
|
-
}
|
|
484
|
-
catch {
|
|
485
|
-
throw new ConnectorCallError("invalid_args", "contentBase64 is not valid base64.");
|
|
486
|
-
}
|
|
487
|
-
const bytes = new Uint8Array(binary.length);
|
|
488
|
-
for (let index = 0; index < binary.length; index += 1)
|
|
489
|
-
bytes[index] = binary.charCodeAt(index);
|
|
490
|
-
return bytes;
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* Why the content tool returns no content for a file, as the note says it.
|
|
494
|
-
* Only a well-formed id or media type is interpolated; anything else Drive
|
|
495
|
-
* sends is named by kind, so a note stays a sentence.
|
|
496
|
-
*/
|
|
497
|
-
function unreadable(file) {
|
|
498
|
-
const mimeType = String(file["mimeType"] ?? "");
|
|
499
|
-
if (mimeType === FOLDER)
|
|
500
|
-
return "A folder has no content; list it with list_folder_items.";
|
|
501
|
-
if (mimeType === SHORTCUT) {
|
|
502
|
-
const target = text(asRecord(file["shortcutDetails"])["targetId"]);
|
|
503
|
-
const named = target !== undefined && DRIVE_ID.test(target) ? ` (${target})` : "";
|
|
504
|
-
return `A shortcut has no content of its own; read its target${named} instead.`;
|
|
505
|
-
}
|
|
506
|
-
if (mimeType === "application/vnd.google-apps.drawing") {
|
|
507
|
-
return "Drawings export only as images or PDF; this connection reads no drawing. Open webViewLink.";
|
|
508
|
-
}
|
|
509
|
-
if (mimeType.startsWith(NATIVE) && !EXPORTS[mimeType]) {
|
|
510
|
-
const named = mimeType.length <= 255 && MEDIA_TYPE.test(mimeType) ? mimeType : "this Google file type";
|
|
511
|
-
return `Drive exports no text for ${named}; open webViewLink.`;
|
|
512
|
-
}
|
|
513
|
-
if (asRecord(file["capabilities"])["canDownload"] === false) {
|
|
514
|
-
return "The owner has disabled download, print, and copy for this account; Drive refuses its content.";
|
|
515
|
-
}
|
|
516
|
-
return undefined;
|
|
517
|
-
}
|
|
518
|
-
/**
|
|
519
|
-
* The bytes a text read keeps: enough for `maxChars` of UTF-8 at four bytes
|
|
520
|
-
* each and one more character, so a body this long is always cut.
|
|
521
|
-
*/
|
|
522
|
-
function textBudget(maxChars) {
|
|
523
|
-
return maxChars * 4 + 4;
|
|
524
|
-
}
|
|
525
|
-
/**
|
|
526
|
-
* A media download of at most `limit` bytes, and whether the file had more.
|
|
527
|
-
*
|
|
528
|
-
* The limit is enforced on the response, never on the size metadata
|
|
529
|
-
* reported: that is a separate read, and a file can grow, shrink, or empty
|
|
530
|
-
* between the two. The range asks for one byte past the limit, so a file that
|
|
531
|
-
* has more says so by sending it, and the shared client's `maxBytes` reads the
|
|
532
|
-
* body only that far and cancels the rest unread — so a server that ignores
|
|
533
|
-
* the range, or a body past the client's own ceiling, costs no more than the
|
|
534
|
-
* limit either.
|
|
535
|
-
*
|
|
536
|
-
* The range is sent even when the metadata says the file is empty, since that
|
|
537
|
-
* may no longer be so. A range from byte 0 of a file with no bytes is answered
|
|
538
|
-
* 416 with `Content-Range: bytes *\/0`, which the client returns as an empty
|
|
539
|
-
* body: a verified empty file, not a failure. Any other 416 still fails.
|
|
540
|
-
*/
|
|
541
|
-
async function download(client, ctx, path, limit) {
|
|
542
|
-
const read = await client.bytes({
|
|
543
|
-
method: "GET",
|
|
544
|
-
path,
|
|
545
|
-
query: { ...ALL_DRIVES, alt: "media" },
|
|
546
|
-
headers: { Range: `bytes=0-${limit}` },
|
|
547
|
-
}, ctx, "*/*", { maxBytes: limit });
|
|
548
|
-
return { bytes: read.bytes.subarray(0, limit), more: read.truncated, contentType: read.contentType };
|
|
549
|
-
}
|
|
550
|
-
async function readContent(client, ctx, fileId, maxChars, maxBytes) {
|
|
551
|
-
const filePath = `/files/${encodeURIComponent(fileId)}`;
|
|
552
|
-
const file = asRecord(await client.json({
|
|
553
|
-
method: "GET",
|
|
554
|
-
path: filePath,
|
|
555
|
-
query: {
|
|
556
|
-
...ALL_DRIVES,
|
|
557
|
-
fields: "id,name,mimeType,size,webViewLink,capabilities(canDownload),shortcutDetails(targetId)",
|
|
558
|
-
},
|
|
559
|
-
}, ctx));
|
|
560
|
-
const mimeType = String(file["mimeType"] ?? "application/octet-stream");
|
|
561
|
-
const size = sizeOf(file["size"]);
|
|
562
|
-
const cuts = new Cuts();
|
|
563
|
-
const base = compact({
|
|
564
|
-
id: cuts.id("id", file["id"]) ?? fileId,
|
|
565
|
-
name: cuts.label("name", file["name"], ROW_NAME_BYTES),
|
|
566
|
-
mimeType: cuts.label("mimeType", mimeType),
|
|
567
|
-
size,
|
|
568
|
-
webViewLink: cuts.label("webViewLink", file["webViewLink"]),
|
|
569
|
-
truncatedFields: cuts.fields,
|
|
570
|
-
});
|
|
571
|
-
const refusal = unreadable(file);
|
|
572
|
-
if (refusal)
|
|
573
|
-
return { ...base, format: "unavailable", contentTruncated: false, note: refusal };
|
|
574
|
-
/** Whether the whole result, with this content, stays under maxBytes. */
|
|
575
|
-
const within = (result) => (content) => jsonBytes({ ...result, content }) <= maxBytes;
|
|
576
|
-
const exported = EXPORTS[mimeType];
|
|
577
|
-
if (exported) {
|
|
578
|
-
// Past Drive's 10 MB export limit, the shared client names Google's
|
|
579
|
-
// exportSizeLimitExceeded itself; nothing here guesses at a 403.
|
|
580
|
-
const { text: body } = await client.text({ method: "GET", path: `${filePath}/export`, query: { mimeType: exported.mimeType } }, ctx, exported.mimeType);
|
|
581
|
-
const result = compact({
|
|
582
|
-
...base,
|
|
583
|
-
format: exported.format,
|
|
584
|
-
exportedAs: exported.mimeType,
|
|
585
|
-
content: "",
|
|
586
|
-
contentTruncated: false,
|
|
587
|
-
note: exported.note,
|
|
588
|
-
});
|
|
589
|
-
return { ...result, ...capped(body, maxChars, undefined, within(result)) };
|
|
590
|
-
}
|
|
591
|
-
if (isText(mimeType)) {
|
|
592
|
-
const read = await download(client, ctx, filePath, textBudget(maxChars));
|
|
593
|
-
let body = decodeIn(read.bytes, read.contentType);
|
|
594
|
-
// A cut can land inside a character; drop the half, never show it.
|
|
595
|
-
if (read.more)
|
|
596
|
-
body = body.replace(/(?:\uFFFD|[\uD800-\uDBFF])$/, "");
|
|
597
|
-
const result = { ...base, format: "text", content: "", contentTruncated: false };
|
|
598
|
-
return { ...result, ...capped(body, maxChars, read.more ? { size } : undefined, within(result)) };
|
|
599
|
-
}
|
|
600
|
-
// Base64 is four characters per three bytes and never escaped, so the file
|
|
601
|
-
// bytes that fit are known before anything is downloaded.
|
|
602
|
-
const envelope = jsonBytes({ ...base, format: "base64", content: "", contentTruncated: false });
|
|
603
|
-
const fitting = Math.floor(Math.max(0, maxBytes - envelope) / 4) * 3;
|
|
604
|
-
const limit = Math.min(BINARY_INLINE_BYTES, fitting);
|
|
605
|
-
const tooLarge = (bytes) => ({
|
|
606
|
-
...base,
|
|
607
|
-
format: "unavailable",
|
|
608
|
-
contentTruncated: false,
|
|
609
|
-
note: limit < BINARY_INLINE_BYTES
|
|
610
|
-
? `A binary file of ${bytes} does not fit this result's maxBytes (${maxBytes}), which holds ${limit} bytes of file; no content is returned. Raise maxBytes, up to ${MAX_RESULT_BYTES} for a direct call_tool only, to read up to ${BINARY_INLINE_BYTES} bytes, or open webViewLink.`
|
|
611
|
-
: `A binary file of ${bytes} is past this connection's ${BINARY_INLINE_BYTES}-byte inline cap; no content is returned. Open webViewLink.`,
|
|
612
|
-
});
|
|
613
|
-
// Metadata that already says too large saves the download; metadata that
|
|
614
|
-
// says small is not trusted, and the download is held to the cap itself.
|
|
615
|
-
if (size !== undefined && size > limit)
|
|
616
|
-
return tooLarge(`${size} bytes`);
|
|
617
|
-
const read = await download(client, ctx, filePath, limit);
|
|
618
|
-
if (read.more)
|
|
619
|
-
return tooLarge(`more than ${limit} bytes`);
|
|
620
|
-
return { ...base, format: "base64", content: base64(read.bytes), contentTruncated: false };
|
|
621
|
-
}
|
|
622
|
-
// --- Writes -----------------------------------------------------------------------
|
|
623
|
-
/**
|
|
624
|
-
* Run a write that adds something, saying what to check before repeating it
|
|
625
|
-
* when it may have landed: whenever the request left connecta and Google did
|
|
626
|
-
* not refuse it. That is no answer (`awaiting-response`), an unfollowed
|
|
627
|
-
* redirect (`redirected`), a 5xx (`server-error`) — each of which leaves the
|
|
628
|
-
* outcome unknown — or a 2xx whose reply broke (`reading-body`), which
|
|
629
|
-
* probably applied. The shared client already says which, in its own words,
|
|
630
|
-
* and makes the failure non-retryable; this adds only the read that tells,
|
|
631
|
-
* because a repeat of an additive write is not harmless — it makes a second
|
|
632
|
-
* file, folder, copy, or share. The code is kept, so core still reads the
|
|
633
|
-
* outcome as unknown. A refusal, or a failure before anything was sent,
|
|
634
|
-
* passes through untouched.
|
|
635
|
-
*/
|
|
636
|
-
async function adding(work, check) {
|
|
637
|
-
try {
|
|
638
|
-
return await work();
|
|
639
|
-
}
|
|
640
|
-
catch (error) {
|
|
641
|
-
const outcome = googleOutcomeOf(error);
|
|
642
|
-
if (error instanceof ConnectorCallError &&
|
|
643
|
-
error.code === "connector_call_failed" &&
|
|
644
|
-
outcome?.dispatched === true &&
|
|
645
|
-
outcome.phase !== "refused" &&
|
|
646
|
-
outcome.phase !== "before-send") {
|
|
647
|
-
throw new ConnectorCallError(error.code, `${error.message} ${check}`, { retryable: false, cause: error });
|
|
648
|
-
}
|
|
649
|
-
throw error;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
/**
|
|
653
|
-
* Writes safe to send twice: each sets fixed values — a name, a description,
|
|
654
|
-
* the trashed flag, a role, the whole content — so a second identical request
|
|
655
|
-
* leaves the file as the first did, and a 5xx stays a retryable outage. Never
|
|
656
|
-
* a create, copy, share, permission delete, or move, whose second send makes a
|
|
657
|
-
* second thing, fails differently, or acts on parents the first send changed.
|
|
658
|
-
*/
|
|
659
|
-
const IDEMPOTENT = { idempotent: true };
|
|
660
|
-
const CHECK_CREATED = "Find it with search_files or list_folder_items before creating it again: a second call makes a second one.";
|
|
661
|
-
// --- Uploads ----------------------------------------------------------------------
|
|
662
|
-
const MEDIA_TYPE = /^[A-Za-z0-9][\w.+-]*\/[A-Za-z0-9][\w.+-]*$/;
|
|
663
|
-
function mediaType(value, fallback) {
|
|
664
|
-
const type = typeof value === "string" ? value : fallback;
|
|
665
|
-
if (!MEDIA_TYPE.test(type)) {
|
|
666
|
-
throw new ConnectorCallError("invalid_args", "mimeType must be a bare media type such as text/plain.");
|
|
667
|
-
}
|
|
668
|
-
if (type.startsWith(NATIVE)) {
|
|
669
|
-
throw new ConnectorCallError("invalid_args", "mimeType names the uploaded content, never a Google type; use convertTo to create a Google Doc, Sheet, or Slides file, or create_folder for a folder.");
|
|
670
|
-
}
|
|
671
|
-
return type;
|
|
672
|
-
}
|
|
673
|
-
/** The uploaded bytes and their media type, from exactly one of the two fields. */
|
|
674
|
-
function media(args) {
|
|
675
|
-
const hasText = typeof args["content"] === "string";
|
|
676
|
-
const hasBinary = typeof args["contentBase64"] === "string";
|
|
677
|
-
if (hasText && hasBinary) {
|
|
678
|
-
throw new ConnectorCallError("invalid_args", "Pass content or contentBase64, not both.");
|
|
679
|
-
}
|
|
680
|
-
if (hasText) {
|
|
681
|
-
return {
|
|
682
|
-
bytes: new TextEncoder().encode(args["content"]),
|
|
683
|
-
type: mediaType(args["mimeType"], "text/plain"),
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
if (hasBinary) {
|
|
687
|
-
const bytes = fromBase64(args["contentBase64"]);
|
|
688
|
-
if (bytes.length > BINARY_INLINE_BYTES) {
|
|
689
|
-
throw new ConnectorCallError("invalid_args", `contentBase64 decodes to ${bytes.length} bytes, past this connection's ${BINARY_INLINE_BYTES}-byte upload cap.`);
|
|
690
|
-
}
|
|
691
|
-
return { bytes, type: mediaType(args["mimeType"], "application/octet-stream") };
|
|
692
|
-
}
|
|
693
|
-
return undefined;
|
|
694
|
-
}
|
|
695
|
-
/**
|
|
696
|
-
* One `multipart/related` body: the metadata as JSON, then the media. Built
|
|
697
|
-
* whole as bytes, never a stream, so the shared client's single 401 replay
|
|
698
|
-
* can send it again unchanged.
|
|
699
|
-
*/
|
|
700
|
-
function multipart(metadata, content) {
|
|
701
|
-
const boundary = `connecta-${crypto.randomUUID()}`;
|
|
702
|
-
const encoder = new TextEncoder();
|
|
703
|
-
const head = encoder.encode(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n` +
|
|
704
|
-
`--${boundary}\r\nContent-Type: ${content.type}\r\n\r\n`);
|
|
705
|
-
const tail = encoder.encode(`\r\n--${boundary}--\r\n`);
|
|
706
|
-
const body = new Uint8Array(head.length + content.bytes.length + tail.length);
|
|
707
|
-
body.set(head, 0);
|
|
708
|
-
body.set(content.bytes, head.length);
|
|
709
|
-
body.set(tail, head.length + content.bytes.length);
|
|
710
|
-
return { body, contentType: `multipart/related; boundary=${boundary}` };
|
|
711
|
-
}
|
|
712
|
-
/** The upload host beside an API root: `…/drive/v3` → `…/upload/drive/v3`. */
|
|
713
|
-
function uploadBaseUrl(apiBase) {
|
|
714
|
-
const url = new URL(apiBase);
|
|
715
|
-
return `${url.origin}/upload${url.pathname.replace(/\/+$/, "")}`;
|
|
716
|
-
}
|
|
717
|
-
// --- Schemas ----------------------------------------------------------------------
|
|
718
|
-
function input(properties, required) {
|
|
719
|
-
return { type: "object", properties, required, additionalProperties: false };
|
|
720
|
-
}
|
|
721
|
-
const ID_PATTERN = "^[A-Za-z0-9_-]+$";
|
|
722
|
-
function idProperty(description) {
|
|
723
|
-
return { type: "string", minLength: 1, maxLength: 256, pattern: ID_PATTERN, description };
|
|
724
|
-
}
|
|
725
|
-
const FILE_ID = idProperty("File or folder id, from search_files, list_folder_items, or a Drive URL's /d/<id>.");
|
|
726
|
-
const CURSOR_PROPERTY = {
|
|
727
|
-
type: "string",
|
|
728
|
-
minLength: 1,
|
|
729
|
-
maxLength: MAX_CURSOR_CHARS,
|
|
730
|
-
pattern: "^[A-Za-z0-9_-]+$",
|
|
731
|
-
description: "Opaque page.nextCursor from the previous page. Pass it back unchanged with the same arguments.",
|
|
732
|
-
};
|
|
733
|
-
const MAX_BYTES_PROPERTY = {
|
|
734
|
-
type: "integer",
|
|
735
|
-
minimum: MIN_RESULT_BYTES,
|
|
736
|
-
maximum: MAX_RESULT_BYTES,
|
|
737
|
-
description: `Serialized bytes of the whole result; defaults to ${DEFAULT_RESULT_BYTES}, what execute_code can receive. Raise it, to ${MAX_RESULT_BYTES}, only for a direct call_tool read.`,
|
|
738
|
-
};
|
|
739
|
-
const LIMIT_PROPERTY = {
|
|
740
|
-
type: "integer",
|
|
741
|
-
minimum: 1,
|
|
742
|
-
maximum: MAX_PAGE_SIZE,
|
|
743
|
-
description: `Rows per page, 1 to ${MAX_PAGE_SIZE}; defaults to ${DEFAULT_PAGE_SIZE}. Connecta's cap, below Drive's 1,000.`,
|
|
744
|
-
};
|
|
745
|
-
const ORDER_PROPERTY = {
|
|
746
|
-
type: "string",
|
|
747
|
-
enum: ["modifiedTime desc", "modifiedTime", "name", "name desc", "createdTime desc", "folder,name"],
|
|
748
|
-
description: "Sort order. Drive refuses any sort on a fullText query, which comes back by relevance.",
|
|
749
|
-
};
|
|
750
|
-
const INCLUDE_TRASHED = {
|
|
751
|
-
type: "boolean",
|
|
752
|
-
description: "Include trashed files, which are left out by default.",
|
|
753
|
-
};
|
|
754
|
-
const NAME_PROPERTY = {
|
|
755
|
-
type: "string",
|
|
756
|
-
minLength: 1,
|
|
757
|
-
maxLength: 1000,
|
|
758
|
-
pattern: "^[^\\r\\n]*$",
|
|
759
|
-
description: "File name as Drive shows it, extension included where it has one.",
|
|
760
|
-
};
|
|
761
|
-
const DESCRIPTION_PROPERTY = {
|
|
762
|
-
type: "string",
|
|
763
|
-
maxLength: 10_000,
|
|
764
|
-
description: "Free-text description shown in Drive's details pane.",
|
|
765
|
-
};
|
|
766
|
-
const PARENT_PROPERTY = idProperty("Folder id to place it in, or root for My Drive; defaults to My Drive.");
|
|
767
|
-
const CONTENT_PROPERTY = {
|
|
768
|
-
type: "string",
|
|
769
|
-
maxLength: MAX_UPLOAD_CHARS,
|
|
770
|
-
description: `Text content, up to ${MAX_UPLOAD_CHARS} characters (connecta's cap). Exclusive with contentBase64.`,
|
|
771
|
-
};
|
|
772
|
-
const CONTENT_BASE64_PROPERTY = {
|
|
773
|
-
type: "string",
|
|
774
|
-
maxLength: MAX_UPLOAD_BASE64,
|
|
775
|
-
pattern: "^[A-Za-z0-9+/]*={0,2}$",
|
|
776
|
-
description: `Binary content as base64, up to ${BINARY_INLINE_BYTES} bytes decoded (connecta's cap; Drive allows 5 MB). Exclusive with content.`,
|
|
777
|
-
};
|
|
778
|
-
const MIME_PROPERTY = {
|
|
779
|
-
type: "string",
|
|
780
|
-
maxLength: 255,
|
|
781
|
-
pattern: "^[A-Za-z0-9][\\w.+-]*/[A-Za-z0-9][\\w.+-]*$",
|
|
782
|
-
description: "Media type of the content: text/plain, text/markdown, text/csv, application/pdf. Never a Google type.",
|
|
783
|
-
};
|
|
784
|
-
const ROLE_PROPERTY = {
|
|
785
|
-
type: "string",
|
|
786
|
-
enum: ["reader", "commenter", "writer", "fileOrganizer", "organizer"],
|
|
787
|
-
description: "Access granted. fileOrganizer and organizer exist only on shared drives. Ownership is never granted here.",
|
|
788
|
-
};
|
|
789
|
-
const PERMISSION_ID = idProperty("Permission id from list_permissions.");
|
|
790
|
-
const TRUNCATED_FIELDS = {
|
|
791
|
-
type: "array",
|
|
792
|
-
items: { type: "string" },
|
|
793
|
-
description: "Fields this result shortened (a string ending in …), capped (a list past ten), or left out (a malformed id). get_file keeps longer names.",
|
|
794
|
-
};
|
|
795
|
-
const PAGE_SCHEMA = {
|
|
796
|
-
type: "object",
|
|
797
|
-
properties: {
|
|
798
|
-
hasMore: { type: "boolean" },
|
|
799
|
-
nextCursor: { type: ["string", "null"] },
|
|
800
|
-
},
|
|
801
|
-
required: ["hasMore", "nextCursor"],
|
|
802
|
-
};
|
|
803
|
-
const FILE_PROPERTIES = {
|
|
804
|
-
id: { type: "string" },
|
|
805
|
-
name: { type: "string" },
|
|
806
|
-
mimeType: { type: "string" },
|
|
807
|
-
parents: { type: "array", items: { type: "string" } },
|
|
808
|
-
driveId: { type: "string" },
|
|
809
|
-
size: { type: "integer" },
|
|
810
|
-
modifiedTime: { type: "string" },
|
|
811
|
-
owners: { type: "array", items: { type: "string" } },
|
|
812
|
-
trashed: { type: "boolean" },
|
|
813
|
-
webViewLink: { type: "string" },
|
|
814
|
-
shortcutTargetId: { type: "string" },
|
|
815
|
-
shortcutTargetMimeType: { type: "string" },
|
|
816
|
-
truncatedFields: TRUNCATED_FIELDS,
|
|
817
|
-
};
|
|
818
|
-
// Nothing is required: Google's ProtoJSON omits an empty field, and a
|
|
819
|
-
// projection says only what the response said.
|
|
820
|
-
const FILE_SCHEMA = { type: "object", properties: FILE_PROPERTIES };
|
|
821
|
-
const FILE_DETAIL_SCHEMA = {
|
|
822
|
-
type: "object",
|
|
823
|
-
properties: {
|
|
824
|
-
...FILE_PROPERTIES,
|
|
825
|
-
description: { type: "string" },
|
|
826
|
-
createdTime: { type: "string" },
|
|
827
|
-
modifiedBy: { type: "string" },
|
|
828
|
-
shared: { type: "boolean" },
|
|
829
|
-
starred: { type: "boolean" },
|
|
830
|
-
capabilities: {
|
|
831
|
-
type: "object",
|
|
832
|
-
properties: Object.fromEntries(CAPABILITIES.map((name) => [name, { type: "boolean" }])),
|
|
833
|
-
},
|
|
834
|
-
},
|
|
835
|
-
};
|
|
836
|
-
const FILE_LIST_SCHEMA = {
|
|
837
|
-
type: "object",
|
|
838
|
-
properties: {
|
|
839
|
-
files: { type: "array", items: FILE_SCHEMA },
|
|
840
|
-
incompleteSearch: { type: "boolean" },
|
|
841
|
-
page: PAGE_SCHEMA,
|
|
842
|
-
},
|
|
843
|
-
required: ["files", "page"],
|
|
844
|
-
};
|
|
845
|
-
const PERMISSION_SCHEMA = {
|
|
846
|
-
type: "object",
|
|
847
|
-
properties: {
|
|
848
|
-
id: { type: "string" },
|
|
849
|
-
type: { type: "string", enum: ["user", "group", "domain", "anyone"] },
|
|
850
|
-
role: { type: "string" },
|
|
851
|
-
emailAddress: { type: "string" },
|
|
852
|
-
domain: { type: "string" },
|
|
853
|
-
displayName: { type: "string" },
|
|
854
|
-
allowFileDiscovery: { type: "boolean" },
|
|
855
|
-
expirationTime: { type: "string" },
|
|
856
|
-
inherited: { type: "boolean" },
|
|
857
|
-
inheritedFrom: { type: "string" },
|
|
858
|
-
deleted: { type: "boolean" },
|
|
859
|
-
truncatedFields: TRUNCATED_FIELDS,
|
|
860
|
-
},
|
|
861
|
-
};
|
|
862
|
-
// --- Tools ------------------------------------------------------------------------
|
|
863
|
-
function tools(client, upload) {
|
|
864
|
-
const readOnly = { readOnlyHint: true };
|
|
865
|
-
// Additive: creates something new that discloses nothing, and changes
|
|
866
|
-
// nothing that existed.
|
|
867
|
-
const additive = { readOnlyHint: false, destructiveHint: false };
|
|
868
|
-
// Overwrites, moves, trashes, or changes who can see a file.
|
|
869
|
-
const destructive = { readOnlyHint: false, destructiveHint: true };
|
|
870
|
-
const filePath = (id) => `/files/${encodeURIComponent(id)}`;
|
|
871
|
-
const list = (ctx, tool, args, query) =>
|
|
872
|
-
// The Drive query decides the rows: folder, search, corpus, drive, order.
|
|
873
|
-
pageOf("files", args, { tool, ...query }, async (pageToken, pageSize) => {
|
|
874
|
-
const listing = asRecord(await client.json({
|
|
875
|
-
method: "GET",
|
|
876
|
-
path: "/files",
|
|
877
|
-
query: {
|
|
878
|
-
...ALL_DRIVES,
|
|
879
|
-
includeItemsFromAllDrives: true,
|
|
880
|
-
fields: `nextPageToken,incompleteSearch,files(${SUMMARY_FIELDS})`,
|
|
881
|
-
...query,
|
|
882
|
-
pageSize,
|
|
883
|
-
pageToken,
|
|
884
|
-
},
|
|
885
|
-
}, ctx));
|
|
886
|
-
return {
|
|
887
|
-
ids: asArray(listing["files"]).map((file) => String(asRecord(file)["id"] ?? "")),
|
|
888
|
-
rows: asArray(listing["files"]).map((file) => projectFile(file)),
|
|
889
|
-
next: text(listing["nextPageToken"]),
|
|
890
|
-
// Drive's own truncation: it stopped before searching every corpus.
|
|
891
|
-
extra: listing["incompleteSearch"] === true ? { incompleteSearch: true } : undefined,
|
|
892
|
-
};
|
|
893
|
-
});
|
|
894
|
-
const patch = async (ctx, fileId, body, query = {}, options = {}) => projectFile(await client.json({
|
|
895
|
-
method: "PATCH",
|
|
896
|
-
path: filePath(fileId),
|
|
897
|
-
query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS, ...query },
|
|
898
|
-
body,
|
|
899
|
-
}, ctx, options), fileId);
|
|
900
|
-
return [
|
|
901
|
-
{
|
|
902
|
-
name: "search_files",
|
|
903
|
-
description: "Search Drive files the user can open with Drive query syntax, returning name, type, folder, size, and link per file. Content needs get_file_content.",
|
|
904
|
-
annotations: readOnly,
|
|
905
|
-
inputSchema: input({
|
|
906
|
-
query: {
|
|
907
|
-
type: "string",
|
|
908
|
-
maxLength: 2048,
|
|
909
|
-
description: "Drive query: name contains 'budget', fullText contains 'elders', mimeType = 'application/pdf', modifiedTime > '2026-01-01T00:00:00', 'me' in owners. Omit for everything.",
|
|
910
|
-
},
|
|
911
|
-
corpora: {
|
|
912
|
-
type: "string",
|
|
913
|
-
enum: ["user", "domain", "drive", "allDrives"],
|
|
914
|
-
description: "Where to look: user (default; My Drive and shared with me), drive (one shared drive, with driveId), allDrives (slower, may be incomplete), domain.",
|
|
915
|
-
},
|
|
916
|
-
driveId: idProperty("Shared drive id from list_shared_drives; required with corpora drive, refused otherwise."),
|
|
917
|
-
orderBy: ORDER_PROPERTY,
|
|
918
|
-
includeTrashed: INCLUDE_TRASHED,
|
|
919
|
-
limit: LIMIT_PROPERTY,
|
|
920
|
-
cursor: CURSOR_PROPERTY,
|
|
921
|
-
maxBytes: MAX_BYTES_PROPERTY,
|
|
922
|
-
}, []),
|
|
923
|
-
outputSchema: FILE_LIST_SCHEMA,
|
|
924
|
-
handler: async (args, ctx) => {
|
|
925
|
-
const corpora = args["corpora"] ?? "user";
|
|
926
|
-
if ((corpora === "drive") !== (args["driveId"] !== undefined)) {
|
|
927
|
-
throw new ConnectorCallError("invalid_args", "driveId goes with corpora drive and only with it; find shared drive ids with list_shared_drives.");
|
|
928
|
-
}
|
|
929
|
-
const query = text(args["query"]);
|
|
930
|
-
const q = args["includeTrashed"] === true ? query : query ? `(${query}) and trashed = false` : "trashed = false";
|
|
931
|
-
return await list(ctx, "search_files", args, {
|
|
932
|
-
q,
|
|
933
|
-
corpora,
|
|
934
|
-
driveId: args["driveId"],
|
|
935
|
-
orderBy: args["orderBy"],
|
|
936
|
-
});
|
|
937
|
-
},
|
|
938
|
-
},
|
|
939
|
-
{
|
|
940
|
-
name: "list_folder_items",
|
|
941
|
-
description: "List the files and folders directly inside one Drive folder, in My Drive or a shared drive. Not recursive: list a subfolder by its own id.",
|
|
942
|
-
annotations: readOnly,
|
|
943
|
-
inputSchema: input({
|
|
944
|
-
folderId: idProperty("Folder id, a shared drive id for its top level, or root for My Drive."),
|
|
945
|
-
orderBy: { ...ORDER_PROPERTY, description: "Sort order; defaults to folders first, then by name." },
|
|
946
|
-
includeTrashed: INCLUDE_TRASHED,
|
|
947
|
-
limit: LIMIT_PROPERTY,
|
|
948
|
-
cursor: CURSOR_PROPERTY,
|
|
949
|
-
maxBytes: MAX_BYTES_PROPERTY,
|
|
950
|
-
}, ["folderId"]),
|
|
951
|
-
outputSchema: FILE_LIST_SCHEMA,
|
|
952
|
-
handler: async (args, ctx) => {
|
|
953
|
-
const folderId = String(args["folderId"]);
|
|
954
|
-
// A shared drive folder is listed within its drive, which Drive
|
|
955
|
-
// searches only when asked by id; the folder itself says which.
|
|
956
|
-
let driveId;
|
|
957
|
-
if (folderId !== "root") {
|
|
958
|
-
const folder = asRecord(await client.json({
|
|
959
|
-
method: "GET",
|
|
960
|
-
path: filePath(folderId),
|
|
961
|
-
query: { ...ALL_DRIVES, fields: "id,mimeType,driveId" },
|
|
962
|
-
}, ctx));
|
|
963
|
-
if (folder["mimeType"] !== FOLDER) {
|
|
964
|
-
throw new ConnectorCallError("invalid_args", `${folderId} is not a folder (${String(folder["mimeType"] ?? "unknown type")}); read it with get_file or get_file_content.`);
|
|
965
|
-
}
|
|
966
|
-
driveId = text(folder["driveId"]);
|
|
967
|
-
}
|
|
968
|
-
const q = `'${folderId}' in parents${args["includeTrashed"] === true ? "" : " and trashed = false"}`;
|
|
969
|
-
return await list(ctx, "list_folder_items", args, {
|
|
970
|
-
q,
|
|
971
|
-
corpora: driveId ? "drive" : "user",
|
|
972
|
-
driveId,
|
|
973
|
-
orderBy: args["orderBy"] ?? "folder,name",
|
|
974
|
-
});
|
|
975
|
-
},
|
|
976
|
-
},
|
|
977
|
-
{
|
|
978
|
-
name: "get_file",
|
|
979
|
-
description: "Get one Drive file's metadata: name, type, folder, owners, sharing flag, description, and what this user may do with it. Never its content.",
|
|
980
|
-
annotations: readOnly,
|
|
981
|
-
inputSchema: input({ fileId: FILE_ID }, ["fileId"]),
|
|
982
|
-
outputSchema: FILE_DETAIL_SCHEMA,
|
|
983
|
-
handler: async (args, ctx) => projectFileDetail(await client.json({
|
|
984
|
-
method: "GET",
|
|
985
|
-
path: filePath(args["fileId"]),
|
|
986
|
-
query: { ...ALL_DRIVES, fields: DETAIL_FIELDS },
|
|
987
|
-
}, ctx), String(args["fileId"])),
|
|
988
|
-
},
|
|
989
|
-
{
|
|
990
|
-
name: "get_file_content",
|
|
991
|
-
description: "Read a Drive file's content: Docs as Markdown, Sheets as CSV of the first sheet, Slides as text, text files as text, small binaries as base64. Never drawings or folders.",
|
|
992
|
-
annotations: readOnly,
|
|
993
|
-
inputSchema: input({
|
|
994
|
-
fileId: FILE_ID,
|
|
995
|
-
maxChars: {
|
|
996
|
-
type: "integer",
|
|
997
|
-
minimum: 1,
|
|
998
|
-
maximum: MAX_CONTENT_CHARS,
|
|
999
|
-
description: `Characters (code points) of text kept, 1 to ${MAX_CONTENT_CHARS}; defaults to ${DEFAULT_CONTENT_CHARS}. Longer text ends with a truncation marker. Binaries ignore it.`,
|
|
1000
|
-
},
|
|
1001
|
-
maxBytes: MAX_BYTES_PROPERTY,
|
|
1002
|
-
}, ["fileId"]),
|
|
1003
|
-
outputSchema: {
|
|
1004
|
-
type: "object",
|
|
1005
|
-
properties: {
|
|
1006
|
-
id: { type: "string" },
|
|
1007
|
-
name: { type: "string" },
|
|
1008
|
-
mimeType: { type: "string" },
|
|
1009
|
-
size: { type: "integer" },
|
|
1010
|
-
truncatedFields: TRUNCATED_FIELDS,
|
|
1011
|
-
webViewLink: { type: "string" },
|
|
1012
|
-
format: { type: "string", enum: ["markdown", "csv", "text", "base64", "unavailable"] },
|
|
1013
|
-
exportedAs: { type: "string" },
|
|
1014
|
-
content: { type: "string" },
|
|
1015
|
-
contentTruncated: { type: "boolean" },
|
|
1016
|
-
note: { type: "string" },
|
|
1017
|
-
},
|
|
1018
|
-
required: ["id", "format", "contentTruncated"],
|
|
1019
|
-
},
|
|
1020
|
-
handler: async (args, ctx) => await readContent(client, ctx, String(args["fileId"]), typeof args["maxChars"] === "number" ? args["maxChars"] : DEFAULT_CONTENT_CHARS, typeof args["maxBytes"] === "number" ? args["maxBytes"] : DEFAULT_RESULT_BYTES),
|
|
1021
|
-
},
|
|
1022
|
-
{
|
|
1023
|
-
name: "list_permissions",
|
|
1024
|
-
description: "List who a Drive file or folder is shared with: each permission's id, type, role, and address or domain. Use the ids with update_permission or delete_permission.",
|
|
1025
|
-
annotations: readOnly,
|
|
1026
|
-
inputSchema: input({ fileId: FILE_ID, limit: LIMIT_PROPERTY, cursor: CURSOR_PROPERTY, maxBytes: MAX_BYTES_PROPERTY }, ["fileId"]),
|
|
1027
|
-
outputSchema: {
|
|
1028
|
-
type: "object",
|
|
1029
|
-
properties: { permissions: { type: "array", items: PERMISSION_SCHEMA }, page: PAGE_SCHEMA },
|
|
1030
|
-
required: ["permissions", "page"],
|
|
1031
|
-
},
|
|
1032
|
-
handler: async (args, ctx) => await pageOf("permissions", args, { tool: "list_permissions", fileId: args["fileId"] }, async (pageToken, pageSize) => {
|
|
1033
|
-
const listing = asRecord(await client.json({
|
|
1034
|
-
method: "GET",
|
|
1035
|
-
path: `${filePath(args["fileId"])}/permissions`,
|
|
1036
|
-
query: {
|
|
1037
|
-
...ALL_DRIVES,
|
|
1038
|
-
fields: `nextPageToken,permissions(${PERMISSION_FIELDS})`,
|
|
1039
|
-
pageSize,
|
|
1040
|
-
pageToken,
|
|
1041
|
-
},
|
|
1042
|
-
}, ctx));
|
|
1043
|
-
return {
|
|
1044
|
-
ids: asArray(listing["permissions"]).map((permission) => String(asRecord(permission)["id"] ?? "")),
|
|
1045
|
-
rows: asArray(listing["permissions"]).map((permission) => projectPermission(permission)),
|
|
1046
|
-
next: text(listing["nextPageToken"]),
|
|
1047
|
-
};
|
|
1048
|
-
}),
|
|
1049
|
-
},
|
|
1050
|
-
{
|
|
1051
|
-
name: "list_shared_drives",
|
|
1052
|
-
description: "List the shared drives the user is a member of, with their ids for search_files and list_folder_items. Not the files inside them.",
|
|
1053
|
-
annotations: readOnly,
|
|
1054
|
-
inputSchema: input({
|
|
1055
|
-
query: {
|
|
1056
|
-
type: "string",
|
|
1057
|
-
maxLength: 1024,
|
|
1058
|
-
description: "Shared drive query, e.g. name contains 'Finance'. Omit for every drive.",
|
|
1059
|
-
},
|
|
1060
|
-
limit: LIMIT_PROPERTY,
|
|
1061
|
-
cursor: CURSOR_PROPERTY,
|
|
1062
|
-
maxBytes: MAX_BYTES_PROPERTY,
|
|
1063
|
-
}, []),
|
|
1064
|
-
outputSchema: {
|
|
1065
|
-
type: "object",
|
|
1066
|
-
properties: {
|
|
1067
|
-
drives: {
|
|
1068
|
-
type: "array",
|
|
1069
|
-
items: {
|
|
1070
|
-
type: "object",
|
|
1071
|
-
properties: {
|
|
1072
|
-
id: { type: "string" },
|
|
1073
|
-
name: { type: "string" },
|
|
1074
|
-
hidden: { type: "boolean" },
|
|
1075
|
-
truncatedFields: TRUNCATED_FIELDS,
|
|
1076
|
-
},
|
|
1077
|
-
},
|
|
1078
|
-
},
|
|
1079
|
-
page: PAGE_SCHEMA,
|
|
1080
|
-
},
|
|
1081
|
-
required: ["drives", "page"],
|
|
1082
|
-
},
|
|
1083
|
-
handler: async (args, ctx) => await pageOf("drives", args, { tool: "list_shared_drives", q: args["query"] }, async (pageToken, pageSize) => {
|
|
1084
|
-
const listing = asRecord(await client.json({
|
|
1085
|
-
method: "GET",
|
|
1086
|
-
path: "/drives",
|
|
1087
|
-
query: {
|
|
1088
|
-
q: args["query"],
|
|
1089
|
-
fields: "nextPageToken,drives(id,name,hidden)",
|
|
1090
|
-
pageSize,
|
|
1091
|
-
pageToken,
|
|
1092
|
-
},
|
|
1093
|
-
}, ctx));
|
|
1094
|
-
return {
|
|
1095
|
-
ids: asArray(listing["drives"]).map((value) => String(asRecord(value)["id"] ?? "")),
|
|
1096
|
-
rows: asArray(listing["drives"]).map((value) => {
|
|
1097
|
-
const sharedDrive = asRecord(value);
|
|
1098
|
-
const cuts = new Cuts();
|
|
1099
|
-
return compact({
|
|
1100
|
-
id: cuts.id("id", sharedDrive["id"]),
|
|
1101
|
-
name: cuts.label("name", sharedDrive["name"]),
|
|
1102
|
-
hidden: sharedDrive["hidden"] === true ? true : undefined,
|
|
1103
|
-
truncatedFields: cuts.fields,
|
|
1104
|
-
});
|
|
1105
|
-
}),
|
|
1106
|
-
next: text(listing["nextPageToken"]),
|
|
1107
|
-
};
|
|
1108
|
-
}),
|
|
1109
|
-
},
|
|
1110
|
-
{
|
|
1111
|
-
name: "create_folder",
|
|
1112
|
-
description: "Create a new, empty Drive folder, in My Drive or inside another folder. Shares nothing beyond what the parent folder already grants.",
|
|
1113
|
-
annotations: additive,
|
|
1114
|
-
inputSchema: input({ name: NAME_PROPERTY, parentId: PARENT_PROPERTY, description: DESCRIPTION_PROPERTY }, [
|
|
1115
|
-
"name",
|
|
1116
|
-
]),
|
|
1117
|
-
outputSchema: FILE_SCHEMA,
|
|
1118
|
-
handler: async (args, ctx) => projectFile(await adding(() => client.json({
|
|
1119
|
-
method: "POST",
|
|
1120
|
-
path: "/files",
|
|
1121
|
-
query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS },
|
|
1122
|
-
body: compact({
|
|
1123
|
-
name: args["name"],
|
|
1124
|
-
mimeType: FOLDER,
|
|
1125
|
-
parents: args["parentId"] ? [args["parentId"]] : undefined,
|
|
1126
|
-
description: args["description"],
|
|
1127
|
-
}),
|
|
1128
|
-
}, ctx), CHECK_CREATED)),
|
|
1129
|
-
},
|
|
1130
|
-
{
|
|
1131
|
-
name: "create_file",
|
|
1132
|
-
description: "Create a Drive file from text or base64 content, or an empty one; convertTo makes a Google Doc, Sheet, or Slides file. Whoever can see its folder can see it.",
|
|
1133
|
-
// Destructive though nothing is overwritten: a new file takes its
|
|
1134
|
-
// folder's sharing, so content written into a shared folder is
|
|
1135
|
-
// disclosed to everyone it is shared with, at once.
|
|
1136
|
-
annotations: destructive,
|
|
1137
|
-
inputSchema: input({
|
|
1138
|
-
name: NAME_PROPERTY,
|
|
1139
|
-
parentId: PARENT_PROPERTY,
|
|
1140
|
-
content: CONTENT_PROPERTY,
|
|
1141
|
-
contentBase64: CONTENT_BASE64_PROPERTY,
|
|
1142
|
-
mimeType: {
|
|
1143
|
-
...MIME_PROPERTY,
|
|
1144
|
-
description: `${MIME_PROPERTY.description} Defaults to text/plain or application/octet-stream.`,
|
|
1145
|
-
},
|
|
1146
|
-
convertTo: {
|
|
1147
|
-
type: "string",
|
|
1148
|
-
enum: ["document", "spreadsheet", "presentation"],
|
|
1149
|
-
description: "Import as a Google type: markdown, HTML, or text to document; CSV to spreadsheet. Without content, an empty one.",
|
|
1150
|
-
},
|
|
1151
|
-
description: DESCRIPTION_PROPERTY,
|
|
1152
|
-
}, ["name"]),
|
|
1153
|
-
outputSchema: FILE_SCHEMA,
|
|
1154
|
-
handler: async (args, ctx) => {
|
|
1155
|
-
const content = media(args);
|
|
1156
|
-
const convert = typeof args["convertTo"] === "string" ? CONVERSIONS[args["convertTo"]] : undefined;
|
|
1157
|
-
const metadata = compact({
|
|
1158
|
-
name: args["name"],
|
|
1159
|
-
parents: args["parentId"] ? [args["parentId"]] : undefined,
|
|
1160
|
-
description: args["description"],
|
|
1161
|
-
mimeType: convert ?? content?.type,
|
|
1162
|
-
});
|
|
1163
|
-
if (!content) {
|
|
1164
|
-
if (args["mimeType"] !== undefined) {
|
|
1165
|
-
throw new ConnectorCallError("invalid_args", "mimeType describes content; pass content or contentBase64 with it.");
|
|
1166
|
-
}
|
|
1167
|
-
return projectFile(await adding(() => client.json({ method: "POST", path: "/files", query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS }, body: metadata }, ctx), CHECK_CREATED));
|
|
1168
|
-
}
|
|
1169
|
-
const framed = multipart(metadata, content);
|
|
1170
|
-
return projectFile(await adding(() => upload.json({
|
|
1171
|
-
method: "POST",
|
|
1172
|
-
path: "/files",
|
|
1173
|
-
query: { ...ALL_DRIVES, uploadType: "multipart", fields: SUMMARY_FIELDS },
|
|
1174
|
-
headers: { "Content-Type": framed.contentType },
|
|
1175
|
-
rawBody: framed.body,
|
|
1176
|
-
}, ctx), CHECK_CREATED));
|
|
1177
|
-
},
|
|
1178
|
-
},
|
|
1179
|
-
{
|
|
1180
|
-
name: "update_file_content",
|
|
1181
|
-
description: "Replace a Drive file's entire content with new text or base64 bytes; the old content survives only in Drive's version history. Keeps name, folder, and sharing.",
|
|
1182
|
-
// Destructive: the whole content is overwritten. Drive keeps revisions
|
|
1183
|
-
// for a while, but this connection offers no way back to one.
|
|
1184
|
-
annotations: destructive,
|
|
1185
|
-
inputSchema: input({
|
|
1186
|
-
fileId: FILE_ID,
|
|
1187
|
-
content: CONTENT_PROPERTY,
|
|
1188
|
-
contentBase64: CONTENT_BASE64_PROPERTY,
|
|
1189
|
-
mimeType: {
|
|
1190
|
-
...MIME_PROPERTY,
|
|
1191
|
-
description: "Media type of the new content: usually the file's own, text/markdown for a Google Doc, text/csv for a Sheet (replacing every tab).",
|
|
1192
|
-
},
|
|
1193
|
-
}, ["fileId", "mimeType"]),
|
|
1194
|
-
outputSchema: FILE_SCHEMA,
|
|
1195
|
-
handler: async (args, ctx) => {
|
|
1196
|
-
const content = media(args);
|
|
1197
|
-
if (!content) {
|
|
1198
|
-
throw new ConnectorCallError("invalid_args", "Pass content or contentBase64: the file's new content.");
|
|
1199
|
-
}
|
|
1200
|
-
return projectFile(await upload.json({
|
|
1201
|
-
method: "PATCH",
|
|
1202
|
-
path: filePath(args["fileId"]),
|
|
1203
|
-
query: { ...ALL_DRIVES, uploadType: "media", fields: SUMMARY_FIELDS },
|
|
1204
|
-
headers: { "Content-Type": content.type },
|
|
1205
|
-
rawBody: content.bytes,
|
|
1206
|
-
}, ctx,
|
|
1207
|
-
// The whole content, replaced: a second send leaves the same file.
|
|
1208
|
-
IDEMPOTENT), String(args["fileId"]));
|
|
1209
|
-
},
|
|
1210
|
-
},
|
|
1211
|
-
{
|
|
1212
|
-
name: "update_file",
|
|
1213
|
-
description: "Rename a Drive file or folder, or replace its description. Never its content, folder, or sharing: use update_file_content, move_file, or share_file.",
|
|
1214
|
-
// Destructive: the old name or description is overwritten, and a
|
|
1215
|
-
// rename can break what finds the file by name.
|
|
1216
|
-
annotations: destructive,
|
|
1217
|
-
inputSchema: input({ fileId: FILE_ID, name: NAME_PROPERTY, description: DESCRIPTION_PROPERTY }, ["fileId"]),
|
|
1218
|
-
outputSchema: FILE_SCHEMA,
|
|
1219
|
-
handler: async (args, ctx) => {
|
|
1220
|
-
if (args["name"] === undefined && args["description"] === undefined) {
|
|
1221
|
-
throw new ConnectorCallError("invalid_args", "Pass name, description, or both.");
|
|
1222
|
-
}
|
|
1223
|
-
return patch(ctx, args["fileId"], compact({ name: args["name"], description: args["description"] }), {}, IDEMPOTENT);
|
|
1224
|
-
},
|
|
1225
|
-
},
|
|
1226
|
-
{
|
|
1227
|
-
name: "move_file",
|
|
1228
|
-
description: "Move a Drive file or folder into another folder or shared drive, out of the folder it is in now. Can change who has access to it.",
|
|
1229
|
-
// Destructive: a file inherits sharing from its folder, so a move can
|
|
1230
|
-
// revoke access granted through the old folder or grant it through the
|
|
1231
|
-
// new one, and moving into a shared drive hands ownership to the drive.
|
|
1232
|
-
annotations: destructive,
|
|
1233
|
-
inputSchema: input({
|
|
1234
|
-
fileId: FILE_ID,
|
|
1235
|
-
folderId: idProperty("Destination folder id, a shared drive id for its top level, or root for My Drive."),
|
|
1236
|
-
}, ["fileId", "folderId"]),
|
|
1237
|
-
outputSchema: FILE_SCHEMA,
|
|
1238
|
-
handler: async (args, ctx) => {
|
|
1239
|
-
const current = asRecord(await client.json({ method: "GET", path: filePath(args["fileId"]), query: { ...ALL_DRIVES, fields: "parents" } }, ctx));
|
|
1240
|
-
const from = idsOf(current["parents"]).filter((id) => id !== args["folderId"]);
|
|
1241
|
-
return patch(ctx, args["fileId"], {}, {
|
|
1242
|
-
addParents: args["folderId"],
|
|
1243
|
-
removeParents: from.length > 0 ? from.join(",") : undefined,
|
|
1244
|
-
});
|
|
1245
|
-
},
|
|
1246
|
-
},
|
|
1247
|
-
{
|
|
1248
|
-
name: "copy_file",
|
|
1249
|
-
description: "Copy a Drive file into a new file, optionally renamed or in another folder. The copy is shared like its folder, not like the original; no folders.",
|
|
1250
|
-
// Destructive though nothing is overwritten: the copy takes its
|
|
1251
|
-
// destination folder's sharing, so a private file copied into a shared
|
|
1252
|
-
// folder is disclosed — the same exposure move_file carries.
|
|
1253
|
-
annotations: destructive,
|
|
1254
|
-
inputSchema: input({
|
|
1255
|
-
fileId: FILE_ID,
|
|
1256
|
-
name: { ...NAME_PROPERTY, description: 'Name of the copy; defaults to Drive\'s "Copy of …".' },
|
|
1257
|
-
parentId: { ...PARENT_PROPERTY, description: "Folder for the copy; defaults to the original's." },
|
|
1258
|
-
}, ["fileId"]),
|
|
1259
|
-
outputSchema: FILE_SCHEMA,
|
|
1260
|
-
handler: async (args, ctx) => projectFile(await adding(() => client.json({
|
|
1261
|
-
method: "POST",
|
|
1262
|
-
path: `${filePath(args["fileId"])}/copy`,
|
|
1263
|
-
query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS },
|
|
1264
|
-
body: compact({ name: args["name"], parents: args["parentId"] ? [args["parentId"]] : undefined }),
|
|
1265
|
-
}, ctx), "Look for the copy with search_files or list_folder_items before copying again: a second call makes a second copy.")),
|
|
1266
|
-
},
|
|
1267
|
-
{
|
|
1268
|
-
name: "trash_file",
|
|
1269
|
-
description: "Move a Drive file or folder to the trash, where Drive deletes it for good after 30 days; restore_file undoes it. Never deletes permanently itself.",
|
|
1270
|
-
// Destructive: collaborators lose the file at once, and the trash
|
|
1271
|
-
// empties itself.
|
|
1272
|
-
annotations: destructive,
|
|
1273
|
-
inputSchema: input({ fileId: FILE_ID }, ["fileId"]),
|
|
1274
|
-
outputSchema: FILE_SCHEMA,
|
|
1275
|
-
handler: async (args, ctx) => patch(ctx, args["fileId"], { trashed: true }, {}, IDEMPOTENT),
|
|
1276
|
-
},
|
|
1277
|
-
{
|
|
1278
|
-
name: "restore_file",
|
|
1279
|
-
description: "Restore (untrash) a trashed Drive file or folder to where it was. Cannot recover a file already deleted from the trash.",
|
|
1280
|
-
// Additive: brings back exactly what was there, losing nothing.
|
|
1281
|
-
annotations: additive,
|
|
1282
|
-
inputSchema: input({ fileId: FILE_ID }, ["fileId"]),
|
|
1283
|
-
outputSchema: FILE_SCHEMA,
|
|
1284
|
-
handler: async (args, ctx) => patch(ctx, args["fileId"], { trashed: false }, {}, IDEMPOTENT),
|
|
1285
|
-
},
|
|
1286
|
-
{
|
|
1287
|
-
name: "share_file",
|
|
1288
|
-
description: "Share a Drive file or folder with a user, group, domain, or anyone with the link, at a role up to writer. Sends no email unless asked; never transfers ownership.",
|
|
1289
|
-
annotations: destructive,
|
|
1290
|
-
inputSchema: input({
|
|
1291
|
-
fileId: FILE_ID,
|
|
1292
|
-
type: {
|
|
1293
|
-
type: "string",
|
|
1294
|
-
enum: ["user", "group", "domain", "anyone"],
|
|
1295
|
-
description: "Grantee kind: user or group needs emailAddress, domain needs domain, anyone means anyone with the link.",
|
|
1296
|
-
},
|
|
1297
|
-
role: ROLE_PROPERTY,
|
|
1298
|
-
emailAddress: {
|
|
1299
|
-
type: "string",
|
|
1300
|
-
minLength: 3,
|
|
1301
|
-
maxLength: 320,
|
|
1302
|
-
pattern: "^[^\\s@]+@[^\\s@]+$",
|
|
1303
|
-
description: "The user's or group's email address, for type user or group only.",
|
|
1304
|
-
},
|
|
1305
|
-
domain: {
|
|
1306
|
-
type: "string",
|
|
1307
|
-
minLength: 1,
|
|
1308
|
-
maxLength: 253,
|
|
1309
|
-
pattern: "^[A-Za-z0-9.-]+$",
|
|
1310
|
-
description: "The Workspace domain, for type domain only.",
|
|
1311
|
-
},
|
|
1312
|
-
allowFileDiscovery: {
|
|
1313
|
-
type: "boolean",
|
|
1314
|
-
description: "For domain or anyone: whether it appears in search rather than needing the link. Defaults to false.",
|
|
1315
|
-
},
|
|
1316
|
-
sendNotificationEmail: {
|
|
1317
|
-
type: "boolean",
|
|
1318
|
-
description: "Email a user or group that it was shared. Defaults to false; Drive requires it for a non-Google address.",
|
|
1319
|
-
},
|
|
1320
|
-
emailMessage: {
|
|
1321
|
-
type: "string",
|
|
1322
|
-
maxLength: 2000,
|
|
1323
|
-
description: "Message in the notification email; only with sendNotificationEmail true.",
|
|
1324
|
-
},
|
|
1325
|
-
}, ["fileId", "type", "role"]),
|
|
1326
|
-
outputSchema: PERMISSION_SCHEMA,
|
|
1327
|
-
handler: async (args, ctx) => {
|
|
1328
|
-
const type = String(args["type"]);
|
|
1329
|
-
const person = type === "user" || type === "group";
|
|
1330
|
-
const refuse = (message) => {
|
|
1331
|
-
throw new ConnectorCallError("invalid_args", message);
|
|
1332
|
-
};
|
|
1333
|
-
if (person !== (args["emailAddress"] !== undefined))
|
|
1334
|
-
refuse("emailAddress goes with type user or group, and only with them.");
|
|
1335
|
-
if ((type === "domain") !== (args["domain"] !== undefined))
|
|
1336
|
-
refuse("domain goes with type domain, and only with it.");
|
|
1337
|
-
if (person && args["allowFileDiscovery"] !== undefined)
|
|
1338
|
-
refuse("allowFileDiscovery applies to type domain or anyone only.");
|
|
1339
|
-
if (!person && args["sendNotificationEmail"] !== undefined)
|
|
1340
|
-
refuse("sendNotificationEmail applies to a user or group only.");
|
|
1341
|
-
const notify = person ? args["sendNotificationEmail"] === true : undefined;
|
|
1342
|
-
if (args["emailMessage"] !== undefined && notify !== true)
|
|
1343
|
-
refuse("emailMessage needs sendNotificationEmail true.");
|
|
1344
|
-
return projectPermission(await adding(() => client.json({
|
|
1345
|
-
method: "POST",
|
|
1346
|
-
path: `${filePath(args["fileId"])}/permissions`,
|
|
1347
|
-
query: {
|
|
1348
|
-
...ALL_DRIVES,
|
|
1349
|
-
fields: PERMISSION_FIELDS,
|
|
1350
|
-
// Drive's default is to email; this connection's is not to.
|
|
1351
|
-
sendNotificationEmail: notify,
|
|
1352
|
-
emailMessage: args["emailMessage"],
|
|
1353
|
-
},
|
|
1354
|
-
body: compact({
|
|
1355
|
-
type,
|
|
1356
|
-
role: args["role"],
|
|
1357
|
-
emailAddress: args["emailAddress"],
|
|
1358
|
-
domain: args["domain"],
|
|
1359
|
-
allowFileDiscovery: person ? undefined : args["allowFileDiscovery"] === true,
|
|
1360
|
-
}),
|
|
1361
|
-
}, ctx), "Check list_permissions before sharing again: the share may already be in place, and its notification sent."));
|
|
1362
|
-
},
|
|
1363
|
-
},
|
|
1364
|
-
{
|
|
1365
|
-
name: "update_permission",
|
|
1366
|
-
description: "Change the role of one existing share on a Drive file or folder. Never makes anyone an owner; an inherited share is changed on the folder it comes from.",
|
|
1367
|
-
annotations: destructive,
|
|
1368
|
-
inputSchema: input({ fileId: FILE_ID, permissionId: PERMISSION_ID, role: ROLE_PROPERTY }, [
|
|
1369
|
-
"fileId",
|
|
1370
|
-
"permissionId",
|
|
1371
|
-
"role",
|
|
1372
|
-
]),
|
|
1373
|
-
outputSchema: PERMISSION_SCHEMA,
|
|
1374
|
-
handler: async (args, ctx) => projectPermission(await client.json({
|
|
1375
|
-
method: "PATCH",
|
|
1376
|
-
path: `${filePath(args["fileId"])}/permissions/${encodeURIComponent(args["permissionId"])}`,
|
|
1377
|
-
query: { ...ALL_DRIVES, fields: PERMISSION_FIELDS },
|
|
1378
|
-
body: { role: args["role"] },
|
|
1379
|
-
}, ctx, IDEMPOTENT), String(args["permissionId"])),
|
|
1380
|
-
},
|
|
1381
|
-
{
|
|
1382
|
-
name: "delete_permission",
|
|
1383
|
-
description: "Remove one share from a Drive file or folder, revoking that user's, group's, domain's, or link's access. Never deletes the file; an owner cannot be removed.",
|
|
1384
|
-
annotations: destructive,
|
|
1385
|
-
inputSchema: input({ fileId: FILE_ID, permissionId: PERMISSION_ID }, ["fileId", "permissionId"]),
|
|
1386
|
-
outputSchema: {
|
|
1387
|
-
type: "object",
|
|
1388
|
-
properties: {
|
|
1389
|
-
fileId: { type: "string" },
|
|
1390
|
-
permissionId: { type: "string" },
|
|
1391
|
-
deleted: { type: "boolean", const: true },
|
|
1392
|
-
},
|
|
1393
|
-
required: ["fileId", "permissionId", "deleted"],
|
|
1394
|
-
},
|
|
1395
|
-
handler: async (args, ctx) => {
|
|
1396
|
-
await client.json({
|
|
1397
|
-
method: "DELETE",
|
|
1398
|
-
path: `${filePath(args["fileId"])}/permissions/${encodeURIComponent(args["permissionId"])}`,
|
|
1399
|
-
query: ALL_DRIVES,
|
|
1400
|
-
}, ctx);
|
|
1401
|
-
return { fileId: args["fileId"], permissionId: args["permissionId"], deleted: true };
|
|
1402
|
-
},
|
|
1403
|
-
},
|
|
1404
|
-
];
|
|
1405
|
-
}
|
|
1406
|
-
/**
|
|
1407
|
-
* The last word on size, around every tool and every way it can return:
|
|
1408
|
-
* whatever a handler built, a result past the call's `maxBytes` — or the
|
|
1409
|
-
* shared budget, for a tool without one — is refused rather than delivered.
|
|
1410
|
-
* Each source is budgeted where it is built, so this should never fire; when
|
|
1411
|
-
* it does, the caller learns why instead of a result execute_code cannot
|
|
1412
|
-
* receive.
|
|
1413
|
-
*
|
|
1414
|
-
* A write handler returns only from Google's recorded 2xx to its write — the
|
|
1415
|
-
* shared client throws on every other status, a redirect included, and on a
|
|
1416
|
-
* reply it could not read — so a write that gets here has applied, and only
|
|
1417
|
-
* here does this connection say so, pointing at a re-read rather than a
|
|
1418
|
-
* repeat.
|
|
1419
|
-
*/
|
|
1420
|
-
function guarded(list) {
|
|
1421
|
-
return list.map((tool) => ({
|
|
1422
|
-
...tool,
|
|
1423
|
-
handler: async (args, ctx) => {
|
|
1424
|
-
const result = await tool.handler(args, ctx);
|
|
1425
|
-
const budget = typeof args?.["maxBytes"] === "number" ? args["maxBytes"] : DEFAULT_RESULT_BYTES;
|
|
1426
|
-
const bytes = jsonBytes(result);
|
|
1427
|
-
if (bytes <= budget)
|
|
1428
|
-
return result;
|
|
1429
|
-
const raisable = asRecord(asRecord(tool.inputSchema)["properties"])["maxBytes"] !== undefined;
|
|
1430
|
-
throw new ConnectorCallError("connector_call_failed", tool.annotations.readOnlyHint
|
|
1431
|
-
? `This ${tool.name} result serializes to ${bytes} bytes, past ${raisable ? `this call's maxBytes of ${budget}` : `the ${budget} bytes one result may carry`}; nothing is returned. ${raisable ? `Raise maxBytes (up to ${MAX_RESULT_BYTES}, for a direct call_tool only), or narrow the request.` : "Read the item in Drive instead."}`
|
|
1432
|
-
: `${tool.name} applied, but its result serializes to ${bytes} bytes, past the ${budget} bytes one result may carry, so it is not returned. Re-read the item with get_file; do not repeat the write.`, { retryable: false });
|
|
1433
|
-
},
|
|
1434
|
-
}));
|
|
1435
|
-
}
|
|
1436
|
-
// --- Guide ------------------------------------------------------------------------
|
|
1437
|
-
function usageGuide(purpose, instructions) {
|
|
1438
|
-
const extra = instructions?.trim();
|
|
1439
|
-
return `${skill.fragments.guide_0}${purpose}${skill.fragments.guide_1}${extra ? `\n## ${skill.instructionsHeading}\n\n${extra}\n` : ""}`;
|
|
1440
|
-
}
|
|
1441
|
-
// --- Construction -----------------------------------------------------------------
|
|
33
|
+
import { bearer } from "../../http/oauth.js";
|
|
34
|
+
import { defineApiProvider } from "../../http/provider.js";
|
|
35
|
+
import { GOOGLE_GRANT_RENEWAL, tagWorkspace } from "../_shared/google/kit.js";
|
|
36
|
+
import { GOOGLE_WORKSPACE_OPTIONS } from "../_shared/google/workspace.js";
|
|
37
|
+
import { driveGrant, DriveApi, errors, probe, result, safety } from "./api.js";
|
|
38
|
+
import { DRIVE_API_BASE_URL, DRIVE_MAX_RESPONSE_BYTES } from "./lib/constants.js";
|
|
39
|
+
import { DRIVE_HATCH, driveBinding } from "./lib/hatch.js";
|
|
40
|
+
import { driveTools } from "./tools.js";
|
|
41
|
+
export { DRIVE_API_BASE_URL, DRIVE_SCOPES } from "./lib/constants.js";
|
|
1442
42
|
/**
|
|
1443
43
|
* A maintained Google Drive connection acting as each signed-in Workspace
|
|
1444
44
|
* user through a delegated service account.
|
|
@@ -1475,49 +75,39 @@ function usageGuide(purpose, instructions) {
|
|
|
1475
75
|
* ownership. Every write crosses host approval through call_destructive_tool
|
|
1476
76
|
* or a program on a writable endpoint.
|
|
1477
77
|
*/
|
|
1478
|
-
export const drive =
|
|
78
|
+
export const drive = defineApiProvider({
|
|
1479
79
|
name: "drive",
|
|
1480
80
|
title: "Google Drive",
|
|
1481
|
-
kind: "api",
|
|
1482
81
|
readme: "Google Drive",
|
|
1483
82
|
bundle: {
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
"note": "./providers/drive starts at 28,955 B gzip (#680): the same class as ./providers/gmail — a hand-written api() surface over the shared Workspace delegation layer, with two confined transports (API and upload host) and no MCP SDK. The cap uses the existing baseline + 60,000 B policy.",
|
|
83
|
+
baselineGzip: 390707,
|
|
84
|
+
note: "./providers/drive step-2 review snapshot measured standalone with Node 22.23.3, esbuild 0.28.1 and gzip level 9 before recording this baseline (#1020). This entry includes the shared Effect provider kit, paid once per deployment; standalone provider sizes are not additive. Provider entries are reported, not capped (#899). Combined Google merge snapshot over main db979604 measures 390,707 B gzip before recording these facts, Node 22.23.3, esbuild 0.28.1, neutral Worker conditions and gzip level 9; no cap changes (#703).",
|
|
1487
85
|
},
|
|
1488
86
|
skill,
|
|
1489
87
|
options: GOOGLE_WORKSPACE_OPTIONS,
|
|
1490
|
-
|
|
88
|
+
description: (options) => "Google Drive as the signed-in Workspace user: search, read, write, and share files, never delete them for good — " +
|
|
89
|
+
options.purpose,
|
|
90
|
+
vendor: DriveApi,
|
|
91
|
+
baseUrl: (options) => options.baseUrl?.trim() || DRIVE_API_BASE_URL,
|
|
92
|
+
maxResponseBytes: DRIVE_MAX_RESPONSE_BYTES,
|
|
93
|
+
grant: driveGrant,
|
|
94
|
+
grantRenewal: GOOGLE_GRANT_RENEWAL,
|
|
95
|
+
authenticate: bearer,
|
|
96
|
+
errors,
|
|
97
|
+
safety: () => safety,
|
|
98
|
+
result,
|
|
99
|
+
probe,
|
|
100
|
+
pageSize: { default: 25, max: 100 },
|
|
101
|
+
idempotencyKey: false,
|
|
102
|
+
hatch: { ...DRIVE_HATCH, binding: ({ connection }) => driveBinding(connection) },
|
|
103
|
+
guide: (options) => ({
|
|
104
|
+
context: [
|
|
105
|
+
`Connection purpose: ${options.purpose}`,
|
|
106
|
+
"Acts as the Workspace user deployment config maps from the admitted caller or pool owner. No caller argument selects an account. An unmapped or ownerless caller has no access unless the deployment fixes a shared account.",
|
|
107
|
+
],
|
|
108
|
+
summary: "Each caller's Drive files, bounded content, recoverable trash and quiet sharing.",
|
|
109
|
+
required: true,
|
|
110
|
+
}),
|
|
111
|
+
tools: driveTools,
|
|
112
|
+
tagConnector: tagWorkspace("drive"),
|
|
1491
113
|
});
|
|
1492
|
-
function driveConnector(id, options) {
|
|
1493
|
-
const connection = workspaceConnection("drive", options);
|
|
1494
|
-
const baseUrl = options.baseUrl?.trim() || DRIVE_API_BASE_URL;
|
|
1495
|
-
const shared = {
|
|
1496
|
-
provider: "Google Drive",
|
|
1497
|
-
api: "Google Drive API",
|
|
1498
|
-
scopes: DRIVE_SCOPES,
|
|
1499
|
-
maxResponseBytes: DRIVE_MAX_RESPONSE_BYTES,
|
|
1500
|
-
// Drive answers 404 for a file that exists but is not shared with this
|
|
1501
|
-
// account, exactly as for one that does not exist.
|
|
1502
|
-
notFound: "ambiguous",
|
|
1503
|
-
connection,
|
|
1504
|
-
};
|
|
1505
|
-
const client = googleWorkspaceClient({ ...shared, baseUrl });
|
|
1506
|
-
const upload = googleWorkspaceClient({ ...shared, baseUrl: uploadBaseUrl(baseUrl) });
|
|
1507
|
-
return workspaceConnector(connection, api(id, {
|
|
1508
|
-
...defined({
|
|
1509
|
-
callAdmission: options.callAdmission,
|
|
1510
|
-
maxResultBytes: options.maxResultBytes,
|
|
1511
|
-
}),
|
|
1512
|
-
title: options.title ?? "Google Drive",
|
|
1513
|
-
description: `Google Drive as the signed-in Workspace user: search, read, write, and share files, never delete them for good — ${connection.purpose}`,
|
|
1514
|
-
usageGuide: {
|
|
1515
|
-
content: usageGuide(connection.purpose, options.instructions),
|
|
1516
|
-
summary: "Each caller's own Drive: query syntax, Docs as Markdown, capped content, recoverable trash, quiet sharing.",
|
|
1517
|
-
// Required: whose Drive it is, the first-sheet CSV, and what
|
|
1518
|
-
// update_file_content replaces are conventions no schema can carry.
|
|
1519
|
-
required: true,
|
|
1520
|
-
},
|
|
1521
|
-
tools: guarded(tools(client, upload)),
|
|
1522
|
-
}));
|
|
1523
|
-
}
|