@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
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
// Google Drive named workflows, bounded projections and bound continuation on the provider kit.
|
|
2
|
+
import { Effect, Option, Schema } from "effect";
|
|
3
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
4
|
+
import { toConnectorCallError } from "../../http/errors.js";
|
|
5
|
+
import { Classification, Dispatches, Signal } from "../../http/services.js";
|
|
6
|
+
import { RESULT_BUDGET_BYTES, clampText, jsonBytes } from "../_shared/google/result-size.js";
|
|
7
|
+
import { googleOutcomeOf, googleFailureMessage } from "../_shared/google/kit.js";
|
|
8
|
+
import { read, write } from "../../http/tools.js";
|
|
9
|
+
import { fieldsInput, fieldsProblem, selectFields } from "../../http/fields.js";
|
|
10
|
+
import { catalogSchema } from "../../http/json-schema.js";
|
|
11
|
+
import { Refusal } from "../../http/errors.js";
|
|
12
|
+
import { Operation } from "../../runtime/call.js";
|
|
13
|
+
import { boundCursor } from "../../http/paging.js";
|
|
14
|
+
import { refuseWithheldValues, refuseWithheldLinks } from "../_shared/rest/value-safety.js";
|
|
15
|
+
import * as contracts from "./lib/contracts.js";
|
|
16
|
+
import { driveClient, driveUploadConnection } from "./api.js";
|
|
17
|
+
import { encodeForAgent, redactBeforeCut, redactBoundedRead, redactSentSecrets } from "../../sent-secrets.js";
|
|
18
|
+
/** Rows are one cheap request per page; Drive's own maximum is 1,000. */
|
|
19
|
+
const MAX_PAGE_SIZE = 100;
|
|
20
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
21
|
+
const DEFAULT_CONTENT_CHARS = 20_000;
|
|
22
|
+
const MAX_CONTENT_CHARS = 100_000;
|
|
23
|
+
/** A binary file is returned as base64 at or under this size, never above. */
|
|
24
|
+
const BINARY_INLINE_BYTES = 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* Serialized bytes of a whole read result — rows or content, envelope and
|
|
27
|
+
* cursor included. Every read is built to stay under `maxBytes`, measured as
|
|
28
|
+
* the UTF-8 length of its JSON, the way both delivery routes measure it. The
|
|
29
|
+
* default is the shared Workspace budget, three quarters of the 256 KiB a
|
|
30
|
+
* host result may carry into execute_code, so a program always receives a
|
|
31
|
+
* default result. A direct call_tool caller may ask for up to 4 MiB, which the
|
|
32
|
+
* result stash (8 MiB by default) still pages through connecta.result; execute_code
|
|
33
|
+
* cannot receive that much.
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_RESULT_BYTES = RESULT_BUDGET_BYTES;
|
|
36
|
+
/** Maximum whole-result budget for a direct call. */
|
|
37
|
+
const MAX_RESULT_BYTES = 4 * 1024 * 1024;
|
|
38
|
+
/**
|
|
39
|
+
* Strings Drive does not bound for us, cut in JSON bytes so one row or one
|
|
40
|
+
* file always fits under the smallest `maxBytes`: a name in a listing, a
|
|
41
|
+
* write result, or a content result; a name and description in get_file; and
|
|
42
|
+
* labels such as a display name. A cut string ends with "…" and its field is
|
|
43
|
+
* named in the result's `truncated`, so a real ellipsis is never taken
|
|
44
|
+
* for a cut. Identifiers are never cut: one that is not a well-formed Drive id
|
|
45
|
+
* is left out and named there instead.
|
|
46
|
+
*/
|
|
47
|
+
const ROW_NAME_BYTES = 2 * 1024;
|
|
48
|
+
const DETAIL_NAME_BYTES = 32 * 1024;
|
|
49
|
+
const DETAIL_DESCRIPTION_BYTES = 64 * 1024;
|
|
50
|
+
const LABEL_BYTES = 1024;
|
|
51
|
+
/** Owners and parents listed per file; Drive returns one of each today. */
|
|
52
|
+
const MAX_LISTED_IDS = 10;
|
|
53
|
+
/** What a Drive id looks like; anything else is malformed, never echoed. */
|
|
54
|
+
const DRIVE_ID = /^[A-Za-z0-9_-]{1,256}$/;
|
|
55
|
+
/**
|
|
56
|
+
* The longest inner cursor; its signed outer envelope has a 32 KiB input cap.
|
|
57
|
+
* Drive's page tokens are a few hundred characters; one so long that a cursor
|
|
58
|
+
* carrying it would pass this is refused when Drive sends it, rather than
|
|
59
|
+
* issued and then refused on replay.
|
|
60
|
+
*/
|
|
61
|
+
const MAX_CURSOR_CHARS = 16_384;
|
|
62
|
+
const FOLDER = "application/vnd.google-apps.folder";
|
|
63
|
+
const SHORTCUT = "application/vnd.google-apps.shortcut";
|
|
64
|
+
const NATIVE = "application/vnd.google-apps.";
|
|
65
|
+
/** Google file types the content tool exports, and as what. */
|
|
66
|
+
const EXPORTS = {
|
|
67
|
+
"application/vnd.google-apps.document": { mimeType: "text/markdown", format: "markdown" },
|
|
68
|
+
"application/vnd.google-apps.spreadsheet": {
|
|
69
|
+
mimeType: "text/csv",
|
|
70
|
+
format: "csv",
|
|
71
|
+
note: "CSV of the first sheet only; Drive exports no other tab as CSV.",
|
|
72
|
+
},
|
|
73
|
+
"application/vnd.google-apps.presentation": { mimeType: "text/plain", format: "text" },
|
|
74
|
+
};
|
|
75
|
+
/** `convertTo` values, as the Google type a created file becomes. */
|
|
76
|
+
const CONVERSIONS = {
|
|
77
|
+
document: "application/vnd.google-apps.document",
|
|
78
|
+
spreadsheet: "application/vnd.google-apps.spreadsheet",
|
|
79
|
+
presentation: "application/vnd.google-apps.presentation",
|
|
80
|
+
};
|
|
81
|
+
const SUMMARY_FIELDS = "id,name,mimeType,parents,driveId,size,modifiedTime,webViewLink,trashed,owners(emailAddress),shortcutDetails(targetId,targetMimeType)";
|
|
82
|
+
const DETAIL_FIELDS = `${SUMMARY_FIELDS},description,createdTime,lastModifyingUser(emailAddress,displayName),shared,starred,capabilities(canEdit,canComment,canShare,canDownload,canTrash,canRename,canAddChildren)`;
|
|
83
|
+
const PERMISSION_FIELDS = "id,type,role,emailAddress,domain,displayName,allowFileDiscovery,expirationTime,deleted,permissionDetails(inherited,inheritedFrom)";
|
|
84
|
+
/** Every file request reaches shared drives as well as My Drive. */
|
|
85
|
+
const ALL_DRIVES = { supportsAllDrives: true };
|
|
86
|
+
function asRecord(value) {
|
|
87
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
88
|
+
}
|
|
89
|
+
function asArray(value) {
|
|
90
|
+
return Array.isArray(value) ? value : [];
|
|
91
|
+
}
|
|
92
|
+
function compact(value) {
|
|
93
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
94
|
+
}
|
|
95
|
+
function text(value) {
|
|
96
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
97
|
+
}
|
|
98
|
+
function bool(value) {
|
|
99
|
+
return typeof value === "boolean" ? value : undefined;
|
|
100
|
+
}
|
|
101
|
+
/** Drive reports sizes as int64 strings. */
|
|
102
|
+
function sizeOf(ctx, value) {
|
|
103
|
+
if (redactBeforeCut(ctx, value) !== value)
|
|
104
|
+
return undefined;
|
|
105
|
+
const size = typeof value === "string" ? Number(value) : value;
|
|
106
|
+
return typeof size === "number" && Number.isSafeInteger(size) && size >= 0 ? size : undefined;
|
|
107
|
+
}
|
|
108
|
+
/** Well-formed Drive ids in a list, for a decision rather than a result. */
|
|
109
|
+
function idsOf(value) {
|
|
110
|
+
return asArray(value).filter((entry) => typeof entry === "string" && DRIVE_ID.test(entry));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* What one projection kept, and the name of every field it shortened or left
|
|
114
|
+
* out — reported as `truncatedFields`, never silently.
|
|
115
|
+
*/
|
|
116
|
+
class Cuts {
|
|
117
|
+
ctx;
|
|
118
|
+
cut = new Set();
|
|
119
|
+
constructor(ctx) {
|
|
120
|
+
this.ctx = ctx;
|
|
121
|
+
}
|
|
122
|
+
/** A string Drive does not bound, cut to `bytes` of JSON with "…". */
|
|
123
|
+
label(field, value, bytes = LABEL_BYTES) {
|
|
124
|
+
const whole = typeof value === "string" ? value : undefined;
|
|
125
|
+
if (whole === undefined || (whole === "" && field !== "name"))
|
|
126
|
+
return undefined;
|
|
127
|
+
const kept = clampText(this.ctx, whole, bytes, () => "…");
|
|
128
|
+
if (kept !== whole)
|
|
129
|
+
this.cut.add(field);
|
|
130
|
+
return kept;
|
|
131
|
+
}
|
|
132
|
+
/** An identifier whole, or left out and named when it is malformed. */
|
|
133
|
+
id(field, value) {
|
|
134
|
+
const whole = text(value);
|
|
135
|
+
if (whole === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (DRIVE_ID.test(whole))
|
|
138
|
+
return whole;
|
|
139
|
+
this.cut.add(field);
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
/** Up to ten well-formed ids; the rest, and any malformed one, are named. */
|
|
143
|
+
ids(field, value) {
|
|
144
|
+
const all = asArray(value).filter((entry) => typeof entry === "string");
|
|
145
|
+
const kept = idsOf(all);
|
|
146
|
+
if (kept.length !== all.length || kept.length > MAX_LISTED_IDS)
|
|
147
|
+
this.cut.add(`${field}[]`);
|
|
148
|
+
return kept.length > 0 ? kept.slice(0, MAX_LISTED_IDS) : undefined;
|
|
149
|
+
}
|
|
150
|
+
/** Up to ten labels, each cut as {@link label} cuts one. */
|
|
151
|
+
labels(field, values) {
|
|
152
|
+
const present = values.filter((value) => typeof value === "string" && value !== "");
|
|
153
|
+
if (present.length > MAX_LISTED_IDS)
|
|
154
|
+
this.cut.add(`${field}[]`);
|
|
155
|
+
const kept = present.slice(0, MAX_LISTED_IDS).map((value) => this.label(field, value));
|
|
156
|
+
return kept.length > 0 ? kept : undefined;
|
|
157
|
+
}
|
|
158
|
+
get fields() {
|
|
159
|
+
return this.cut.size > 0 ? [...this.cut] : undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// --- Projections ------------------------------------------------------------------
|
|
163
|
+
/**
|
|
164
|
+
* A file as an agent reads it. `fallbackId` is the id the call already named,
|
|
165
|
+
* for a response that omits it; `nameBytes` bounds the name. Fields cut or
|
|
166
|
+
* left out become `truncated` paths at the result boundary.
|
|
167
|
+
*/
|
|
168
|
+
function projectFile(ctx, value, fallbackId, nameBytes = ROW_NAME_BYTES, extra) {
|
|
169
|
+
const file = asRecord(value);
|
|
170
|
+
const shortcut = asRecord(file["shortcutDetails"]);
|
|
171
|
+
const cuts = new Cuts(ctx);
|
|
172
|
+
const projected = compact({
|
|
173
|
+
id: cuts.id("id", file["id"]) ?? fallbackId,
|
|
174
|
+
name: cuts.label("name", file["name"], nameBytes),
|
|
175
|
+
mimeType: cuts.label("mimeType", file["mimeType"]),
|
|
176
|
+
parents: cuts.ids("parents", file["parents"]),
|
|
177
|
+
driveId: cuts.id("driveId", file["driveId"]),
|
|
178
|
+
size: sizeOf(ctx, file["size"]),
|
|
179
|
+
modifiedTime: cuts.label("modifiedTime", file["modifiedTime"]),
|
|
180
|
+
owners: cuts.labels("owners", asArray(file["owners"]).map((owner) => asRecord(owner)["emailAddress"])),
|
|
181
|
+
trashed: bool(file["trashed"]),
|
|
182
|
+
webViewLink: cuts.label("webViewLink", file["webViewLink"]),
|
|
183
|
+
shortcutTargetId: cuts.id("shortcutTargetId", shortcut["targetId"]),
|
|
184
|
+
shortcutTargetMimeType: cuts.label("shortcutTargetMimeType", shortcut["targetMimeType"]),
|
|
185
|
+
...extra?.(file, cuts),
|
|
186
|
+
});
|
|
187
|
+
return compact({ ...projected, truncatedFields: cuts.fields });
|
|
188
|
+
}
|
|
189
|
+
const CAPABILITIES = [
|
|
190
|
+
"canEdit",
|
|
191
|
+
"canComment",
|
|
192
|
+
"canShare",
|
|
193
|
+
"canDownload",
|
|
194
|
+
"canTrash",
|
|
195
|
+
"canRename",
|
|
196
|
+
"canAddChildren",
|
|
197
|
+
];
|
|
198
|
+
function projectFileDetail(ctx, value, fallbackId) {
|
|
199
|
+
return projectFile(ctx, value, fallbackId, DETAIL_NAME_BYTES, (file, cuts) => {
|
|
200
|
+
const modifier = asRecord(file["lastModifyingUser"]);
|
|
201
|
+
const granted = asRecord(file["capabilities"]);
|
|
202
|
+
const capabilities = compact(Object.fromEntries(CAPABILITIES.map((name) => [name, bool(granted[name])])));
|
|
203
|
+
return {
|
|
204
|
+
description: cuts.label("description", file["description"], DETAIL_DESCRIPTION_BYTES),
|
|
205
|
+
createdTime: cuts.label("createdTime", file["createdTime"]),
|
|
206
|
+
modifiedBy: cuts.label("modifiedBy", text(modifier["emailAddress"])) ??
|
|
207
|
+
cuts.label("modifiedBy", text(modifier["displayName"])),
|
|
208
|
+
shared: bool(file["shared"]),
|
|
209
|
+
starred: bool(file["starred"]),
|
|
210
|
+
capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
function projectPermission(ctx, value, fallbackId) {
|
|
215
|
+
const permission = asRecord(value);
|
|
216
|
+
const details = asArray(permission["permissionDetails"]).map(asRecord);
|
|
217
|
+
const cuts = new Cuts(ctx);
|
|
218
|
+
const inheritedFrom = details
|
|
219
|
+
.map((detail) => cuts.id("inheritedFrom", detail["inheritedFrom"]))
|
|
220
|
+
.find((id) => id !== undefined);
|
|
221
|
+
return compact({
|
|
222
|
+
id: cuts.id("id", permission["id"]) ?? fallbackId,
|
|
223
|
+
type: cuts.label("type", permission["type"]),
|
|
224
|
+
role: cuts.label("role", permission["role"]),
|
|
225
|
+
emailAddress: cuts.label("emailAddress", permission["emailAddress"]),
|
|
226
|
+
domain: cuts.label("domain", permission["domain"]),
|
|
227
|
+
displayName: cuts.label("displayName", permission["displayName"]),
|
|
228
|
+
allowFileDiscovery: bool(permission["allowFileDiscovery"]),
|
|
229
|
+
expirationTime: cuts.label("expirationTime", permission["expirationTime"]),
|
|
230
|
+
// On a shared drive, a role granted by the drive or a parent folder.
|
|
231
|
+
inherited: details.length > 0 ? details.some((detail) => detail["inherited"] === true) : undefined,
|
|
232
|
+
// The folder or shared drive to change instead, since an inherited share
|
|
233
|
+
// cannot be changed or removed here.
|
|
234
|
+
inheritedFrom,
|
|
235
|
+
deleted: permission["deleted"] === true ? true : undefined,
|
|
236
|
+
truncatedFields: cuts.fields,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const encoder = new TextEncoder();
|
|
240
|
+
function base64url(bytes) {
|
|
241
|
+
return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
242
|
+
}
|
|
243
|
+
/** A short SHA-256 fingerprint: 96 bits, as 16 base64url characters. */
|
|
244
|
+
async function fingerprint(value) {
|
|
245
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(JSON.stringify(value))));
|
|
246
|
+
return base64url(digest.subarray(0, 12));
|
|
247
|
+
}
|
|
248
|
+
function cursorText(resume) {
|
|
249
|
+
const { token, skip, size, scope, page, last } = resume;
|
|
250
|
+
return JSON.stringify([token, skip, size, scope, page, last]);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The cursor an agent pages with. Drive's page token is the only downstream
|
|
254
|
+
* text in it, and it arrives redacted ({@link pageOf}); the rest is
|
|
255
|
+
* connecta's counts and fingerprints.
|
|
256
|
+
*/
|
|
257
|
+
function encodeCursor(ctx, resume) {
|
|
258
|
+
return encodeForAgent(ctx, cursorText(resume), { url: true, claims: true });
|
|
259
|
+
}
|
|
260
|
+
const FINGERPRINT = /^[A-Za-z0-9_-]{16}$/;
|
|
261
|
+
function decodeCursor(cursor) {
|
|
262
|
+
try {
|
|
263
|
+
const base = cursor.replace(/-/g, "+").replace(/_/g, "/");
|
|
264
|
+
const json = new TextDecoder().decode(fromBase64(base + "=".repeat((4 - (base.length % 4)) % 4)));
|
|
265
|
+
const [token, skip, size, scope, page, last] = JSON.parse(json);
|
|
266
|
+
const midPage = typeof page === "string" && FINGERPRINT.test(page) && typeof last === "string" && FINGERPRINT.test(last);
|
|
267
|
+
if ((token === null || (typeof token === "string" && token !== "")) &&
|
|
268
|
+
Number.isSafeInteger(skip) &&
|
|
269
|
+
skip >= 0 &&
|
|
270
|
+
Number.isSafeInteger(size) &&
|
|
271
|
+
size >= 1 &&
|
|
272
|
+
size <= MAX_PAGE_SIZE &&
|
|
273
|
+
typeof scope === "string" &&
|
|
274
|
+
FINGERPRINT.test(scope) &&
|
|
275
|
+
// A cursor inside a page proves which page; one at a boundary has nothing to prove.
|
|
276
|
+
(skip > 0 ? midPage : page === null && last === null)) {
|
|
277
|
+
refuseWithheldValues(token, "Drive continuation", true);
|
|
278
|
+
return {
|
|
279
|
+
token: token,
|
|
280
|
+
skip: skip,
|
|
281
|
+
size: size,
|
|
282
|
+
scope,
|
|
283
|
+
page: midPage ? page : null,
|
|
284
|
+
last: midPage ? last : null,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// Reported below, as any other cursor this connection did not issue.
|
|
290
|
+
}
|
|
291
|
+
throw new ConnectorCallError("invalid_args", "cursor is not a page.nextCursor this connection issued; pass it back unchanged, or omit it to start over.");
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Whether a cursor carrying this page token stays within
|
|
295
|
+
* {@link MAX_CURSOR_CHARS} wherever it resumes, mid-page fingerprints included.
|
|
296
|
+
*/
|
|
297
|
+
function issuable(token) {
|
|
298
|
+
const widest = "A".repeat(16);
|
|
299
|
+
const text = cursorText({
|
|
300
|
+
token,
|
|
301
|
+
skip: MAX_PAGE_SIZE,
|
|
302
|
+
size: MAX_PAGE_SIZE,
|
|
303
|
+
scope: widest,
|
|
304
|
+
page: widest,
|
|
305
|
+
last: widest,
|
|
306
|
+
});
|
|
307
|
+
return Math.ceil((encoder.encode(text).length * 4) / 3) <= MAX_CURSOR_CHARS;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* One page of a listing, as many rows as fit `maxBytes` with the whole
|
|
311
|
+
* result — envelope and cursor included — and a cursor that resumes at the
|
|
312
|
+
* first row left out, inside Drive's page or after it.
|
|
313
|
+
*/
|
|
314
|
+
function pageOf(ctx, key, args,
|
|
315
|
+
/** The tool and every argument that decides which rows, in which order. */
|
|
316
|
+
binding, fetch) {
|
|
317
|
+
return Effect.gen(function* () {
|
|
318
|
+
const maxBytes = (args["maxBytes"] ?? DEFAULT_RESULT_BYTES) - 8192;
|
|
319
|
+
const size = args["limit"] ?? DEFAULT_PAGE_SIZE;
|
|
320
|
+
const scope = yield* Effect.promise(() => fingerprint({ ...binding, size }));
|
|
321
|
+
const resume = typeof args["cursor"] === "string"
|
|
322
|
+
? decodeCursor(args["cursor"])
|
|
323
|
+
: { token: null, skip: 0, size, scope, page: null, last: null };
|
|
324
|
+
if (resume.scope !== scope) {
|
|
325
|
+
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.");
|
|
326
|
+
}
|
|
327
|
+
const fetched = yield* fetch(resume.token ?? undefined, resume.size);
|
|
328
|
+
// Drive's token goes into a cursor the agent decodes, which no later pass reads through:
|
|
329
|
+
// it is redacted first, so a token echoing a sent value cannot carry it.
|
|
330
|
+
if (fetched.next !== undefined)
|
|
331
|
+
fetched.next = redactSentSecrets(ctx, fetched.next);
|
|
332
|
+
if (fetched.next !== undefined && !issuable(fetched.next)) {
|
|
333
|
+
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 });
|
|
334
|
+
}
|
|
335
|
+
// Drive's ids as Drive sent them, so two that differ anywhere differ here.
|
|
336
|
+
const page = yield* Effect.promise(() => fingerprint(fetched.ids));
|
|
337
|
+
const lasts = yield* Effect.promise(() => Promise.all(fetched.ids.map((id) => fingerprint(id))));
|
|
338
|
+
if (resume.skip > 0 && (page !== resume.page || lasts[resume.skip - 1] !== resume.last)) {
|
|
339
|
+
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 });
|
|
340
|
+
}
|
|
341
|
+
const rows = fetched.rows.slice(resume.skip);
|
|
342
|
+
const build = (count) => {
|
|
343
|
+
const nextCursor = count < rows.length
|
|
344
|
+
? encodeCursor(ctx, {
|
|
345
|
+
...resume,
|
|
346
|
+
skip: resume.skip + count,
|
|
347
|
+
page,
|
|
348
|
+
last: lasts[resume.skip + count - 1] ?? "",
|
|
349
|
+
})
|
|
350
|
+
: fetched.next
|
|
351
|
+
? encodeCursor(ctx, { token: fetched.next, skip: 0, size: resume.size, scope, page: null, last: null })
|
|
352
|
+
: null;
|
|
353
|
+
return {
|
|
354
|
+
items: rows.slice(0, count),
|
|
355
|
+
...fetched.extra,
|
|
356
|
+
page: { hasMore: nextCursor !== null, ...(nextCursor ? { nextCursor } : {}) },
|
|
357
|
+
};
|
|
358
|
+
};
|
|
359
|
+
if (jsonBytes(build(rows.length)) <= maxBytes)
|
|
360
|
+
return build(rows.length);
|
|
361
|
+
// The most rows that fit, by binary search; a result's size grows with them.
|
|
362
|
+
let low = 0;
|
|
363
|
+
let high = rows.length - 1;
|
|
364
|
+
while (low < high) {
|
|
365
|
+
const middle = Math.ceil((low + high) / 2);
|
|
366
|
+
if (jsonBytes(build(middle)) <= maxBytes)
|
|
367
|
+
low = middle;
|
|
368
|
+
else
|
|
369
|
+
high = middle - 1;
|
|
370
|
+
}
|
|
371
|
+
if (low === 0) {
|
|
372
|
+
// Every row is bounded well under the smallest maxBytes; only a cursor
|
|
373
|
+
// stuck on a row that never fits could land here, and it must not loop.
|
|
374
|
+
throw new ConnectorCallError("connector_call_failed", `One ${key} row does not fit this result's maxBytes of ${maxBytes}; raise maxBytes.`, { retryable: false });
|
|
375
|
+
}
|
|
376
|
+
return build(low);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// --- Content ----------------------------------------------------------------------
|
|
380
|
+
const TEXT_TYPES = new Set([
|
|
381
|
+
"application/json",
|
|
382
|
+
"application/xml",
|
|
383
|
+
"application/javascript",
|
|
384
|
+
"application/x-javascript",
|
|
385
|
+
"application/x-yaml",
|
|
386
|
+
"application/yaml",
|
|
387
|
+
"application/x-sh",
|
|
388
|
+
"application/sql",
|
|
389
|
+
"application/csv",
|
|
390
|
+
"application/x-ndjson",
|
|
391
|
+
"image/svg+xml",
|
|
392
|
+
]);
|
|
393
|
+
function isText(mimeType) {
|
|
394
|
+
const type = mimeType.toLowerCase();
|
|
395
|
+
return type.startsWith("text/") || TEXT_TYPES.has(type) || type.endsWith("+json") || type.endsWith("+xml");
|
|
396
|
+
}
|
|
397
|
+
/** Whether the UTF-16 unit at `index` opens a surrogate pair that is whole. */
|
|
398
|
+
function pairAt(value, index) {
|
|
399
|
+
const unit = value.charCodeAt(index);
|
|
400
|
+
return unit >= 0xd800 && unit <= 0xdbff && (value.charCodeAt(index + 1) & 0xfc00) === 0xdc00;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The string index just past the first `max` characters, counted as code
|
|
404
|
+
* points — so a cut never splits a surrogate pair — and how many characters
|
|
405
|
+
* the whole string holds.
|
|
406
|
+
*/
|
|
407
|
+
function codePointCut(value, max) {
|
|
408
|
+
let index = 0;
|
|
409
|
+
let total = 0;
|
|
410
|
+
let end = value.length;
|
|
411
|
+
while (index < value.length) {
|
|
412
|
+
if (total === max)
|
|
413
|
+
end = index;
|
|
414
|
+
index += pairAt(value, index) ? 2 : 1;
|
|
415
|
+
total += 1;
|
|
416
|
+
}
|
|
417
|
+
return { end: total > max ? end : value.length, total };
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Cut text to what both bounds allow and say so in the text itself: `max`
|
|
421
|
+
* characters (code points, so never inside a pair), and whatever `fits` — the
|
|
422
|
+
* whole result under `maxBytes` — admits, marker included. `partial` is a body
|
|
423
|
+
* read only in part, which is cut whatever its length: there is more past it.
|
|
424
|
+
*/
|
|
425
|
+
function capped(ctx, whole, max, partial, fits) {
|
|
426
|
+
if (codePointCut(whole, max).total <= max && !partial && fits(whole))
|
|
427
|
+
return { content: whole, contentTruncated: false };
|
|
428
|
+
// Every secret the request sent is replaced before the cut, which could otherwise split one.
|
|
429
|
+
const body = redactBeforeCut(ctx, whole);
|
|
430
|
+
const { total } = codePointCut(body, max);
|
|
431
|
+
if (total <= max && !partial && fits(body))
|
|
432
|
+
return { content: body, contentTruncated: false };
|
|
433
|
+
const of = partial
|
|
434
|
+
? partial.size !== undefined && partial.size > 0
|
|
435
|
+
? `a ${partial.size}-byte file`
|
|
436
|
+
: "a larger file"
|
|
437
|
+
: `${total} characters`;
|
|
438
|
+
const cut = (count, raise) => `${body.slice(0, codePointCut(body, count).end)}\n[… truncated: ${count} characters of ${of} shown; raise ${raise} to read more]`;
|
|
439
|
+
const byChars = cut(Math.min(total, max), `maxChars (up to ${MAX_CONTENT_CHARS})`);
|
|
440
|
+
if (fits(byChars))
|
|
441
|
+
return { content: byChars, contentTruncated: true };
|
|
442
|
+
// The bytes bind first: the most characters that fit, by binary search.
|
|
443
|
+
const raise = `maxBytes (up to ${MAX_RESULT_BYTES}, for a direct call_tool only)`;
|
|
444
|
+
let low = 0;
|
|
445
|
+
let high = Math.min(total, max);
|
|
446
|
+
while (low < high) {
|
|
447
|
+
const middle = Math.ceil((low + high) / 2);
|
|
448
|
+
if (fits(cut(middle, raise)))
|
|
449
|
+
low = middle;
|
|
450
|
+
else
|
|
451
|
+
high = middle - 1;
|
|
452
|
+
}
|
|
453
|
+
return { content: cut(low, raise), contentTruncated: true };
|
|
454
|
+
}
|
|
455
|
+
function charsetOf(contentType) {
|
|
456
|
+
return /charset="?([^";\s]+)"?/i.exec(contentType ?? "")?.[1] ?? "utf-8";
|
|
457
|
+
}
|
|
458
|
+
/** Bytes as text in the charset the response declared, where the runtime knows it. */
|
|
459
|
+
function decodeIn(bytes, contentType) {
|
|
460
|
+
try {
|
|
461
|
+
return new TextDecoder(charsetOf(contentType)).decode(bytes);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return new TextDecoder().decode(bytes);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function base64(bytes) {
|
|
468
|
+
let binary = "";
|
|
469
|
+
for (let index = 0; index < bytes.length; index += 0x8000) {
|
|
470
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
471
|
+
}
|
|
472
|
+
return btoa(binary);
|
|
473
|
+
}
|
|
474
|
+
function fromBase64(value) {
|
|
475
|
+
let binary;
|
|
476
|
+
try {
|
|
477
|
+
binary = atob(value);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
throw new ConnectorCallError("invalid_args", "contentBase64 is not valid base64.");
|
|
481
|
+
}
|
|
482
|
+
const bytes = new Uint8Array(binary.length);
|
|
483
|
+
for (let index = 0; index < binary.length; index += 1)
|
|
484
|
+
bytes[index] = binary.charCodeAt(index);
|
|
485
|
+
return bytes;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Why the content tool returns no content for a file, as the note says it.
|
|
489
|
+
* Only a well-formed id or media type is interpolated; anything else Drive
|
|
490
|
+
* sends is named by kind, so a note stays a sentence.
|
|
491
|
+
*/
|
|
492
|
+
function unreadable(file) {
|
|
493
|
+
const mimeType = String(file["mimeType"] ?? "");
|
|
494
|
+
if (mimeType === FOLDER)
|
|
495
|
+
return "A folder has no content; list it with list_folder_items.";
|
|
496
|
+
if (mimeType === SHORTCUT) {
|
|
497
|
+
const target = text(asRecord(file["shortcutDetails"])["targetId"]);
|
|
498
|
+
const named = target !== undefined && DRIVE_ID.test(target) ? ` (${target})` : "";
|
|
499
|
+
return `A shortcut has no content of its own; read its target${named} instead.`;
|
|
500
|
+
}
|
|
501
|
+
if (mimeType === "application/vnd.google-apps.drawing") {
|
|
502
|
+
return "Drawings export only as images or PDF; this connection reads no drawing. Open webViewLink.";
|
|
503
|
+
}
|
|
504
|
+
if (mimeType.startsWith(NATIVE) && !EXPORTS[mimeType]) {
|
|
505
|
+
const named = mimeType.length <= 255 && MEDIA_TYPE.test(mimeType) ? mimeType : "this Google file type";
|
|
506
|
+
return `Drive exports no text for ${named}; open webViewLink.`;
|
|
507
|
+
}
|
|
508
|
+
if (asRecord(file["capabilities"])["canDownload"] === false) {
|
|
509
|
+
return "The owner has disabled download, print, and copy for this account; Drive refuses its content.";
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The bytes a text read keeps: enough for `maxChars` of UTF-8 at four bytes
|
|
515
|
+
* each and one more character, so a body this long is always cut.
|
|
516
|
+
*/
|
|
517
|
+
function textBudget(maxChars) {
|
|
518
|
+
return maxChars * 4 + 4;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* A media download of at most `limit` bytes, and whether the file had more.
|
|
522
|
+
*
|
|
523
|
+
* The limit is enforced on the response, never on the size metadata
|
|
524
|
+
* reported: that is a separate read, and a file can grow, shrink, or empty
|
|
525
|
+
* between the two. The range asks for one byte past the limit, so a file that
|
|
526
|
+
* has more says so by sending it, and the shared client's `maxBytes` reads the
|
|
527
|
+
* body only that far and cancels the rest unread — so a server that ignores
|
|
528
|
+
* the range, or a body past the client's own ceiling, costs no more than the
|
|
529
|
+
* limit either.
|
|
530
|
+
*
|
|
531
|
+
* The range is sent even when the metadata says the file is empty, since that
|
|
532
|
+
* may no longer be so. A range from byte 0 of a file with no bytes is answered
|
|
533
|
+
* 416 with `Content-Range: bytes *\/0`, which the client returns as an empty
|
|
534
|
+
* body: a verified empty file, not a failure. Any other 416 still fails.
|
|
535
|
+
*/
|
|
536
|
+
function download(client, ctx, path, limit) {
|
|
537
|
+
return Effect.gen(function* () {
|
|
538
|
+
const read = yield* client.bytes({
|
|
539
|
+
method: "GET",
|
|
540
|
+
path,
|
|
541
|
+
query: { ...ALL_DRIVES, alt: "media" },
|
|
542
|
+
headers: { Range: `bytes=0-${limit}` },
|
|
543
|
+
}, "*/*", { maxBytes: limit, step: "content" });
|
|
544
|
+
return { bytes: read.bytes.subarray(0, limit), more: read.truncated, contentType: read.contentType };
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function readContent(client, ctx, fileId, maxChars, maxBytes) {
|
|
548
|
+
return Effect.gen(function* () {
|
|
549
|
+
const filePath = `/files/${encodeURIComponent(fileId)}`;
|
|
550
|
+
const file = decisionRecord(yield* client.json({
|
|
551
|
+
method: "GET",
|
|
552
|
+
path: filePath,
|
|
553
|
+
query: {
|
|
554
|
+
...ALL_DRIVES,
|
|
555
|
+
fields: "id,name,mimeType,size,webViewLink,capabilities(canDownload),shortcutDetails(targetId,targetMimeType)",
|
|
556
|
+
},
|
|
557
|
+
}, { step: "metadata" }));
|
|
558
|
+
decisionInputs(file, ["mimeType", "shortcutDetails", "capabilities"]);
|
|
559
|
+
const mimeType = String(file["mimeType"] ?? "application/octet-stream");
|
|
560
|
+
const size = sizeOf(ctx, file["size"]);
|
|
561
|
+
const cuts = new Cuts(ctx);
|
|
562
|
+
const base = compact({
|
|
563
|
+
id: cuts.id("id", file["id"]) ?? fileId,
|
|
564
|
+
name: cuts.label("name", file["name"], ROW_NAME_BYTES),
|
|
565
|
+
mimeType: cuts.label("mimeType", mimeType),
|
|
566
|
+
size,
|
|
567
|
+
webViewLink: cuts.label("webViewLink", file["webViewLink"]),
|
|
568
|
+
truncatedFields: cuts.fields,
|
|
569
|
+
});
|
|
570
|
+
const refusal = unreadable(file);
|
|
571
|
+
if (refusal)
|
|
572
|
+
return { ...base, format: "unavailable", contentTruncated: false, note: refusal };
|
|
573
|
+
/** Whether the whole result, with this content, stays under maxBytes. */
|
|
574
|
+
const within = (result) => (content) => jsonBytes({ ...result, content }) <= maxBytes;
|
|
575
|
+
const exported = EXPORTS[mimeType];
|
|
576
|
+
if (exported) {
|
|
577
|
+
// Past Drive's 10 MB export limit, the shared client names Google's
|
|
578
|
+
// exportSizeLimitExceeded itself; nothing here guesses at a 403.
|
|
579
|
+
const { text: body } = yield* client.text({ method: "GET", path: `${filePath}/export`, query: { mimeType: exported.mimeType } }, exported.mimeType, { step: "content" });
|
|
580
|
+
const result = compact({
|
|
581
|
+
...base,
|
|
582
|
+
format: exported.format,
|
|
583
|
+
exportedAs: exported.mimeType,
|
|
584
|
+
content: "",
|
|
585
|
+
contentTruncated: false,
|
|
586
|
+
note: exported.note,
|
|
587
|
+
});
|
|
588
|
+
return { ...result, ...capped(ctx, body, maxChars, undefined, within(result)) };
|
|
589
|
+
}
|
|
590
|
+
if (isText(mimeType)) {
|
|
591
|
+
const read = yield* download(client, ctx, filePath, textBudget(maxChars));
|
|
592
|
+
let body = decodeIn(read.bytes, read.contentType);
|
|
593
|
+
// A cut can land inside a character; drop the half, never show it. A sent
|
|
594
|
+
// secret the read stopped inside never arrives whole, so its start goes too.
|
|
595
|
+
if (read.more)
|
|
596
|
+
body = redactBoundedRead(ctx, body.replace(/(?:\uFFFD|[\uD800-\uDBFF])$/, ""));
|
|
597
|
+
const result = { ...base, format: "text", content: "", contentTruncated: false };
|
|
598
|
+
return { ...result, ...capped(ctx, 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 = yield* download(client, ctx, filePath, limit);
|
|
618
|
+
if (read.more)
|
|
619
|
+
return tooLarge(`more than ${limit} bytes`);
|
|
620
|
+
return { ...base, format: "base64", content: encodeForAgent(ctx, read.bytes), contentTruncated: false };
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
// --- Writes -----------------------------------------------------------------------
|
|
624
|
+
/**
|
|
625
|
+
* Run a write that adds something, saying what to check before repeating it
|
|
626
|
+
* when it may have landed: whenever the request left connecta and Google did
|
|
627
|
+
* not refuse it. That is no answer (`awaiting-response`), an unfollowed
|
|
628
|
+
* redirect (`redirected`), a 5xx (`server-error`) — each of which leaves the
|
|
629
|
+
* outcome unknown — or a 2xx whose reply broke (`reading-body`), which
|
|
630
|
+
* probably applied. The shared client already says which, in its own words,
|
|
631
|
+
* and makes the failure non-retryable; this adds only the read that tells,
|
|
632
|
+
* because a repeat of an additive write is not harmless — it makes a second
|
|
633
|
+
* file, folder, copy, or share. The code is kept, so core still reads the
|
|
634
|
+
* outcome as unknown. A refusal, or a failure before anything was sent,
|
|
635
|
+
* passes through untouched.
|
|
636
|
+
*/
|
|
637
|
+
function adding(connection, work, check) {
|
|
638
|
+
return Effect.suspend(work).pipe(Effect.catch((failure) => Effect.gen(function* () {
|
|
639
|
+
const outcome = googleOutcomeOf(failure);
|
|
640
|
+
if (!outcome?.dispatched ||
|
|
641
|
+
outcome.phase === "refused" ||
|
|
642
|
+
outcome.phase === "before-send" ||
|
|
643
|
+
outcome.phase === "unauthorized")
|
|
644
|
+
return yield* Effect.fail(failure);
|
|
645
|
+
const error = toConnectorCallError(failure, connection.errors, {
|
|
646
|
+
classification: yield* Classification,
|
|
647
|
+
dispatches: yield* Dispatches,
|
|
648
|
+
withholds: connection.safety.withholdsErrors,
|
|
649
|
+
signal: yield* Signal,
|
|
650
|
+
});
|
|
651
|
+
if (error.code === "connector_call_failed")
|
|
652
|
+
return yield* googleFailureMessage(connection, failure, (message) => message + " " + check);
|
|
653
|
+
return yield* Effect.fail(failure);
|
|
654
|
+
})));
|
|
655
|
+
}
|
|
656
|
+
const CHECK_CREATED = "Find it with search_files or list_folder_items before creating it again: a second call makes a second one.";
|
|
657
|
+
// --- Uploads ----------------------------------------------------------------------
|
|
658
|
+
const MEDIA_TYPE = /^[A-Za-z0-9][\w.+-]*\/[A-Za-z0-9][\w.+-]*$/;
|
|
659
|
+
function mediaType(value, fallback) {
|
|
660
|
+
const type = typeof value === "string" ? value : fallback;
|
|
661
|
+
if (!MEDIA_TYPE.test(type)) {
|
|
662
|
+
throw new ConnectorCallError("invalid_args", "mimeType must be a bare media type such as text/plain.");
|
|
663
|
+
}
|
|
664
|
+
if (type.startsWith(NATIVE)) {
|
|
665
|
+
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.");
|
|
666
|
+
}
|
|
667
|
+
return type;
|
|
668
|
+
}
|
|
669
|
+
/** The uploaded bytes and their media type, from exactly one of the two fields. */
|
|
670
|
+
function media(args) {
|
|
671
|
+
const hasText = typeof args["content"] === "string";
|
|
672
|
+
const hasBinary = typeof args["contentBase64"] === "string";
|
|
673
|
+
if (hasText && hasBinary) {
|
|
674
|
+
throw new ConnectorCallError("invalid_args", "Pass content or contentBase64, not both.");
|
|
675
|
+
}
|
|
676
|
+
if (hasText) {
|
|
677
|
+
refuseWithheldValues(args["content"], "Drive text upload");
|
|
678
|
+
return {
|
|
679
|
+
bytes: new TextEncoder().encode(args["content"]),
|
|
680
|
+
type: mediaType(args["mimeType"], "text/plain"),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (hasBinary) {
|
|
684
|
+
const bytes = fromBase64(args["contentBase64"]);
|
|
685
|
+
if (bytes.length > BINARY_INLINE_BYTES) {
|
|
686
|
+
throw new ConnectorCallError("invalid_args", `contentBase64 decodes to ${bytes.length} bytes, past this connection's ${BINARY_INLINE_BYTES}-byte upload cap.`);
|
|
687
|
+
}
|
|
688
|
+
refuseWithheldValues(new TextDecoder().decode(bytes), "Drive binary upload");
|
|
689
|
+
refuseWithheldLinks(new TextDecoder().decode(bytes), "Drive binary upload");
|
|
690
|
+
return { bytes, type: mediaType(args["mimeType"], "application/octet-stream") };
|
|
691
|
+
}
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* One `multipart/related` body: the metadata as JSON, then the media. Built
|
|
696
|
+
* whole as bytes, never a stream: GoogleRequest.rawBody admits only strings
|
|
697
|
+
* and byte arrays. An upload is a write and is never resent.
|
|
698
|
+
*/
|
|
699
|
+
function multipart(metadata, content) {
|
|
700
|
+
const boundary = `connecta-${crypto.randomUUID()}`;
|
|
701
|
+
const encoder = new TextEncoder();
|
|
702
|
+
const head = encoder.encode(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n` +
|
|
703
|
+
`--${boundary}\r\nContent-Type: ${content.type}\r\n\r\n`);
|
|
704
|
+
const tail = encoder.encode(`\r\n--${boundary}--\r\n`);
|
|
705
|
+
const body = new Uint8Array(head.length + content.bytes.length + tail.length);
|
|
706
|
+
body.set(head, 0);
|
|
707
|
+
body.set(content.bytes, head.length);
|
|
708
|
+
body.set(tail, head.length + content.bytes.length);
|
|
709
|
+
return { body, contentType: `multipart/related; boundary=${boundary}` };
|
|
710
|
+
}
|
|
711
|
+
// --- Tools ------------------------------------------------------------------------
|
|
712
|
+
export function driveTools({ connection, options, }) {
|
|
713
|
+
const client = driveClient(connection);
|
|
714
|
+
const uploadConnection = driveUploadConnection(options);
|
|
715
|
+
const upload = driveClient(uploadConnection);
|
|
716
|
+
const readOnly = { readOnlyHint: true };
|
|
717
|
+
// Additive: creates something new that discloses nothing, and changes
|
|
718
|
+
// nothing that existed.
|
|
719
|
+
const additive = { readOnlyHint: false, destructiveHint: false };
|
|
720
|
+
// Overwrites, moves, trashes, or changes who can see a file.
|
|
721
|
+
const destructive = { readOnlyHint: false, destructiveHint: true };
|
|
722
|
+
const filePath = (id) => `/files/${encodeURIComponent(id)}`;
|
|
723
|
+
const list = (ctx, tool, args, query, step) =>
|
|
724
|
+
// The Drive query decides the rows: folder, search, corpus, drive, order.
|
|
725
|
+
pageOf(ctx, "files", args, { tool, ...query }, (pageToken, pageSize) => Effect.gen(function* () {
|
|
726
|
+
const listing = asRecord(yield* client.json({
|
|
727
|
+
method: "GET",
|
|
728
|
+
path: "/files",
|
|
729
|
+
query: {
|
|
730
|
+
...ALL_DRIVES,
|
|
731
|
+
includeItemsFromAllDrives: true,
|
|
732
|
+
fields: `nextPageToken,incompleteSearch,files(${SUMMARY_FIELDS})`,
|
|
733
|
+
...query,
|
|
734
|
+
pageSize,
|
|
735
|
+
pageToken,
|
|
736
|
+
},
|
|
737
|
+
}, step ? { step } : undefined));
|
|
738
|
+
return {
|
|
739
|
+
ids: asArray(listing["files"]).map((file) => String(asRecord(file)["id"] ?? "")),
|
|
740
|
+
rows: asArray(listing["files"]).map((file) => projectFile(ctx, file)),
|
|
741
|
+
next: nextToken(listing["nextPageToken"]),
|
|
742
|
+
// Drive's own truncation: it stopped before searching every corpus.
|
|
743
|
+
extra: listing["incompleteSearch"] === true ? { incompleteSearch: true } : undefined,
|
|
744
|
+
};
|
|
745
|
+
}));
|
|
746
|
+
const patch = (ctx, fileId, body, query = {}) => Effect.gen(function* () {
|
|
747
|
+
return projectFile(ctx, yield* client.json({
|
|
748
|
+
method: "PATCH",
|
|
749
|
+
path: filePath(fileId),
|
|
750
|
+
query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS, ...query },
|
|
751
|
+
body,
|
|
752
|
+
}), fileId);
|
|
753
|
+
});
|
|
754
|
+
const declarations = [
|
|
755
|
+
{
|
|
756
|
+
name: "search_files",
|
|
757
|
+
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.",
|
|
758
|
+
annotations: readOnly,
|
|
759
|
+
contract: contracts.search_files,
|
|
760
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
761
|
+
const corpora = args["corpora"] ?? "user";
|
|
762
|
+
if ((corpora === "drive") !== (args["driveId"] !== undefined)) {
|
|
763
|
+
throw new ConnectorCallError("invalid_args", "driveId goes with corpora drive and only with it; find shared drive ids with list_shared_drives.");
|
|
764
|
+
}
|
|
765
|
+
const query = text(args["query"]);
|
|
766
|
+
const q = args["includeTrashed"] === true ? query : query ? `(${query}) and trashed = false` : "trashed = false";
|
|
767
|
+
return yield* list(ctx, "search_files", args, {
|
|
768
|
+
q,
|
|
769
|
+
corpora,
|
|
770
|
+
driveId: args["driveId"],
|
|
771
|
+
orderBy: args["orderBy"],
|
|
772
|
+
});
|
|
773
|
+
}),
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
name: "list_folder_items",
|
|
777
|
+
writeSteps: { folder: "once", files: "once" },
|
|
778
|
+
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.",
|
|
779
|
+
annotations: readOnly,
|
|
780
|
+
contract: contracts.list_folder_items,
|
|
781
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
782
|
+
const folderId = String(args["folderId"]);
|
|
783
|
+
// A shared drive folder is listed within its drive, which Drive
|
|
784
|
+
// searches only when asked by id; the folder itself says which.
|
|
785
|
+
let driveId;
|
|
786
|
+
if (folderId !== "root") {
|
|
787
|
+
const folder = decisionRecord(yield* client.json({
|
|
788
|
+
method: "GET",
|
|
789
|
+
path: filePath(folderId),
|
|
790
|
+
query: { ...ALL_DRIVES, fields: "id,mimeType,driveId" },
|
|
791
|
+
}, { step: "folder" }));
|
|
792
|
+
decisionInputs(folder, ["mimeType", "driveId"]);
|
|
793
|
+
if (folder["mimeType"] !== FOLDER) {
|
|
794
|
+
throw new ConnectorCallError("invalid_args", `${folderId} is not a folder (${String(folder["mimeType"] ?? "unknown type")}); read it with get_file or get_file_content.`);
|
|
795
|
+
}
|
|
796
|
+
driveId = text(folder["driveId"]);
|
|
797
|
+
}
|
|
798
|
+
const q = `'${folderId}' in parents${args["includeTrashed"] === true ? "" : " and trashed = false"}`;
|
|
799
|
+
return yield* list(ctx, "list_folder_items", args, {
|
|
800
|
+
q,
|
|
801
|
+
corpora: driveId ? "drive" : "user",
|
|
802
|
+
driveId,
|
|
803
|
+
orderBy: args["orderBy"] ?? "folder,name",
|
|
804
|
+
}, "files");
|
|
805
|
+
}),
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
name: "get_file",
|
|
809
|
+
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.",
|
|
810
|
+
annotations: readOnly,
|
|
811
|
+
contract: contracts.get_file,
|
|
812
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
813
|
+
return projectFileDetail(ctx, yield* client.json({
|
|
814
|
+
method: "GET",
|
|
815
|
+
path: filePath(args["fileId"]),
|
|
816
|
+
query: { ...ALL_DRIVES, fields: DETAIL_FIELDS },
|
|
817
|
+
}), String(args["fileId"]));
|
|
818
|
+
}),
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
name: "get_file_content",
|
|
822
|
+
writeSteps: { metadata: "once", content: "once" },
|
|
823
|
+
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.",
|
|
824
|
+
annotations: readOnly,
|
|
825
|
+
contract: contracts.get_file_content,
|
|
826
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
827
|
+
return yield* readContent(client, ctx, String(args["fileId"]), typeof args["maxChars"] === "number" ? args["maxChars"] : DEFAULT_CONTENT_CHARS, typeof args["maxBytes"] === "number" ? args["maxBytes"] : DEFAULT_RESULT_BYTES);
|
|
828
|
+
}),
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
name: "list_permissions",
|
|
832
|
+
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.",
|
|
833
|
+
annotations: readOnly,
|
|
834
|
+
contract: contracts.list_permissions,
|
|
835
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
836
|
+
return yield* pageOf(ctx, "permissions", args, { tool: "list_permissions", fileId: args["fileId"] }, (pageToken, pageSize) => Effect.gen(function* () {
|
|
837
|
+
const listing = asRecord(yield* client.json({
|
|
838
|
+
method: "GET",
|
|
839
|
+
path: `${filePath(args["fileId"])}/permissions`,
|
|
840
|
+
query: {
|
|
841
|
+
...ALL_DRIVES,
|
|
842
|
+
fields: `nextPageToken,permissions(${PERMISSION_FIELDS})`,
|
|
843
|
+
pageSize,
|
|
844
|
+
pageToken,
|
|
845
|
+
},
|
|
846
|
+
}));
|
|
847
|
+
return {
|
|
848
|
+
ids: asArray(listing["permissions"]).map((permission) => String(asRecord(permission)["id"] ?? "")),
|
|
849
|
+
rows: asArray(listing["permissions"]).map((permission) => projectPermission(ctx, permission)),
|
|
850
|
+
next: nextToken(listing["nextPageToken"]),
|
|
851
|
+
};
|
|
852
|
+
}));
|
|
853
|
+
}),
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
name: "list_shared_drives",
|
|
857
|
+
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.",
|
|
858
|
+
annotations: readOnly,
|
|
859
|
+
contract: contracts.list_shared_drives,
|
|
860
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
861
|
+
return yield* pageOf(ctx, "drives", args, { tool: "list_shared_drives", q: args["query"] }, (pageToken, pageSize) => Effect.gen(function* () {
|
|
862
|
+
const listing = asRecord(yield* client.json({
|
|
863
|
+
method: "GET",
|
|
864
|
+
path: "/drives",
|
|
865
|
+
query: {
|
|
866
|
+
q: args["query"],
|
|
867
|
+
fields: "nextPageToken,drives(id,name,hidden)",
|
|
868
|
+
pageSize,
|
|
869
|
+
pageToken,
|
|
870
|
+
},
|
|
871
|
+
}));
|
|
872
|
+
return {
|
|
873
|
+
ids: asArray(listing["drives"]).map((value) => String(asRecord(value)["id"] ?? "")),
|
|
874
|
+
rows: asArray(listing["drives"]).map((value) => {
|
|
875
|
+
const sharedDrive = asRecord(value);
|
|
876
|
+
const cuts = new Cuts(ctx);
|
|
877
|
+
return compact({
|
|
878
|
+
id: cuts.id("id", sharedDrive["id"]),
|
|
879
|
+
name: cuts.label("name", sharedDrive["name"]),
|
|
880
|
+
hidden: sharedDrive["hidden"] === true ? true : undefined,
|
|
881
|
+
truncatedFields: cuts.fields,
|
|
882
|
+
});
|
|
883
|
+
}),
|
|
884
|
+
next: nextToken(listing["nextPageToken"]),
|
|
885
|
+
};
|
|
886
|
+
}));
|
|
887
|
+
}),
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
name: "create_folder",
|
|
891
|
+
description: "Create a new, empty Drive folder, in My Drive or inside another folder. Shares nothing beyond what the parent folder already grants.",
|
|
892
|
+
annotations: additive,
|
|
893
|
+
contract: contracts.create_folder,
|
|
894
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
895
|
+
return projectFile(ctx, yield* adding(connection, () => client.json({
|
|
896
|
+
method: "POST",
|
|
897
|
+
path: "/files",
|
|
898
|
+
query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS },
|
|
899
|
+
body: compact({
|
|
900
|
+
name: args["name"],
|
|
901
|
+
mimeType: FOLDER,
|
|
902
|
+
parents: args["parentId"] ? [args["parentId"]] : undefined,
|
|
903
|
+
description: args["description"],
|
|
904
|
+
}),
|
|
905
|
+
}), CHECK_CREATED));
|
|
906
|
+
}),
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
name: "create_file",
|
|
910
|
+
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.",
|
|
911
|
+
// Destructive though nothing is overwritten: a new file takes its
|
|
912
|
+
// folder's sharing, so content written into a shared folder is
|
|
913
|
+
// disclosed to everyone it is shared with, at once.
|
|
914
|
+
annotations: destructive,
|
|
915
|
+
contract: contracts.create_file,
|
|
916
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
917
|
+
const content = media(args);
|
|
918
|
+
const convert = typeof args["convertTo"] === "string" ? CONVERSIONS[args["convertTo"]] : undefined;
|
|
919
|
+
const metadata = compact({
|
|
920
|
+
name: args["name"],
|
|
921
|
+
parents: args["parentId"] ? [args["parentId"]] : undefined,
|
|
922
|
+
description: args["description"],
|
|
923
|
+
mimeType: convert ?? content?.type,
|
|
924
|
+
});
|
|
925
|
+
if (!content) {
|
|
926
|
+
if (args["mimeType"] !== undefined) {
|
|
927
|
+
throw new ConnectorCallError("invalid_args", "mimeType describes content; pass content or contentBase64 with it.");
|
|
928
|
+
}
|
|
929
|
+
return projectFile(ctx, yield* adding(connection, () => client.json({
|
|
930
|
+
method: "POST",
|
|
931
|
+
path: "/files",
|
|
932
|
+
query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS },
|
|
933
|
+
body: metadata,
|
|
934
|
+
}), CHECK_CREATED));
|
|
935
|
+
}
|
|
936
|
+
const framed = multipart(metadata, content);
|
|
937
|
+
return projectFile(ctx, yield* adding(uploadConnection, () => upload.json({
|
|
938
|
+
method: "POST",
|
|
939
|
+
path: "/files",
|
|
940
|
+
query: { ...ALL_DRIVES, uploadType: "multipart", fields: SUMMARY_FIELDS },
|
|
941
|
+
headers: { "Content-Type": framed.contentType },
|
|
942
|
+
rawBody: new Uint8Array(framed.body),
|
|
943
|
+
}), CHECK_CREATED));
|
|
944
|
+
}),
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
name: "update_file_content",
|
|
948
|
+
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.",
|
|
949
|
+
// Destructive: the whole content is overwritten. Drive keeps revisions
|
|
950
|
+
// for a while, but this connection offers no way back to one.
|
|
951
|
+
annotations: destructive,
|
|
952
|
+
contract: contracts.update_file_content,
|
|
953
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
954
|
+
const content = media(args);
|
|
955
|
+
if (!content) {
|
|
956
|
+
throw new ConnectorCallError("invalid_args", "Pass content or contentBase64: the file's new content.");
|
|
957
|
+
}
|
|
958
|
+
return projectFile(ctx, yield* upload.json({
|
|
959
|
+
method: "PATCH",
|
|
960
|
+
path: filePath(args["fileId"]),
|
|
961
|
+
query: { ...ALL_DRIVES, uploadType: "media", fields: SUMMARY_FIELDS },
|
|
962
|
+
headers: { "Content-Type": content.type },
|
|
963
|
+
rawBody: new Uint8Array(content.bytes),
|
|
964
|
+
}), String(args["fileId"]));
|
|
965
|
+
}),
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
name: "update_file",
|
|
969
|
+
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.",
|
|
970
|
+
// Destructive: the old name or description is overwritten, and a
|
|
971
|
+
// rename can break what finds the file by name.
|
|
972
|
+
annotations: destructive,
|
|
973
|
+
contract: contracts.update_file,
|
|
974
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
975
|
+
if (args["name"] === undefined && args["description"] === undefined) {
|
|
976
|
+
throw new ConnectorCallError("invalid_args", "Pass name, description, or both.");
|
|
977
|
+
}
|
|
978
|
+
return yield* patch(ctx, args["fileId"], compact({ name: args["name"], description: args["description"] }));
|
|
979
|
+
}),
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
name: "move_file",
|
|
983
|
+
writeSteps: { parents: "once", write: "once" },
|
|
984
|
+
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.",
|
|
985
|
+
// Destructive: a file inherits sharing from its folder, so a move can
|
|
986
|
+
// revoke access granted through the old folder or grant it through the
|
|
987
|
+
// new one, and moving into a shared drive hands ownership to the drive.
|
|
988
|
+
annotations: destructive,
|
|
989
|
+
contract: contracts.move_file,
|
|
990
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
991
|
+
const current = decisionRecord(yield* client.json({ method: "GET", path: filePath(args["fileId"]), query: { ...ALL_DRIVES, fields: "parents" } }, { step: "parents", effect: "read" }));
|
|
992
|
+
decisionInputs(current, ["parents"]);
|
|
993
|
+
const from = idsOf(current["parents"]).filter((id) => id !== args["folderId"]);
|
|
994
|
+
return yield* patch(ctx, args["fileId"], {}, {
|
|
995
|
+
addParents: args["folderId"],
|
|
996
|
+
removeParents: from.length > 0 ? from.join(",") : undefined,
|
|
997
|
+
});
|
|
998
|
+
}),
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
name: "copy_file",
|
|
1002
|
+
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.",
|
|
1003
|
+
// Destructive though nothing is overwritten: the copy takes its
|
|
1004
|
+
// destination folder's sharing, so a private file copied into a shared
|
|
1005
|
+
// folder is disclosed — the same exposure move_file carries.
|
|
1006
|
+
annotations: destructive,
|
|
1007
|
+
contract: contracts.copy_file,
|
|
1008
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1009
|
+
return projectFile(ctx, yield* adding(connection, () => client.json({
|
|
1010
|
+
method: "POST",
|
|
1011
|
+
path: `${filePath(args["fileId"])}/copy`,
|
|
1012
|
+
query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS },
|
|
1013
|
+
body: compact({ name: args["name"], parents: args["parentId"] ? [args["parentId"]] : undefined }),
|
|
1014
|
+
}), "Look for the copy with search_files or list_folder_items before copying again: a second call makes a second copy."));
|
|
1015
|
+
}),
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
name: "trash_file",
|
|
1019
|
+
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.",
|
|
1020
|
+
// Destructive: collaborators lose the file at once, and the trash
|
|
1021
|
+
// empties itself.
|
|
1022
|
+
annotations: destructive,
|
|
1023
|
+
contract: contracts.trash_file,
|
|
1024
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1025
|
+
return yield* patch(ctx, args["fileId"], { trashed: true });
|
|
1026
|
+
}),
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
name: "restore_file",
|
|
1030
|
+
description: "Restore (untrash) a trashed Drive file or folder to where it was. Cannot recover a file already deleted from the trash.",
|
|
1031
|
+
// Additive: brings back exactly what was there, losing nothing.
|
|
1032
|
+
annotations: additive,
|
|
1033
|
+
contract: contracts.restore_file,
|
|
1034
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1035
|
+
return yield* patch(ctx, args["fileId"], { trashed: false });
|
|
1036
|
+
}),
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
name: "share_file",
|
|
1040
|
+
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.",
|
|
1041
|
+
annotations: destructive,
|
|
1042
|
+
contract: contracts.share_file,
|
|
1043
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1044
|
+
const type = String(args["type"]);
|
|
1045
|
+
const person = type === "user" || type === "group";
|
|
1046
|
+
const refuse = (message) => {
|
|
1047
|
+
throw new ConnectorCallError("invalid_args", message);
|
|
1048
|
+
};
|
|
1049
|
+
if (person !== (args["emailAddress"] !== undefined))
|
|
1050
|
+
refuse("emailAddress goes with type user or group, and only with them.");
|
|
1051
|
+
if ((type === "domain") !== (args["domain"] !== undefined))
|
|
1052
|
+
refuse("domain goes with type domain, and only with it.");
|
|
1053
|
+
if (person && args["allowFileDiscovery"] !== undefined)
|
|
1054
|
+
refuse("allowFileDiscovery applies to type domain or anyone only.");
|
|
1055
|
+
if (!person && args["sendNotificationEmail"] !== undefined)
|
|
1056
|
+
refuse("sendNotificationEmail applies to a user or group only.");
|
|
1057
|
+
const notify = person ? args["sendNotificationEmail"] === true : undefined;
|
|
1058
|
+
if (args["emailMessage"] !== undefined && notify !== true)
|
|
1059
|
+
refuse("emailMessage needs sendNotificationEmail true.");
|
|
1060
|
+
return projectPermission(ctx, yield* adding(connection, () => client.json({
|
|
1061
|
+
method: "POST",
|
|
1062
|
+
path: `${filePath(args["fileId"])}/permissions`,
|
|
1063
|
+
query: {
|
|
1064
|
+
...ALL_DRIVES,
|
|
1065
|
+
fields: PERMISSION_FIELDS,
|
|
1066
|
+
// Drive's default is to email; this connection's is not to.
|
|
1067
|
+
sendNotificationEmail: notify,
|
|
1068
|
+
emailMessage: args["emailMessage"],
|
|
1069
|
+
},
|
|
1070
|
+
body: compact({
|
|
1071
|
+
type,
|
|
1072
|
+
role: args["role"],
|
|
1073
|
+
emailAddress: args["emailAddress"],
|
|
1074
|
+
domain: args["domain"],
|
|
1075
|
+
allowFileDiscovery: person ? undefined : args["allowFileDiscovery"] === true,
|
|
1076
|
+
}),
|
|
1077
|
+
}), "Check list_permissions before sharing again: the share may already be in place, and its notification sent."));
|
|
1078
|
+
}),
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
name: "update_permission",
|
|
1082
|
+
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.",
|
|
1083
|
+
annotations: destructive,
|
|
1084
|
+
contract: contracts.update_permission,
|
|
1085
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1086
|
+
return projectPermission(ctx, yield* client.json({
|
|
1087
|
+
method: "PATCH",
|
|
1088
|
+
path: `${filePath(args["fileId"])}/permissions/${encodeURIComponent(args["permissionId"])}`,
|
|
1089
|
+
query: { ...ALL_DRIVES, fields: PERMISSION_FIELDS },
|
|
1090
|
+
body: { role: args["role"] },
|
|
1091
|
+
}), String(args["permissionId"]));
|
|
1092
|
+
}),
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
name: "delete_permission",
|
|
1096
|
+
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.",
|
|
1097
|
+
annotations: destructive,
|
|
1098
|
+
contract: contracts.delete_permission,
|
|
1099
|
+
run: (args) => Effect.gen(function* () {
|
|
1100
|
+
yield* client.json({
|
|
1101
|
+
method: "DELETE",
|
|
1102
|
+
path: `${filePath(args["fileId"])}/permissions/${encodeURIComponent(args["permissionId"])}`,
|
|
1103
|
+
query: ALL_DRIVES,
|
|
1104
|
+
});
|
|
1105
|
+
return { fileId: args["fileId"], permissionId: args["permissionId"], deleted: true };
|
|
1106
|
+
}),
|
|
1107
|
+
},
|
|
1108
|
+
];
|
|
1109
|
+
return declarations.map((declaration) => buildTool(connection, guarded(declaration)));
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* The last word on size, around every tool and every way it can return:
|
|
1113
|
+
* whatever a handler built, a result past the call's `maxBytes` — or the
|
|
1114
|
+
* shared budget, for a tool without one — is refused rather than delivered.
|
|
1115
|
+
* Each source is budgeted where it is built, so this should never fire; when
|
|
1116
|
+
* it does, the caller learns why instead of a result execute_code cannot
|
|
1117
|
+
* receive.
|
|
1118
|
+
*
|
|
1119
|
+
* A write handler returns only from Google's recorded 2xx to its write — the
|
|
1120
|
+
* shared client throws on every other status, a redirect included, and on a
|
|
1121
|
+
* reply it could not read — so a write that gets here has applied, and only
|
|
1122
|
+
* here does this connection say so, pointing at a re-read rather than a
|
|
1123
|
+
* repeat.
|
|
1124
|
+
*/
|
|
1125
|
+
function guarded(tool) {
|
|
1126
|
+
return {
|
|
1127
|
+
...tool,
|
|
1128
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1129
|
+
const result = standardResult(yield* tool.run(args, ctx));
|
|
1130
|
+
const budget = typeof args?.["maxBytes"] === "number" ? args["maxBytes"] : DEFAULT_RESULT_BYTES;
|
|
1131
|
+
const bytes = jsonBytes(result);
|
|
1132
|
+
if (bytes <= budget)
|
|
1133
|
+
return result;
|
|
1134
|
+
const raisable = "maxBytes" in tool.contract.input.fields;
|
|
1135
|
+
throw new ConnectorCallError("connector_call_failed", tool.annotations.readOnlyHint
|
|
1136
|
+
? `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."}`
|
|
1137
|
+
: `${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 });
|
|
1138
|
+
}),
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
export function buildTool(connection, tool) {
|
|
1142
|
+
const isRead = tool.annotations.readOnlyHint;
|
|
1143
|
+
const isList = ["search_files", "list_folder_items", "list_permissions", "list_shared_drives"].includes(tool.name);
|
|
1144
|
+
const output = asRecord(catalogSchema(tool.contract.output, "output"));
|
|
1145
|
+
const row = isList ? asRecord(asRecord(asRecord(output["properties"])["items"])["items"]) : output;
|
|
1146
|
+
const roots = new Set(Object.keys(asRecord(row["properties"])));
|
|
1147
|
+
const input = Schema.Struct({
|
|
1148
|
+
...tool.contract.input.fields,
|
|
1149
|
+
...(isRead
|
|
1150
|
+
? {
|
|
1151
|
+
fields: Schema.optionalKey(fieldsInput(isList
|
|
1152
|
+
? "Dot paths to keep in each item; only narrows the result."
|
|
1153
|
+
: "Dot paths to keep in the result; only narrows the result.")),
|
|
1154
|
+
}
|
|
1155
|
+
: {}),
|
|
1156
|
+
});
|
|
1157
|
+
const spec = {
|
|
1158
|
+
name: tool.name,
|
|
1159
|
+
description: tool.description,
|
|
1160
|
+
input,
|
|
1161
|
+
output: tool.contract.output,
|
|
1162
|
+
...(tool.writeSteps ? { writeSteps: tool.writeSteps } : {}),
|
|
1163
|
+
run: (args) => Effect.gen(function* () {
|
|
1164
|
+
const fields = args["fields"];
|
|
1165
|
+
const problem = isRead ? fieldsProblem(fields, roots, isList ? "an item" : "the result") : undefined;
|
|
1166
|
+
if (problem)
|
|
1167
|
+
return yield* Effect.fail(new Refusal({ error: problem }));
|
|
1168
|
+
const active = yield* Effect.serviceOption(Operation);
|
|
1169
|
+
const ctx = Option.isSome(active) ? active.value : undefined;
|
|
1170
|
+
if (!ctx)
|
|
1171
|
+
return yield* Effect.die(new Error(`${tool.name} needs a connector context.`));
|
|
1172
|
+
const supplied = args["cursor"];
|
|
1173
|
+
const { maxBytes: _maxBytes, fields: _fields, ...cursorArgs } = args;
|
|
1174
|
+
const cursor = isList && supplied ? yield* boundCursor.decode(tool.name, cursorArgs, supplied) : supplied;
|
|
1175
|
+
const result = asRecord(yield* tool.run({ ...args, ...(cursor ? { cursor } : {}) }, ctx));
|
|
1176
|
+
const page = asRecord(result["page"]);
|
|
1177
|
+
if (isList && typeof page["nextCursor"] === "string")
|
|
1178
|
+
page["nextCursor"] = yield* boundCursor.encode(tool.name, cursorArgs, page["nextCursor"], (json) => encodeForAgent(ctx, json, { url: true, claims: true }));
|
|
1179
|
+
if (isList && jsonBytes(result) > (args["maxBytes"] ?? DEFAULT_RESULT_BYTES))
|
|
1180
|
+
throw new ConnectorCallError("connector_call_failed", "Drive continuation exceeded the result byte budget; narrow the listing.");
|
|
1181
|
+
return isRead
|
|
1182
|
+
? isList
|
|
1183
|
+
? {
|
|
1184
|
+
...result,
|
|
1185
|
+
items: asArray(result["items"]).map((item) => retainTruncated(item, selectFields(item, fields))),
|
|
1186
|
+
}
|
|
1187
|
+
: retainTruncated(result, selectFields(result, fields))
|
|
1188
|
+
: result;
|
|
1189
|
+
}).pipe(Effect.catchDefect((error) => error instanceof ConnectorCallError ? Effect.fail(new Refusal({ error })) : Effect.die(error))),
|
|
1190
|
+
};
|
|
1191
|
+
return isRead
|
|
1192
|
+
? read(connection, spec)
|
|
1193
|
+
: write(connection, { ...spec, destructive: tool.annotations.destructiveHint !== false });
|
|
1194
|
+
}
|
|
1195
|
+
/** Field selection narrows data, while size provenance remains result metadata. */
|
|
1196
|
+
function retainTruncated(source, selected) {
|
|
1197
|
+
const truncated = asRecord(source)["truncated"];
|
|
1198
|
+
return truncated === undefined ? selected : { ...asRecord(selected), truncated };
|
|
1199
|
+
}
|
|
1200
|
+
/** Convert size reports before the final byte guard and field selection. */
|
|
1201
|
+
function standardResult(value) {
|
|
1202
|
+
const all = new Set();
|
|
1203
|
+
const normalize = (value, path = "") => {
|
|
1204
|
+
if (Array.isArray(value))
|
|
1205
|
+
return value.map((item) => normalize(item, `${path}[]`));
|
|
1206
|
+
if (!value || typeof value !== "object")
|
|
1207
|
+
return value;
|
|
1208
|
+
const out = {};
|
|
1209
|
+
const cuts = [];
|
|
1210
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1211
|
+
if (key === "truncatedFields")
|
|
1212
|
+
cuts.push(...item);
|
|
1213
|
+
else if (key === "contentTruncated") {
|
|
1214
|
+
if (item === true)
|
|
1215
|
+
cuts.push("content");
|
|
1216
|
+
}
|
|
1217
|
+
else
|
|
1218
|
+
out[key] = normalize(item, path ? `${path}.${key}` : key);
|
|
1219
|
+
}
|
|
1220
|
+
for (const cut of cuts)
|
|
1221
|
+
all.add(path ? `${path}.${cut}` : cut);
|
|
1222
|
+
if (cuts.length)
|
|
1223
|
+
out["truncated"] = [...new Set(cuts)];
|
|
1224
|
+
return out;
|
|
1225
|
+
};
|
|
1226
|
+
const result = normalize(value);
|
|
1227
|
+
if (all.size)
|
|
1228
|
+
result["truncated"] = [...all];
|
|
1229
|
+
return result;
|
|
1230
|
+
}
|
|
1231
|
+
function nextToken(value) {
|
|
1232
|
+
if (value === undefined || value === "")
|
|
1233
|
+
return undefined;
|
|
1234
|
+
if (typeof value !== "string")
|
|
1235
|
+
throw new ConnectorCallError("connector_call_failed", "Drive returned malformed continuation metadata.");
|
|
1236
|
+
return value;
|
|
1237
|
+
}
|
|
1238
|
+
function decisionRecord(value) {
|
|
1239
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1240
|
+
decisionFailure();
|
|
1241
|
+
// A withheld key may have been mimeType/parents; absence cannot then mean a legitimate omission.
|
|
1242
|
+
for (const key of Object.keys(value)) {
|
|
1243
|
+
try {
|
|
1244
|
+
refuseWithheldValues(key, "Drive decision metadata", true);
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
decisionFailure();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return value;
|
|
1251
|
+
}
|
|
1252
|
+
function decisionFailure() {
|
|
1253
|
+
throw new ConnectorCallError("connector_call_failed", "Drive decision metadata was withheld or malformed; no follow-up request was sent.", { retryable: false });
|
|
1254
|
+
}
|
|
1255
|
+
/** Only safe response values are inspected; never recover the original decision inputs. */
|
|
1256
|
+
function decisionInputs(file, keys) {
|
|
1257
|
+
for (const key of keys) {
|
|
1258
|
+
const value = file[key];
|
|
1259
|
+
try {
|
|
1260
|
+
refuseWithheldValues(value, "Drive decision metadata", true);
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
decisionFailure();
|
|
1264
|
+
}
|
|
1265
|
+
if (value === undefined)
|
|
1266
|
+
continue;
|
|
1267
|
+
if (key === "mimeType" && (typeof value !== "string" || !MEDIA_TYPE.test(value)))
|
|
1268
|
+
decisionFailure();
|
|
1269
|
+
if (key === "driveId" && (typeof value !== "string" || !DRIVE_ID.test(value)))
|
|
1270
|
+
decisionFailure();
|
|
1271
|
+
if (key === "parents" &&
|
|
1272
|
+
(!Array.isArray(value) || value.some((id) => typeof id !== "string" || !DRIVE_ID.test(id))))
|
|
1273
|
+
decisionFailure();
|
|
1274
|
+
if (key === "capabilities" &&
|
|
1275
|
+
(typeof value !== "object" ||
|
|
1276
|
+
value === null ||
|
|
1277
|
+
Array.isArray(value) ||
|
|
1278
|
+
(asRecord(value)["canDownload"] !== undefined && typeof asRecord(value)["canDownload"] !== "boolean")))
|
|
1279
|
+
decisionFailure();
|
|
1280
|
+
if (key === "shortcutDetails") {
|
|
1281
|
+
const shortcut = decisionRecord(value);
|
|
1282
|
+
if (shortcut["targetId"] !== undefined &&
|
|
1283
|
+
(typeof shortcut["targetId"] !== "string" || !DRIVE_ID.test(shortcut["targetId"])))
|
|
1284
|
+
decisionFailure();
|
|
1285
|
+
if (shortcut["targetMimeType"] !== undefined &&
|
|
1286
|
+
(typeof shortcut["targetMimeType"] !== "string" || !MEDIA_TYPE.test(shortcut["targetMimeType"])))
|
|
1287
|
+
decisionFailure();
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|