From 09012882d88df999c1ae58d2fe210d1dc6f91257 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 21 Aug 2026 11:00:26 -0500 Subject: [PATCH 1/2] Add Google Drive native document sessions --- packages/gatekeeper-google/README.md | 18 ++- .../__tests__/configurator-url.test.ts | 34 ++++- .../__tests__/drive-session.test.ts | 118 ++++++++++++++++- .../__tests__/native-api.test.ts | 83 ++++++++++++ .../__tests__/resources.test.ts | 51 ++++++-- .../__tests__/types-parity.test.ts | 7 +- .../gatekeeper-google/__tests__/types.test.ts | 40 ++++++ .../__tests__/workerd/native-sessions.test.ts | 110 ++++++++++++++++ packages/gatekeeper-google/package.json | 4 +- .../drive-account-configurator-ui.tsx | 4 +- .../drive-file-configurator-ui.tsx | 2 +- .../shared-drive-configurator-ui.tsx | 2 +- packages/gatekeeper-google/src/docs-api.ts | 63 ++++----- .../src/docs-read-types.d.ts | 17 +++ .../gatekeeper-google/src/docs-read-types.txt | 1 + .../gatekeeper-google/src/docs-types.d.ts | 17 +-- .../gatekeeper-google/src/drive-session.ts | 24 ++++ .../gatekeeper-google/src/drive-types.d.ts | 24 +++- .../gatekeeper-google/src/google-response.ts | 66 ++++++++++ packages/gatekeeper-google/src/google.ts | 123 +++++++++++++++--- packages/gatekeeper-google/src/resources.ts | 26 ++-- packages/gatekeeper-google/src/sheets-api.ts | 88 ++----------- packages/gatekeeper-google/vite.config.ts | 11 +- .../gatekeeper-google/vitest.worker.config.ts | 22 ++++ pnpm-lock.yaml | 6 + 25 files changed, 778 insertions(+), 183 deletions(-) create mode 100644 packages/gatekeeper-google/__tests__/native-api.test.ts create mode 100644 packages/gatekeeper-google/__tests__/types.test.ts create mode 100644 packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts create mode 100644 packages/gatekeeper-google/src/docs-read-types.d.ts create mode 120000 packages/gatekeeper-google/src/docs-read-types.txt create mode 100644 packages/gatekeeper-google/src/google-response.ts create mode 100644 packages/gatekeeper-google/vitest.worker.config.ts diff --git a/packages/gatekeeper-google/README.md b/packages/gatekeeper-google/README.md index b5a33c2b0..def7b76c8 100644 --- a/packages/gatekeeper-google/README.md +++ b/packages/gatekeeper-google/README.md @@ -51,7 +51,7 @@ You'll need to enable the Google APIs that you want to use. Currently supported: 18. Click on **BigQuery API** in the results 19. Click **Enable** -The Google Drive API powers the Docs and Sheets resource pickers and the read-only Drive metadata bindings described below. Document reads and edits still go through the Google Docs API, and spreadsheet reads go through the Google Sheets API. +The Google Drive API powers the Docs and Sheets resource pickers, Drive discovery, and Drive scope checks. Native document or spreadsheet content opened from a Drive binding is read through the Google Docs or Google Sheets API. Direct Google Doc reads and edits still go through the Docs API, and direct spreadsheet reads go through the Sheets API. ### Step 3: Configure the OAuth Consent Screen @@ -74,10 +74,10 @@ included). Across all resource types, the gatekeeper can request: - `openid`, `userinfo.profile`, and `userinfo.email` to identify the connected account. - `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending. -- `documents` for Google Docs reads and edits. -- `drive.metadata.readonly` for the Docs and Sheets pickers, connected-account Drive metadata, and exact-file metadata. -- `drive.readonly` for the shared-drive resource. This is wider than anything the gatekeeper reads — it is a Google *restricted* scope conveying account-wide file content — but `drives.list` and `drives.get`, which the shared-drive picker and the binding's own scope lookup need, accept nothing narrower. OAuth scopes are held per connected account and only ever expand, so one shared-drive binding upgrades that account's token for good. The gatekeeper itself still exposes metadata only. -- `spreadsheets.readonly` to read metadata and cell values from selected Google spreadsheets. +- `documents` for direct Google Docs reads and edits; `documents.readonly` for native Docs opened from account-wide or exact-file Drive bindings. +- `drive.metadata.readonly` for the Docs and Sheets pickers, account-wide Drive discovery, exact-file metadata, and native-file scope checks. +- `drive.readonly` for the shared-drive picker and scope lookup, metadata search, and native Docs or Sheets reads within one shared drive. This restricted scope conveys account-wide file content and remains after the account expands consent, but Google accepts nothing narrower for `drives.list`/`drives.get` and accepts this Drive scope for the native APIs. The gatekeeper still enforces the shared-drive binding boundary. +- `spreadsheets.readonly` to read metadata and bounded cell ranges from directly selected spreadsheets or native Sheets opened from account-wide or exact-file Drive bindings. - `calendar.calendarlist.readonly` so the resource picker can list calendars. - `calendar.events` to manage selected calendar and check calendar availability. - `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries. @@ -176,9 +176,13 @@ Drive exposes three permanent resource URL forms: Despite the `/folders/` URL, the second resource is a Google Workspace shared drive, not an individual folder. Google uses a shared drive's ID for its root folder too. The gatekeeper confirms the ID with `drives.get`, so it rejects ordinary folder IDs. -The agent-facing `GoogleDriveSession` returns metadata only. It can report the binding scope, list entries, run structured searches, and fetch one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. The API does not expose raw Drive `q` strings, file contents, writes, shortcut traversal, native Docs or Sheets sessions, or Workers AI extraction. One caveat on "metadata only": the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description and OCR text. Results still carry metadata alone, but repeated queries are a content oracle over files the agent can never read directly. +The agent-facing `GoogleDriveSession` reports the binding scope, lists entries, runs structured searches, and fetches one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. For a native Google Doc or Sheet, `openGoogleDoc()` or `openGoogleSheet()` returns an independently disposable, read-only nested session. Docs expose metadata and Markdown content; Sheets expose spreadsheet metadata and bounded A1 range reads. The API does not expose raw Drive `q` strings, file writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. One caveat: the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description, and OCR text. Results carry metadata alone, but repeated queries remain a content oracle over files the agent cannot otherwise read. -Account and shared-drive bindings use per-file observer tracking because individual shared-drive items can carry narrower ACLs. They remember every file ID whose metadata a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires that their own account explicitly consented to a Drive resource — a Drive grant is never inferred from held OAuth scopes, because the Docs and Sheets pickers request the same `drive.metadata.readonly` — and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page is disclosed, it checks the page's IDs against every existing observer and excludes observers who cannot access them. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests, and a binding is capped at 2,000 distinct file IDs; attempting to cross the limit refuses the read and asks the user to bind a narrower scope. There is deliberately no cached access verdict, so revoked access fails closed on the next open. +Every native open re-fetches Drive metadata, enforces the immutable account, shared-drive, or exact-file scope, and checks the exact MIME type before authorizing the observation. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it. + +Account-wide and exact-file Drive bindings request `documents.readonly` and `spreadsheets.readonly` in addition to `drive.metadata.readonly`. An older metadata-only connection is therefore prompted to expand consent before it is treated as granting either resource. Shared-drive bindings remain on `drive.readonly`, which Google accepts for native Docs and Sheets reads, so they do not request redundant scopes. + +Account and shared-drive bindings use per-file observer tracking because individual shared-drive items can carry narrower ACLs. They remember every file ID whose metadata or native content a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires that their own account explicitly consented to a Drive resource — a Drive grant is never inferred from held OAuth scopes — and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page or native child capability is disclosed, it checks the file ID against every existing observer and excludes observers who cannot access it. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests, and a binding is capped at 2,000 distinct file IDs; attempting to cross the limit refuses the read and asks the user to bind a narrower scope. There is deliberately no cached access verdict, so revoked access fails closed on the next open. ## Troubleshooting diff --git a/packages/gatekeeper-google/__tests__/configurator-url.test.ts b/packages/gatekeeper-google/__tests__/configurator-url.test.ts index 98b56549a..44b47a6b4 100644 --- a/packages/gatekeeper-google/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-google/__tests__/configurator-url.test.ts @@ -7,7 +7,17 @@ // `encodeURIComponent`, a normalization one side does and the other does not -- shows up here // rather than as a resource the backend rejects after the user has filled the form. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@gadgets/configurator-ui", () => ({ + h: (component: unknown, props: unknown, ...children: unknown[]) => ({ + component, props, children, + }), + Autocomplete: "Autocomplete", + Field: "Field", + RadioCards: "RadioCards", + Section: "Section", +})); import driveAccountConfigurator from "../src/configurator/drive-account-configurator-ui"; import driveFileConfigurator from "../src/configurator/drive-file-configurator-ui"; import gmailConfigurator from "../src/configurator/gmail-configurator-ui"; @@ -51,6 +61,16 @@ function valuesFromUrlPattern(resourceUrl: string, resourceUrlPattern: string) { return out; } +const configurableValues = ( + configurator: { initialValuesFromResourceUrl?: (context: { + resourceUrl: string; resourceUrlPattern: string; ui: never; + }) => unknown }, + resourceUrl: string, + resourceUrlPattern: string, +) => configurator.initialValuesFromResourceUrl!({ resourceUrl, resourceUrlPattern, ui: noUi }); + +const renderedCopy = (configurator: { render?: (context: never) => unknown }) => + JSON.stringify(configurator.render!({ values: {}, setValues() {}, ui: noUi } as never)); describe("Gmail configurator URLs", () => { it.for([ ["the whole mailbox", { mode: "all" }, { kind: "gmail" }], @@ -114,6 +134,18 @@ describe("Drive configurator URLs", () => { expect(parseResourceUrl(url)).toEqual({ kind: "driveAccount" }); }); + it("explains native Doc and Sheet reads at every Drive scope", () => { + expect(renderedCopy(driveAccountConfigurator)).toContain( + "Returns metadata for every item and read-only content sessions for native Docs and Sheets.", + ); + expect(renderedCopy(sharedDriveConfigurator)).toContain( + "Search its files and read native Google Docs and Sheets.", + ); + expect(renderedCopy(driveFileConfigurator)).toContain( + "A selected native Google Doc or Sheet also provides read-only content.", + ); + }); + it("round-trips an encoded shared-drive ID", () => { let values = { driveId: "shared/id with spaces" }; let url = configurableUrl(sharedDriveConfigurator, values); diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index 2d0933809..ddbccf7a7 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; import { DriveSessionCore, driveFileToEntry } from "../src/drive-session"; import { DriveApiRequestError, type DriveFile, type DriveListFilesOptions } from "../src/drive-api"; +import type { ObserverCheck } from "../src/observers"; const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; @@ -23,6 +24,7 @@ function core(overrides: { files: DriveFile[]; nextPageToken?: string; }>; + prepareObservation?: (ids: string[]) => Promise>; authorize?: (description: ObservationDescription) => Promise; observerIds?: () => string[]; } = {}) { @@ -36,14 +38,14 @@ function core(overrides: { let session = new DriveSessionCore({ api: { listFiles, getFile, getDrive }, scope: overrides.scope ?? { kind: "account" }, - prepareObservation: async (ids: string[]) => { + prepareObservation: overrides.prepareObservation ?? (async (ids: string[]) => { prepared.push(ids); return { excludeObservers: ["excluded"], pendingSets: ids, commit: () => events.push("commit"), }; - }, + }), observerIds: overrides.observerIds ?? (() => ["excluded"]), authorize: async (description: ObservationDescription) => { authorizations.push(description); @@ -423,6 +425,118 @@ describe("Drive parent folder probe", () => { }); }); +describe("Drive native sessions", () => { + const docMime = "application/vnd.google-apps.document"; + const sheetMime = "application/vnd.google-apps.spreadsheet"; + + it.each([ + ["account Doc", { kind: "account" } as const, docMime, "Google Doc"], + ["account Sheet", { kind: "account" } as const, sheetMime, "Google Sheet"], + ["shared-drive Doc", { kind: "sharedDrive", driveId: "drive-1" } as const, + docMime, "Google Doc"], + ["shared-drive Sheet", { kind: "sharedDrive", driveId: "drive-1" } as const, + sheetMime, "Google Sheet"], + ["exact-file Doc", { kind: "file", fileId: "file-1" } as const, + docMime, "Google Doc"], + ["exact-file Sheet", { kind: "file", fileId: "file-1" } as const, + sheetMime, "Google Sheet"], + ])("opens an in-scope native %s", async (_name, scope, mimeType, description) => { + let { session, getFile } = core({ + scope, + getFile: async id => file({ + id, + mimeType, + ...(scope.kind === "sharedDrive" ? { driveId: scope.driveId } : {}), + }), + }); + + await expect(session.openNativeFile("file-1", mimeType, description)) + .resolves.toBe("file-1"); + expect(getFile).toHaveBeenCalledWith("file-1"); + }); + + it("rejects another exact-file ID before calling Google", async () => { + let { session, getFile } = core({ scope: { kind: "file", fileId: "file-1" } }); + + await expect(session.openNativeFile("file-2", docMime, "Google Doc")) + .rejects.toThrow(/outside this Drive binding/); + expect(getFile).not.toHaveBeenCalled(); + }); + + it("rejects a foreign shared-drive file without authorizing or tracking it", async () => { + let { session, prepared, authorizations } = core({ + scope: { kind: "sharedDrive", driveId: "drive-1" }, + getFile: async id => file({ id, driveId: "drive-2", mimeType: docMime }), + }); + + await expect(session.openNativeFile("foreign", docMime, "Google Doc")) + .rejects.toThrow(/outside this Drive binding/); + expect(prepared).toEqual([]); + expect(authorizations).toEqual([]); + }); + + it.each([ + ["wrong native type", sheetMime, undefined], + ["folder", "application/vnd.google-apps.folder", undefined], + ["blob", "application/pdf", undefined], + ["shortcut", "application/vnd.google-apps.shortcut", { targetId: "target-1" }], + ])("observes a %s before rejecting its MIME type", async (_name, mimeType, shortcutDetails) => { + let { session, prepared, authorizations, events } = core({ + getFile: async id => file({ id, mimeType, shortcutDetails }), + }); + + await expect(session.openNativeFile("file-1", docMime, "Google Doc")) + .rejects.toThrow(/not a Google Doc/); + expect(prepared).toEqual([["file-1"]]); + expect(authorizations).toEqual([expect.objectContaining({ excludeObservers: ["excluded"] })]); + expect(events).toEqual(["authorize", "commit"]); + }); + + it("never follows a shortcut target implicitly", async () => { + let getFile = vi.fn(async (id: string) => file({ + id, + mimeType: "application/vnd.google-apps.shortcut", + shortcutDetails: { targetId: "target-1", targetMimeType: docMime }, + })); + let { session } = core({ getFile }); + + await expect(session.openNativeFile("shortcut-1", docMime, "Google Doc")) + .rejects.toThrow(/not a Google Doc/); + expect(getFile).toHaveBeenCalledTimes(1); + expect(getFile).toHaveBeenCalledWith("shortcut-1"); + }); + + it("forwards observer exclusions and commits only after authorization", async () => { + let { session, authorizations, events } = core({ + getFile: async id => file({ id, mimeType: docMime }), + }); + + await session.openNativeFile("file-1", docMime, "Google Doc"); + + expect(authorizations).toEqual([expect.objectContaining({ + title: "Open Google Doc from Google Drive", + excludeObservers: ["excluded"], + })]); + expect(events).toEqual(["authorize", "commit"]); + }); + + it("leaves a denied file observation pending rather than observed", async () => { + let state = "unknown"; + let { session } = core({ + getFile: async id => file({ id, mimeType: docMime }), + prepareObservation: async ids => { + state = "pending"; + return { pendingSets: ids, commit: () => { state = "observed"; } }; + }, + authorize: async () => { throw new Error("denied"); }, + }); + + await expect(session.openNativeFile("file-1", docMime, "Google Doc")) + .rejects.toThrow("denied"); + expect(state).toBe("pending"); + }); +}); + describe("Drive search validation", () => { it("requires at least one populated search filter", async () => { let { session } = core(); diff --git a/packages/gatekeeper-google/__tests__/native-api.test.ts b/packages/gatekeeper-google/__tests__/native-api.test.ts new file mode 100644 index 000000000..a0c2e6aca --- /dev/null +++ b/packages/gatekeeper-google/__tests__/native-api.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GoogleDocsApi } from "../src/docs-api"; +import { GoogleSheetsApi } from "../src/sheets-api"; + +const token = async () => "access-token"; + +function docBody() { + return { + documentId: "doc-1", + title: "Quarterly plan", + revisionId: "revision-1", + body: { content: [] }, + lists: {}, + }; +} + +function sheetBody() { + return { + spreadsheetId: "sheet-1", + properties: { title: "Forecast" }, + sheets: [], + }; +} + +function oversizedResponse(cancel: () => void): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")); + }, + cancel, + }); + return new Response(body, { + headers: { "Content-Length": String(100 * 1024 * 1024) }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("native Google content API safety", () => { + it.each([ + ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1"), docBody()], + ["Sheets", () => new GoogleSheetsApi(token).getSpreadsheet("sheet-1"), sheetBody()], + ] as const)("wires a finite timeout for %s reads", async (_provider, read, body) => { + const timeout = vi.spyOn(AbortSignal, "timeout"); + vi.stubGlobal("fetch", vi.fn(async () => Response.json(body))); + + await read(); + + expect(timeout).toHaveBeenCalledWith(30_000); + }); + + it.each([ + ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1")], + ["Sheets", () => new GoogleSheetsApi(token).getSpreadsheet("sheet-1")], + ] as const)("cancels an oversized successful %s response", async (_provider, read) => { + const cancel = vi.fn(); + vi.stubGlobal("fetch", vi.fn(async () => oversizedResponse(cancel))); + + await expect(read()).rejects.toThrow(/response exceeded/); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it.each([ + ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1"), + "Google Docs get document failed [http=403]"], + ["Sheets", () => new GoogleSheetsApi(token).getSpreadsheet("sheet-1"), + "Google Sheets get spreadsheet failed [http=403]"], + ] as const)("redacts %s provider response prose", async (_provider, read, expected) => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ + error: { message: "secret provider response prose" }, + }, { status: 403 }))); + + const error = await read().catch(value => value as Error); + + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) throw new Error("Expected provider read to fail"); + expect(error.message).toBe(expected); + expect(error.message).not.toContain("secret provider"); + }); +}); diff --git a/packages/gatekeeper-google/__tests__/resources.test.ts b/packages/gatekeeper-google/__tests__/resources.test.ts index a8c430de0..05fb06a98 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -76,6 +76,18 @@ describe("resource declarations", () => { } expect(new Set(Object.values(RESOURCE_BY_KIND)).size).toBe(SUPPORTED_RESOURCES.length); }); + + it("advertises native Docs and Sheets only on Drive resources", () => { + expect([ + GOOGLE_DRIVE_RESOURCE.description, + GOOGLE_SHARED_DRIVE_RESOURCE.description, + GOOGLE_DRIVE_FILE_RESOURCE.description, + ]).toEqual([ + "Find files and folders and read native Google Docs and Sheets anywhere this Google account can read in Drive, including shared drives it belongs to.", + "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", + "Read metadata and, for a native Google Doc or Sheet, content from one Drive file.", + ]); + }); }); describe("resourceUrlPatternsToOAuthScopes", () => { @@ -96,18 +108,41 @@ describe("resourceUrlPatternsToOAuthScopes", () => { ]); }); - // Pins the permanent scope each Drive resource is keyed to. Only the first two are - // least-privilege: the shared drive needs `drive.readonly` because `drives.list`/`drives.get` - // accept nothing narrower, which is why it is the one resource consenting wider than it reads. + // Pins every permanent scope each Drive resource needs. Account and exact-file bindings require + // the metadata scope plus the native Docs and Sheets read scopes. The shared drive needs the wider + // `drive.readonly` scope because `drives.list`/`drives.get` accept nothing narrower. it.each([ - [GOOGLE_DRIVE_RESOURCE, "https://www.googleapis.com/auth/drive.metadata.readonly"], - [GOOGLE_SHARED_DRIVE_RESOURCE, "https://www.googleapis.com/auth/drive.readonly"], - [GOOGLE_DRIVE_FILE_RESOURCE, "https://www.googleapis.com/auth/drive.metadata.readonly"], - ] as const)("pins the permanent scope for $urlPattern", (resource, scope) => { + [GOOGLE_DRIVE_RESOURCE, [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ]], + [GOOGLE_SHARED_DRIVE_RESOURCE, ["https://www.googleapis.com/auth/drive.readonly"]], + [GOOGLE_DRIVE_FILE_RESOURCE, [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ]], + ] as const)("pins the permanent scopes for $urlPattern", (resource, scopes) => { expect(resourceUrlPatternsToOAuthScopes([resource.urlPattern])).toEqual([ - ...IDENTITY_SCOPES, scope, + ...IDENTITY_SCOPES, ...scopes, ]); }); + + it("requires account and file grants to expand beyond metadata-only consent", () => { + const oldMetadataGrant = [ + ...IDENTITY_SCOPES, + "https://www.googleapis.com/auth/drive.metadata.readonly", + ]; + const granted = grantedResourcesFromScopes(oldMetadataGrant); + + expect(granted).not.toContain(GOOGLE_DRIVE_RESOURCE.urlPattern); + expect(granted).not.toContain(GOOGLE_DRIVE_FILE_RESOURCE.urlPattern); + expect(grantedResourcesFromScopes([ + ...IDENTITY_SCOPES, + "https://www.googleapis.com/auth/drive.readonly", + ])).toContain(GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern); + }); it("deduplicates scopes shared between resources", () => { let scopes = resourceUrlPatternsToOAuthScopes( [GOOGLE_DOC_RESOURCE.urlPattern, GOOGLE_SHEETS_RESOURCE.urlPattern]); diff --git a/packages/gatekeeper-google/__tests__/types-parity.test.ts b/packages/gatekeeper-google/__tests__/types-parity.test.ts index 392c71f2b..5757c8456 100644 --- a/packages/gatekeeper-google/__tests__/types-parity.test.ts +++ b/packages/gatekeeper-google/__tests__/types-parity.test.ts @@ -7,6 +7,8 @@ import bigqueryDeclared from "../src/bigquery-types.d.ts?raw"; import bigqueryShipped from "../src/bigquery-types.txt?raw"; import calendarDeclared from "../src/calendar-types.d.ts?raw"; import calendarShipped from "../src/calendar-types.txt?raw"; +import docsReadDeclared from "../src/docs-read-types.d.ts?raw"; +import docsReadShipped from "../src/docs-read-types.txt?raw"; import docsDeclared from "../src/docs-types.d.ts?raw"; import docsShipped from "../src/docs-types.txt?raw"; import driveDeclared from "../src/drive-types.d.ts?raw"; @@ -23,6 +25,7 @@ import gmailShipped from "../src/types.txt?raw"; describe("agent-facing TypeScript type modules", () => { it.each([ ["types", gmailShipped, gmailDeclared], + ["docs-read-types", docsReadShipped, docsReadDeclared], ["docs-types", docsShipped, docsDeclared], ["sheets-types", sheetsShipped, sheetsDeclared], ["calendar-types", calendarShipped, calendarDeclared], @@ -34,8 +37,8 @@ describe("agent-facing TypeScript type modules", () => { }); it.each([ - "types", "docs-types", "sheets-types", "calendar-types", "bigquery-types", - "drive-types", + "types", "docs-read-types", "docs-types", "sheets-types", "calendar-types", + "bigquery-types", "drive-types", ])("ships %s.txt as a symlink to its authoritative declaration", name => { expect(readlinkSync(new URL(`../src/${name}.txt`, import.meta.url))).toBe(`${name}.d.ts`); }); diff --git a/packages/gatekeeper-google/__tests__/types.test.ts b/packages/gatekeeper-google/__tests__/types.test.ts new file mode 100644 index 000000000..f87d4e4fe --- /dev/null +++ b/packages/gatekeeper-google/__tests__/types.test.ts @@ -0,0 +1,40 @@ +/// + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const SOURCE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../src"); + +function sourcePath(name: string): string { + return join(SOURCE_DIR, name); +} + +function source(name: string): string { + return readFileSync(sourcePath(name), "utf8"); +} + +describe("embedded agent declarations", () => { + + it("keeps Drive Docs authority read-only", () => { + const readTypes = source("docs-read-types.d.ts"); + expect(readTypes).toContain("export interface GoogleDocReadSession"); + expect(readTypes).not.toContain("replaceText"); + expect(readTypes).not.toContain("appendText"); + expect(source("docs-types.d.ts")).toContain( + "export interface GoogleDocSession extends GoogleDocReadSession", + ); + }); + + it("exposes only typed native content sessions from Drive", () => { + const driveTypes = source("drive-types.d.ts"); + expect(driveTypes).toContain( + "openGoogleDoc(fileId: string): Promise", + ); + expect(driveTypes).toContain( + "openGoogleSheet(fileId: string): Promise", + ); + expect(driveTypes).not.toContain("GoogleDocSession>"); + }); +}); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts new file mode 100644 index 000000000..2b46ffe2f --- /dev/null +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -0,0 +1,110 @@ +import { RpcStub, RpcTarget } from "cloudflare:workers"; +import type { ApprovalQueue, ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GoogleDocsApi } from "../../src/docs-api"; +import { DriveApi } from "../../src/drive-api"; +import { GoogleDriveSessionImpl } from "../../src/google"; +import { GoogleSheetsApi } from "../../src/sheets-api"; + +const DOC_MIME = "application/vnd.google-apps.document"; +const SHEET_MIME = "application/vnd.google-apps.spreadsheet"; + +async function getAccessToken(): Promise { + return "access-token"; +} + +class TestApprovalQueue extends RpcTarget { + readonly observations: ObservationDescription[] = []; + + async authorizeObservation(description: ObservationDescription): Promise { + this.observations.push(description); + } +} + +function providerFile(id: string, mimeType: string) { + return { + id, + name: id === "doc-1" ? "Quarterly plan" : "Forecast", + mimeType, + modifiedTime: "2026-08-20T12:00:00Z", + }; +} + +function installProvider() { + const urls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + urls.push(url.toString()); + if (url.hostname === "www.googleapis.com" && url.pathname.includes("/drive/v3/files/")) { + const id = decodeURIComponent(url.pathname.split("/").at(-1)!); + const mimeType = id === "doc-1" ? DOC_MIME : SHEET_MIME; + return Response.json(providerFile(id, mimeType)); + } + if (url.hostname === "docs.googleapis.com") { + return Response.json({ + documentId: "doc-1", + title: "Quarterly plan", + revisionId: "revision-1", + body: { content: [] }, + lists: {}, + }); + } + throw new Error(`Unexpected provider request: ${url.origin}${url.pathname}`); + })); + return urls; +} + +function newSession() { + const queue = new TestApprovalQueue(); + const session = new GoogleDriveSessionImpl( + new DriveApi(getAccessToken), + new GoogleDocsApi(getAccessToken), + new GoogleSheetsApi(getAccessToken), + { kind: "account" }, + new RpcStub(queue) as unknown as RpcStub, + ); + return { queue, session }; +} + +beforeEach(() => installProvider()); +afterEach(() => vi.unstubAllGlobals()); + +describe("Drive nested native sessions", () => { + it("returns a Doc target with only the read surface", async () => { + const { session } = newSession(); + + const doc = await session.openGoogleDoc("doc-1"); + + expect(await doc.getMetadata()).toEqual({ + title: "Quarterly plan", + lastModified: new Date("2026-08-20T12:00:00Z"), + }); + expect(await doc.getContent()).toBe(""); + expect("replaceText" in doc).toBe(false); + expect("appendText" in doc).toBe(false); + }); + + it("returns the existing Sheet target with bounded range validation", async () => { + const urls = installProvider(); + const { session } = newSession(); + + const sheet = await session.openGoogleSheet("sheet-1"); + + await expect(sheet.readRange("A:A")).rejects.toThrow(/Invalid or unbounded A1 range/); + expect(urls.some(url => url.includes("sheets.googleapis.com"))).toBe(false); + }); + + it("gives each child an independently disposable approval-queue stub", async () => { + const { queue, session } = newSession(); + const doc = await session.openGoogleDoc("doc-1"); + + session[Symbol.dispose](); + await expect(doc.getMetadata()).resolves.toEqual(expect.objectContaining({ + title: "Quarterly plan", + })); + expect(queue.observations).toHaveLength(2); + + (doc as typeof doc & Disposable)[Symbol.dispose](); + await expect(doc.getContent()).rejects.toThrow(); + }); +}); diff --git a/packages/gatekeeper-google/package.json b/packages/gatekeeper-google/package.json index cda87071e..b6a3d44ef 100644 --- a/packages/gatekeeper-google/package.json +++ b/packages/gatekeeper-google/package.json @@ -7,7 +7,7 @@ "dev": "echo \"run 'pnpm dev-server' in the root directory instead\" >&2 && exit 1", "deploy": "vp run --no-cache build:configurator && wrangler deploy", "clean": "rm -rf dist src/generated", - "test:run": "vitest run" + "test:run": "vitest run && vitest run -c vitest.worker.config.ts" }, "dependencies": { "@gadgets/backend-utils": "workspace:*", @@ -20,6 +20,8 @@ "postal-mime": "^2.7.6" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "catalog:", + "miniflare": "5.20260801.0-alpha", "typescript": "catalog:", "vitest": "catalog:", "wrangler": "catalog:" diff --git a/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx index cab9251f8..8c11c3797 100644 --- a/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx +++ b/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx @@ -12,13 +12,13 @@ export default { return
setValues({ scope: "account" })} /> diff --git a/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx index 32b270b5b..96ee1fe56 100644 --- a/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx +++ b/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx @@ -11,7 +11,7 @@ export default { `https://drive.google.com/file/d/${encodeURIComponent(values.fileId ?? "")}/view`, render({ values, setValues, ui }) { return
- + - + ( + url: string, + init: RequestInit, + operation: string, + ): Promise { + let response = await fetchWithAuthRetry( + url, init, this.getAccessToken, { timeoutMs: REQUEST_TIMEOUT_MS }, + ); + return readGoogleJson(response, { + provider: "Google Docs", operation, maxBytes: MAX_RESPONSE_BYTES, + }); + } + /** Fetch the full document. */ async getDocument(documentId: string): Promise { - let response = await fetchWithAuthRetry( + return this.#request( `${DOCS_API_BASE}/${encodeURIComponent(documentId)}`, {}, - this.getAccessToken, + "get document", ); - - if (!response.ok) { - let errorText = await response.text(); - throw new Error(`Failed to get document: ${response.status} ${errorText}`); - } - - return await response.json() as GoogleDocsDocument; } /** @@ -115,18 +125,11 @@ export class GoogleDocsApi { * that's fine — we just parse revisionId from whatever comes back. */ async getRevisionId(documentId: string): Promise { - let response = await fetchWithAuthRetry( + let data = await this.#request<{ revisionId: string }>( `${DOCS_API_BASE}/${encodeURIComponent(documentId)}?fields=revisionId`, {}, - this.getAccessToken, + "get revision ID", ); - - if (!response.ok) { - let errorText = await response.text(); - throw new Error(`Failed to get revision ID: ${response.status} ${errorText}`); - } - - let data = await response.json() as { revisionId: string }; return data.revisionId; } @@ -146,31 +149,19 @@ export class GoogleDocsApi { targetRevisionId?: string, ): Promise { let body: any = { requests }; - if (targetRevisionId) { - body.writeControl = { targetRevisionId }; - } + if (targetRevisionId) body.writeControl = { targetRevisionId }; - let response = await fetchWithAuthRetry( + let result = await this.#request<{ + writeControl?: { requiredRevisionId?: string }; + }>( `${DOCS_API_BASE}/${encodeURIComponent(documentId)}:batchUpdate`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, - this.getAccessToken, + "batch update document", ); - - if (!response.ok) { - let errorText = await response.text(); - throw new Error(`Failed to batch update document: ${response.status} ${errorText}`); - } - - let result = await response.json() as { - writeControl?: { requiredRevisionId?: string }; - }; - return result.writeControl?.requiredRevisionId ?? ""; } } diff --git a/packages/gatekeeper-google/src/docs-read-types.d.ts b/packages/gatekeeper-google/src/docs-read-types.d.ts new file mode 100644 index 000000000..1caf8d6f0 --- /dev/null +++ b/packages/gatekeeper-google/src/docs-read-types.d.ts @@ -0,0 +1,17 @@ +/** Metadata for one native Google Doc. */ +export type DocMetadata = { + /** Document title. */ + title: string; + + /** When the document was last modified. */ + lastModified: Date; +} + +/** Read-only access to one native Google Doc. */ +export interface GoogleDocReadSession { + /** Return current document metadata. */ + getMetadata(): Promise; + + /** Return the document body converted to Markdown. */ + getContent(): Promise; +} diff --git a/packages/gatekeeper-google/src/docs-read-types.txt b/packages/gatekeeper-google/src/docs-read-types.txt new file mode 120000 index 000000000..72de7fd4f --- /dev/null +++ b/packages/gatekeeper-google/src/docs-read-types.txt @@ -0,0 +1 @@ +docs-read-types.d.ts \ No newline at end of file diff --git a/packages/gatekeeper-google/src/docs-types.d.ts b/packages/gatekeeper-google/src/docs-types.d.ts index 4f4fad06a..9bc209a04 100644 --- a/packages/gatekeeper-google/src/docs-types.d.ts +++ b/packages/gatekeeper-google/src/docs-types.d.ts @@ -1,17 +1,8 @@ -export type DocMetadata = { - /** Document title. */ - title: string; +import type { GoogleDocReadSession } from "./docs-read-types"; +export type { DocMetadata, GoogleDocReadSession } from "./docs-read-types"; - /** When the document was last modified. */ - lastModified: Date; -} - -export interface GoogleDocSession { - /** Get basic metadata about the document (title, last modified time). */ - getMetadata(): Promise; - - /** Get the full document content, converted to Markdown. */ - getContent(): Promise; +/** Read/write access to one directly bound native Google Doc. */ +export interface GoogleDocSession extends GoogleDocReadSession { /** * Find `oldMarkdown` in the current document content and replace it with `newMarkdown`. diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index b90e2f69b..e5f66e59f 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -8,6 +8,10 @@ import type { const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; const SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"; +/** Exact MIME type for native Google Docs files. */ +export const GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; +/** Exact MIME type for native Google Sheets files. */ +export const GOOGLE_SHEET_MIME_TYPE = "application/vnd.google-apps.spreadsheet"; // Agent-supplied query values go in the approval description, so each value and the whole string // are capped. They are not logged and they stay out of the title. @@ -257,6 +261,26 @@ export class DriveSessionCore { return entry; } + /** Validate and authorize one native file before a nested content session is created. */ + async openNativeFile( + fileId: string, + expectedMimeType: string, + description: string, + ): Promise { + if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#outsideScope(); + let file = await this.#api.getFile(fileId); + if (!this.#inScope(file)) this.#outsideScope(); + await this.#authorizeIds( + [file.id], + `Open ${description} from Google Drive`, + `Check current metadata for Drive file ${file.id} and open it as a ${description}.`, + ); + if (file.mimeType !== expectedMimeType) { + throw new Error(`The requested Drive file is not a ${description}.`); + } + return file.id; + } + #cursor(query: DriveListFilesOptions, denyEmptySearch = false): Pager { let hasDisclosedEntries = false; return new CursorPager({ diff --git a/packages/gatekeeper-google/src/drive-types.d.ts b/packages/gatekeeper-google/src/drive-types.d.ts index d72f52270..7636c064f 100644 --- a/packages/gatekeeper-google/src/drive-types.d.ts +++ b/packages/gatekeeper-google/src/drive-types.d.ts @@ -1,3 +1,6 @@ +import type { GoogleDocReadSession } from "./docs-read-types"; +import type { GoogleSpreadsheetSession } from "./sheets-types"; + /** * A pagination cursor. * @@ -119,10 +122,9 @@ export type DriveSearchQuery = { }; /** - * Read-only metadata access to the selected Drive scope. + * Read-only metadata discovery and native Google Docs/Sheets access within the selected Drive scope. * - * Methods do not return file contents, follow shortcut targets, edit Drive, or open native Google - * Docs or Sheets sessions. + * Methods do not follow shortcut targets, edit Drive, or read non-native file contents. */ export interface GoogleDriveSession { /** Return the immutable binding scope with current display metadata. */ @@ -156,4 +158,20 @@ export interface GoogleDriveSession { * trash, while a direct get does not, and {@link DriveEntry} has no `trashed` field. */ getEntry(fileId: string): Promise; + + /** + * Open an in-scope native Google Doc with MIME type + * `application/vnd.google-apps.document`. Other MIME types, including folders and shortcuts, are + * rejected. The returned RPC capability supports promise pipelining and must be disposed when + * finished. + */ + openGoogleDoc(fileId: string): Promise; + + /** + * Open an in-scope native Google Sheet with MIME type + * `application/vnd.google-apps.spreadsheet`. Other MIME types, including folders and shortcuts, + * are rejected. The returned RPC capability supports promise pipelining and must be disposed when + * finished. + */ + openGoogleSheet(fileId: string): Promise; } diff --git a/packages/gatekeeper-google/src/google-response.ts b/packages/gatekeeper-google/src/google-response.ts new file mode 100644 index 000000000..a939c4b60 --- /dev/null +++ b/packages/gatekeeper-google/src/google-response.ts @@ -0,0 +1,66 @@ +type GoogleJsonResponseOptions = { + provider: string; + operation: string; + maxBytes: number; +}; + +async function readBoundedText( + response: Response, + maxBytes: number, + provider: string, +): Promise { + let contentLength = response.headers.get("Content-Length"); + if (contentLength !== null) { + let declaredBytes = Number(contentLength); + if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) { + await response.body?.cancel().catch(() => {}); + throw new Error(`${provider} response exceeded the ${maxBytes}-byte limit.`); + } + } + + if (!response.body) return ""; + let reader = response.body.getReader(); + let chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + let { done, value } = await reader.read(); + if (done) break; + if (totalBytes + value.byteLength > maxBytes) { + await reader.cancel().catch(() => {}); + throw new Error(`${provider} response exceeded the ${maxBytes}-byte limit.`); + } + chunks.push(value); + totalBytes += value.byteLength; + } + } finally { + reader.releaseLock(); + } + + let bytes = new Uint8Array(totalBytes); + let offset = 0; + for (let chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + +/** Read a size-bounded Google JSON response without exposing provider response prose in errors. */ +export async function readGoogleJson( + response: Response, + options: GoogleJsonResponseOptions, +): Promise { + let { provider, operation, maxBytes } = options; + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error(`${provider} ${operation} failed [http=${response.status}]`); + } + + let text = await readBoundedText(response, maxBytes, provider); + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`${provider} ${operation} returned invalid JSON.`); + } +} diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 7acef83f6..b414dca4d 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -6,7 +6,7 @@ import { GmailSession, GmailThread, GmailMessage, GmailThreadInfo, GmailThreadEntry, GmailMessageInfo, GmailLabel, GmailSystemLabel, EmailContent } from "./types"; -import { GoogleDocSession, DocMetadata } from "./docs-types"; +import { GoogleDocSession, DocMetadata, type GoogleDocReadSession } from "./docs-types"; import { GoogleDocsApi } from "./docs-api"; import { GoogleSheetsApi } from "./sheets-api"; import type { @@ -15,7 +15,9 @@ import type { import { docToMarkdown, markdownToDocRequests, computeReplaceOperations, DocSnapshot } from "./markdown-converter"; import { DriveApi } from "./drive-api"; import { driveObserverTracker } from "./drive-observers"; -import { DriveSessionCore, type DriveBindingScope } from "./drive-session"; +import { + DriveSessionCore, GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, type DriveBindingScope, +} from "./drive-session"; import type { DriveEntry, DriveListOptions, DriveSearchQuery, GoogleDriveSession } from "./drive-types"; import { BigQueryApi, DEFAULT_MAX_BYTES_BILLED } from "./bigquery-api"; import { @@ -32,6 +34,7 @@ import type { GoogleCalendarInfo, GoogleCalendarSession, PersonAvailability, } from "./calendar-types"; import TYPES_CODE from "./types.txt"; +import DOCS_READ_TYPES_CODE from "./docs-read-types.txt"; import DOCS_TYPES_CODE from "./docs-types.txt"; import BIGQUERY_TYPES_CODE from "./bigquery-types.txt"; import CALENDAR_TYPES_CODE from "./calendar-types.txt"; @@ -93,6 +96,11 @@ import { type GoogleOAuthState, } from "./oauth"; +const GOOGLE_DOC_TYPES_CODE = [DOCS_READ_TYPES_CODE, DOCS_TYPES_CODE].join("\n"); +const GOOGLE_DRIVE_TYPES_CODE = [ + DOCS_READ_TYPES_CODE, SHEETS_TYPES_CODE, DRIVE_TYPES_CODE, +].join("\n"); + // Vendor id = GATEKEEPER_ binding suffix (lowercased). const VENDOR_ID = "google"; const logger = obsContext.createLogger({ @@ -355,12 +363,12 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe url: "https://google.com", logo: { url: GOOGLE_LOGO_URL }, color: "#e8f0fe", - tagline: "Draft replies, edit docs, read sheets, search Drive, manage calendars, analyze data", + tagline: "Draft replies, edit docs, read sheets, search Drive, manage calendars, and analyze data", description: "Connect your Google account to give Cloudflare OS access to Gmail, Google Docs, Google " + "Sheets, Google Drive, Google Calendar, and BigQuery. Build agents that triage email, " + - "draft and edit documents, read spreadsheets, find files by metadata, find focus time, " + - "schedule meetings, or run analytics queries on your data.", + "draft and edit documents, read spreadsheets, search Drive and read native Docs and " + + "Sheets, find focus time, schedule meetings, or run analytics queries on your data.", providesAuth: true, }; } @@ -395,8 +403,8 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe async getTypeScriptTypes(): Promise { return [ - TYPES_CODE, DOCS_TYPES_CODE, SHEETS_TYPES_CODE, CALENDAR_TYPES_CODE, BIGQUERY_TYPES_CODE, - DRIVE_TYPES_CODE, + TYPES_CODE, GOOGLE_DOC_TYPES_CODE, SHEETS_TYPES_CODE, CALENDAR_TYPES_CODE, + BIGQUERY_TYPES_CODE, DRIVE_TYPES_CODE, ].join("\n"); } } @@ -1977,7 +1985,7 @@ export class GoogleDocGatekeeperImpl } async getTypeScriptTypes(): Promise { - return DOCS_TYPES_CODE; + return GOOGLE_DOC_TYPES_CODE; } async getAutoApprovableActions(): Promise { @@ -3003,7 +3011,7 @@ export class GoogleDriveGatekeeperImpl return { url: `https://drive.google.com/drive/folders/${encodeURIComponent(scope.driveId)}`, title: drive.name, - snippet: `Find files and folders in organization-owned shared drive "${drive.name}" (metadata only)`, + snippet: `Find files and folders and read native Google Docs and Sheets in organization-owned shared drive "${drive.name}"`, suggestedBindingName: "GOOGLE_SHARED_DRIVE", tsType: "GoogleDriveSession", }; @@ -3012,14 +3020,14 @@ export class GoogleDriveGatekeeperImpl return { url: `https://drive.google.com/file/d/${encodeURIComponent(scope.fileId)}/view`, title: file.name, - snippet: `Read metadata for Drive file "${file.name}"`, + snippet: `Read metadata and, when native, Google Doc or Sheet content from Drive file "${file.name}"`, suggestedBindingName: "GOOGLE_DRIVE_FILE", tsType: "GoogleDriveSession", }; } async getTypeScriptTypes(): Promise { - return DRIVE_TYPES_CODE; + return GOOGLE_DRIVE_TYPES_CODE; } async getAutoApprovableActions() { @@ -3028,8 +3036,11 @@ export class GoogleDriveGatekeeperImpl async startSession(approvalQueue: RpcStub): Promise { let observerTracker = this.#observerTracker(); + let getDriveAccessToken = (opts?: AccessTokenRequest) => this.#getAccessToken(opts); return new GoogleDriveSessionImpl( - new DriveApi(opts => this.#getAccessToken(opts)), + new DriveApi(getDriveAccessToken), + new GoogleDocsApi(getDriveAccessToken), + new GoogleSheetsApi(getDriveAccessToken), this.ctx.props.scope, approvalQueue.dup(), fileIds => observerTracker.prepareObservation(fileIds), @@ -3060,26 +3071,88 @@ export class GoogleDriveGatekeeperImpl } @validateRpc() -class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSession { +class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession { + #docsApi: GoogleDocsApi; + #driveApi: DriveApi; + #documentId: string; + #approvalQueue: RpcStub; + + constructor( + docsApi: GoogleDocsApi, + driveApi: DriveApi, + documentId: string, + approvalQueue: RpcStub, + ) { + super(); + this.#docsApi = docsApi; + this.#driveApi = driveApi; + this.#documentId = documentId; + this.#approvalQueue = approvalQueue; + } + + [Symbol.dispose](): void { + this.#approvalQueue[Symbol.dispose](); + } + + async getMetadata(): Promise { + let file = await this.#driveApi.getFile(this.#documentId); + let lastModified = new Date(file.modifiedTime ?? ""); + if (Number.isNaN(lastModified.valueOf())) { + throw new Error("Google Drive returned an invalid modifiedTime"); + } + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc metadata", + description: "Read the current title and modification time of the Drive document.", + }); + return { title: file.name, lastModified }; + } + + async getContent(): Promise { + let snapshot = docToMarkdown(await this.#docsApi.getDocument(this.#documentId)); + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc content", + description: "Read the current document body as Markdown.", + }); + return snapshot.markdown; + } +} + +/** Drive RPC session implementation, exported for workerd contract coverage. */ +@validateRpc() +export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSession { #core: DriveSessionCore; + #driveApi: DriveApi; + #docsApi: GoogleDocsApi; + #sheetsApi: GoogleSheetsApi; + #approvalQueue: RpcStub; constructor( - api: DriveApi, + driveApi: DriveApi, + docsApi: GoogleDocsApi, + sheetsApi: GoogleSheetsApi, scope: DriveBindingScope, approvalQueue: RpcStub, prepareObservation: (fileIds: string[]) => Promise>, observerIds: () => string[], ) { super(); + this.#driveApi = driveApi; + this.#docsApi = docsApi; + this.#sheetsApi = sheetsApi; + this.#approvalQueue = approvalQueue; this.#core = new DriveSessionCore({ - api, + api: driveApi, scope, prepareObservation, observerIds, - authorize: description => approvalQueue.authorizeObservation(description), + authorize: description => this.#approvalQueue.authorizeObservation(description), }); } + [Symbol.dispose](): void { + this.#approvalQueue[Symbol.dispose](); + } + getScope() { return this.#core.getScope(); } @@ -3095,6 +3168,24 @@ class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSession { getEntry(fileId: string): Promise { return this.#core.getEntry(fileId); } + + async openGoogleDoc(fileId: string): Promise { + let documentId = await this.#core.openNativeFile( + fileId, GOOGLE_DOC_MIME_TYPE, "Google Doc", + ); + return new GoogleDocReadSessionImpl( + this.#docsApi, this.#driveApi, documentId, this.#approvalQueue.dup(), + ); + } + + async openGoogleSheet(fileId: string): Promise { + let spreadsheetId = await this.#core.openNativeFile( + fileId, GOOGLE_SHEET_MIME_TYPE, "Google Sheet", + ); + return new GoogleSpreadsheetSessionImpl( + this.#sheetsApi, spreadsheetId, this.#approvalQueue.dup(), + ); + } } // ======================================================================================= diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index d58790faa..1d163fbe0 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -66,7 +66,7 @@ export const BIGQUERY_RESOURCE: SupportedResource = { }; /** - * Metadata for every file and folder the connected Google Drive account can read. + * Files, folders, and read-only native Google Docs and Sheets available to the connected account. * * Whole-account, not just My Drive: listings set `includeItemsFromAllDrives`, so a shared drive the * account belongs to is inside this grant. @@ -76,24 +76,24 @@ export const GOOGLE_DRIVE_RESOURCE: SupportedResource = { title: "Google Drive Account", description: "Find files and folders anywhere this Google account can read in Drive, including shared " + - "drives. Full-text search examines indexed file content, descriptions, and OCR text; " + - "results contain metadata only.", + "drives. Full-text search examines indexed file content, descriptions, and OCR text; search " + + "results contain metadata only, while native Google Docs and Sheets can be opened read-only.", grantable: true, }; -/** Metadata across one Google Workspace shared drive, keyed by its immutable drive ID. */ +/** Files, folders, and read-only native content in one Google Workspace shared drive. */ export const GOOGLE_SHARED_DRIVE_RESOURCE: SupportedResource = { urlPattern: "https://drive.google.com/drive/folders/:driveId", title: "Google Workspace Shared Drive", - description: "Find files and folders in one organization-owned shared drive.", + description: "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", grantable: true, }; -/** Metadata for one immutable Drive file ID. */ +/** Metadata and, when native, read-only content for one immutable Drive file ID. */ export const GOOGLE_DRIVE_FILE_RESOURCE: SupportedResource = { urlPattern: "https://drive.google.com/file/d/:fileId/view", title: "Google Drive File", - description: "Read metadata for one Drive file.", + description: "Read metadata and, for a native Google Doc or Sheet, content from one Drive file.", grantable: true, }; @@ -163,7 +163,11 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] }, { resource: GOOGLE_DRIVE_RESOURCE, - scopes: ["https://www.googleapis.com/auth/drive.metadata.readonly"], + scopes: [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ], }, { resource: GOOGLE_SHARED_DRIVE_RESOURCE, @@ -178,7 +182,11 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] }, { resource: GOOGLE_DRIVE_FILE_RESOURCE, - scopes: ["https://www.googleapis.com/auth/drive.metadata.readonly"], + scopes: [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ], }, { resource: BIGQUERY_RESOURCE, diff --git a/packages/gatekeeper-google/src/sheets-api.ts b/packages/gatekeeper-google/src/sheets-api.ts index 403c585aa..d9cca0e62 100644 --- a/packages/gatekeeper-google/src/sheets-api.ts +++ b/packages/gatekeeper-google/src/sheets-api.ts @@ -2,6 +2,7 @@ import type { SpreadsheetCellValue, SpreadsheetInfo, SpreadsheetRange, SpreadsheetValueMode, } from "./sheets-types"; import { AccessTokenProvider, fetchWithAuthRetry } from "./auth-retry"; +import { readGoogleJson } from "./google-response"; const API_BASE = "https://sheets.googleapis.com/v4/spreadsheets"; const MAX_RANGES = 20; @@ -9,13 +10,8 @@ const MAX_TOTAL_CELLS = 50_000; const MAX_RANGE_LENGTH = 500; // Bound the encoded JSON before decoding and parsing. const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; -const MAX_ERROR_RESPONSE_BYTES = 64 * 1024; const REQUEST_TIMEOUT_MS = 30_000; -type GoogleErrorResponse = { - error?: { message?: string }; -}; - type RestSpreadsheet = { spreadsheetId: string; properties?: { title?: string; locale?: string; timeZone?: string }; @@ -122,83 +118,16 @@ function normalizeRange(rest: RestValueRange, requested: ValidatedRange): Spread }); return { range: rest.range ?? requested.range, values }; } - -async function readResponseText(response: Response, maxBytes: number): Promise { - let contentLength = response.headers.get("Content-Length"); - if (contentLength !== null) { - let declaredBytes = Number(contentLength); - if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) { - await response.body?.cancel().catch(() => {}); - throw new Error(`Google Sheets response exceeded the ${maxBytes}-byte limit.`); - } - } - - if (!response.body) return ""; - let reader = response.body.getReader(); - let chunks: Uint8Array[] = []; - let totalBytes = 0; - try { - while (true) { - let { done, value } = await reader.read(); - if (done) break; - if (totalBytes + value.byteLength > maxBytes) { - await reader.cancel().catch(() => {}); - throw new Error(`Google Sheets response exceeded the ${maxBytes}-byte limit.`); - } - chunks.push(value); - totalBytes += value.byteLength; - } - } finally { - reader.releaseLock(); - } - - let bytes = new Uint8Array(totalBytes); - let offset = 0; - for (let chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return new TextDecoder().decode(bytes); -} - export class GoogleSheetsApi { constructor(private getAccessToken: AccessTokenProvider) {} - async #request(url: URL): Promise { + async #request(url: URL, operation: string): Promise { let response = await fetchWithAuthRetry( url.toString(), {}, this.getAccessToken, { timeoutMs: REQUEST_TIMEOUT_MS }, ); - - let text: string; - try { - text = await readResponseText( - response, response.ok ? MAX_RESPONSE_BYTES : MAX_ERROR_RESPONSE_BYTES, - ); - } catch (error) { - if (!response.ok) { - throw new Error( - `Google Sheets request failed [http=${response.status}]`, { cause: error }, - ); - } - throw error; - } - - let body: unknown; - try { - body = JSON.parse(text); - } catch { - if (!response.ok) { - throw new Error(`Google Sheets request failed [http=${response.status}]`); - } - throw new Error("Google Sheets returned an invalid JSON response."); - } - - if (!response.ok) { - let errorBody = body as GoogleErrorResponse; - let detail = errorBody?.error?.message ? `: ${errorBody.error.message}` : ""; - throw new Error(`Google Sheets request failed [http=${response.status}]${detail}`); - } - return body as T; + return readGoogleJson(response, { + provider: "Google Sheets", operation, maxBytes: MAX_RESPONSE_BYTES, + }); } async getSpreadsheet(spreadsheetId: string): Promise { @@ -208,7 +137,7 @@ export class GoogleSheetsApi { "spreadsheetId,properties(title,locale,timeZone)," + "sheets(properties(sheetId,title,index,hidden,gridProperties(rowCount,columnCount)))", ); - let result = await this.#request(url); + let result = await this.#request(url, "get spreadsheet"); return { id: result.spreadsheetId, title: result.properties?.title ?? "Untitled spreadsheet", @@ -241,7 +170,10 @@ export class GoogleSheetsApi { url.searchParams.set("valueRenderOption", valueRenderOption(valueMode)); if (valueMode === "raw") url.searchParams.set("dateTimeRenderOption", "SERIAL_NUMBER"); - let result = await this.#request<{ valueRanges?: RestValueRange[] }>(url); + let result = await this.#request<{ valueRanges?: RestValueRange[] }>( + url, + "read ranges", + ); let returned = result.valueRanges ?? []; return validated.map((range, index) => normalizeRange(returned[index] ?? {}, range)); } diff --git a/packages/gatekeeper-google/vite.config.ts b/packages/gatekeeper-google/vite.config.ts index 32d85c99c..6df0ab3c1 100644 --- a/packages/gatekeeper-google/vite.config.ts +++ b/packages/gatekeeper-google/vite.config.ts @@ -1,3 +1,8 @@ -// Vite+ per-package settings. Shared by all gatekeepers with a configurator UI and living beside the -// builder it runs; `withTests` is that config plus the shared vitest `test` task. -export { withTests as default } from '../../scripts/gatekeeper-configurator-vite-config.js' +import gatekeeperConfiguratorConfig from "../../scripts/gatekeeper-configurator-vite-config.js"; +import { withVitestTask } from "../../scripts/vitest-task-vite-config.js"; + +/** Configurator tasks plus separate Node and workerd test passes. */ +export default withVitestTask(gatekeeperConfiguratorConfig, [ + "vitest run", + "vitest run -c vitest.worker.config.ts", +]); diff --git a/packages/gatekeeper-google/vitest.worker.config.ts b/packages/gatekeeper-google/vitest.worker.config.ts new file mode 100644 index 000000000..7f6e5eeb3 --- /dev/null +++ b/packages/gatekeeper-google/vitest.worker.config.ts @@ -0,0 +1,22 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import capnwebValidate from "capnweb-validate/vite"; +import { defineConfig } from "vitest/config"; + +/** RpcTarget and RpcStub coverage for nested Drive sessions. */ +export default defineConfig({ + plugins: [ + capnwebValidate(), + cloudflareTest({ + main: "./src/google.ts", + miniflare: { + // Kept in step with wrangler.jsonc; drift here tests a runtime we do not deploy. + compatibilityDate: "2026-02-02", + compatibilityFlags: ["allow_irrevocable_stub_storage", "nodejs_als"], + }, + }), + ], + test: { + include: ["__tests__/workerd/*.test.ts"], + setupFiles: ["../../scripts/assert-workerd.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db74cc15a..db62e5539 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,12 @@ importers: specifier: ^2.7.6 version: 2.7.6 devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: 'catalog:' + version: 0.20.3(@cloudflare/workers-types@5.20260808.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + miniflare: + specifier: 5.20260801.0-alpha + version: 5.20260801.0-alpha typescript: specifier: 'catalog:' version: 7.0.2 From 6b750c7ed1771da6ca3b3428b53ec148a8491cbc Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 21 Aug 2026 11:57:12 -0500 Subject: [PATCH 2/2] Harden Google native session types and diagnostics --- .../__tests__/configurator-url.test.ts | 10 +- .../__tests__/doc-fixture.ts | 9 +- .../__tests__/drive-session.test.ts | 14 +++ .../__tests__/native-api.test.ts | 114 +++++++++++++++++- .../__tests__/resources.test.ts | 17 ++- .../gatekeeper-google/__tests__/types.test.ts | 47 ++++++++ .../__tests__/workerd/native-sessions.test.ts | 100 +++++++++------ packages/gatekeeper-google/package.json | 1 + packages/gatekeeper-google/src/docs-api.ts | 82 +++++++++++-- .../src/docs-read-types.d.ts | 2 +- .../gatekeeper-google/src/drive-session.ts | 3 +- .../gatekeeper-google/src/google-response.ts | 74 +++++++++++- packages/gatekeeper-google/src/google.ts | 97 ++++++++++----- .../gatekeeper-google/src/observability.ts | 6 + packages/gatekeeper-google/src/resources.ts | 14 +-- packages/gatekeeper-google/src/type-bundle.ts | 17 +++ pnpm-lock.yaml | 3 + 17 files changed, 505 insertions(+), 105 deletions(-) create mode 100644 packages/gatekeeper-google/src/type-bundle.ts diff --git a/packages/gatekeeper-google/__tests__/configurator-url.test.ts b/packages/gatekeeper-google/__tests__/configurator-url.test.ts index 44b47a6b4..a848d87dd 100644 --- a/packages/gatekeeper-google/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-google/__tests__/configurator-url.test.ts @@ -61,14 +61,6 @@ function valuesFromUrlPattern(resourceUrl: string, resourceUrlPattern: string) { return out; } -const configurableValues = ( - configurator: { initialValuesFromResourceUrl?: (context: { - resourceUrl: string; resourceUrlPattern: string; ui: never; - }) => unknown }, - resourceUrl: string, - resourceUrlPattern: string, -) => configurator.initialValuesFromResourceUrl!({ resourceUrl, resourceUrlPattern, ui: noUi }); - const renderedCopy = (configurator: { render?: (context: never) => unknown }) => JSON.stringify(configurator.render!({ values: {}, setValues() {}, ui: noUi } as never)); describe("Gmail configurator URLs", () => { @@ -136,7 +128,7 @@ describe("Drive configurator URLs", () => { it("explains native Doc and Sheet reads at every Drive scope", () => { expect(renderedCopy(driveAccountConfigurator)).toContain( - "Returns metadata for every item and read-only content sessions for native Docs and Sheets.", + "native Google Docs and Sheets can be opened in read-only content sessions.", ); expect(renderedCopy(sharedDriveConfigurator)).toContain( "Search its files and read native Google Docs and Sheets.", diff --git a/packages/gatekeeper-google/__tests__/doc-fixture.ts b/packages/gatekeeper-google/__tests__/doc-fixture.ts index 597dee13e..60d4e49bd 100644 --- a/packages/gatekeeper-google/__tests__/doc-fixture.ts +++ b/packages/gatekeeper-google/__tests__/doc-fixture.ts @@ -47,7 +47,14 @@ export function buildDoc( }); } - return { documentId: "doc-1", title: "Fixture", revisionId: "rev-1", body: { content }, lists }; + return { + documentId: "doc-1", + title: "Fixture", + revisionId: "rev-1", + body: { content }, + lists, + namedRanges: {}, + }; } /** A single-level bullet list definition, for paragraphs carrying a matching `bullet`. */ diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index ddbccf7a7..568a5730d 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -475,6 +475,20 @@ describe("Drive native sessions", () => { expect(authorizations).toEqual([]); }); + it.each([403, 404])( + "normalizes a %s shared-drive probe failure without authorizing or tracking it", + async status => { + let { session, prepared, authorizations } = core({ + scope: { kind: "sharedDrive", driveId: "drive-1" }, + getFile: async () => { throw new DriveApiRequestError(status); }, + }); + + await expect(session.openNativeFile("foreign", docMime, "Google Doc")) + .rejects.toThrow(new Error("The requested file is outside this Drive binding.")); + expect(prepared).toEqual([]); + expect(authorizations).toEqual([]); + }, + ); it.each([ ["wrong native type", sheetMime, undefined], ["folder", "application/vnd.google-apps.folder", undefined], diff --git a/packages/gatekeeper-google/__tests__/native-api.test.ts b/packages/gatekeeper-google/__tests__/native-api.test.ts index a0c2e6aca..f5aa8c19e 100644 --- a/packages/gatekeeper-google/__tests__/native-api.test.ts +++ b/packages/gatekeeper-google/__tests__/native-api.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { GoogleDocsApi } from "../src/docs-api"; import { GoogleSheetsApi } from "../src/sheets-api"; +import { readGoogleJson } from "../src/google-response"; const token = async () => "access-token"; @@ -11,6 +12,16 @@ function docBody() { revisionId: "revision-1", body: { content: [] }, lists: {}, + namedRanges: {}, + }; +} + +function docResponse(tabCount = 1) { + const { body, lists, namedRanges, ...document } = docBody(); + const documentTab = { body, lists, namedRanges }; + return { + ...document, + tabs: Array.from({ length: tabCount }, () => ({ documentTab, childTabs: [] })), }; } @@ -34,14 +45,115 @@ function oversizedResponse(cancel: () => void): Response { }); } +function chunkedResponse(cancel: () => void): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])); + controller.enqueue(new Uint8Array([3, 4])); + }, + cancel, + }); + return new Response(body); +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("native Google content API safety", () => { + it("requests tabs and normalizes a single-tab document", async () => { + let requestedUrl: string | undefined; + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { + requestedUrl = input instanceof Request ? input.url : input.toString(); + return Response.json(docResponse()); + })); + + await expect(new GoogleDocsApi(token).getDocument("doc-1")).resolves.toEqual(docBody()); + expect(requestedUrl).toBe( + "https://docs.googleapis.com/v1/documents/doc-1?includeTabsContent=true", + ); + }); + + it("rejects multi-tab documents instead of silently reading the first tab", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json(docResponse(2)))); + + await expect(new GoogleDocsApi(token).getDocument("doc-1")) + .rejects.toThrow("Multi-tab Google Docs are not supported"); + }); + + it("revision-locks and marks Docs writes for retry reconciliation", async () => { + let requestInit: RequestInit | undefined; + vi.stubGlobal("fetch", vi.fn(async ( + _input: string | URL | Request, init?: RequestInit, + ) => { + requestInit = init; + return Response.json({ writeControl: { requiredRevisionId: "revision-2" } }); + })); + const request = { insertText: { text: "hello", location: { index: 1 } } }; + + await new GoogleDocsApi(token).batchUpdate( + "doc-1", [request], "revision-1", { name: "gadgets-write-1", rangeStart: 1 }, + ); + + expect(JSON.parse(String(requestInit?.body))).toEqual({ + requests: [ + { + createNamedRange: { + name: "gadgets-write-1", + range: { startIndex: 1, endIndex: 2 }, + }, + }, + request, + ], + writeControl: { requiredRevisionId: "revision-1" }, + }); + }); + it("cancels an unknown-length response once streamed bytes exceed the limit", async () => { + const cancel = vi.fn(); + + await expect(readGoogleJson(chunkedResponse(cancel), { + provider: "Google Test", operation: "read", maxBytes: 3, + })).rejects.toThrow(/response exceeded/); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("logs bounded provider diagnostics without provider prose", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const response = Response.json({ + error: { + code: 403, + status: "PERMISSION_DENIED", + message: "secret provider response prose", + errors: [{ reason: "accessNotConfigured", message: "secret nested prose" }], + details: [ + { reason: "SERVICE_DISABLED" }, + { reason: "unsafe provider reason prose" }, + ], + }, + }, { status: 403 }); + + await expect(readGoogleJson(response, { + provider: "Google Sheets", operation: "get spreadsheet", maxBytes: 1024, + })).rejects.toThrow("Google Sheets get spreadsheet failed [http=403]"); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]?.[0]).toMatchObject({ + component: "gatekeeper.google.api", + event: "google.api.request.failed", + httpStatus: 403, + operation: "get spreadsheet", + provider: "Google Sheets", + providerCode: 403, + providerReasons: ["accessNotConfigured", "SERVICE_DISABLED"], + providerStatus: "PERMISSION_DENIED", + vendorId: "google", + }); + expect(JSON.stringify(warn.mock.calls)).not.toContain("secret provider"); + expect(JSON.stringify(warn.mock.calls)).not.toContain("unsafe provider reason prose"); + }); it.each([ - ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1"), docBody()], + ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1"), docResponse()], ["Sheets", () => new GoogleSheetsApi(token).getSpreadsheet("sheet-1"), sheetBody()], ] as const)("wires a finite timeout for %s reads", async (_provider, read, body) => { const timeout = vi.spyOn(AbortSignal, "timeout"); diff --git a/packages/gatekeeper-google/__tests__/resources.test.ts b/packages/gatekeeper-google/__tests__/resources.test.ts index 05fb06a98..b4fd64e80 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -37,8 +37,8 @@ describe("resource declarations", () => { it("describes the whole-account Drive authority exactly", () => { expect(GOOGLE_DRIVE_RESOURCE.description).toBe( "Find files and folders anywhere this Google account can read in Drive, including shared " + - "drives. Full-text search examines indexed file content, descriptions, and OCR text; " + - "results contain metadata only.", + "drives. Full-text search examines indexed file content, descriptions, and OCR text; search " + + "results contain metadata only, while native Google Docs and Sheets can be opened read-only.", ); }); @@ -83,7 +83,9 @@ describe("resource declarations", () => { GOOGLE_SHARED_DRIVE_RESOURCE.description, GOOGLE_DRIVE_FILE_RESOURCE.description, ]).toEqual([ - "Find files and folders and read native Google Docs and Sheets anywhere this Google account can read in Drive, including shared drives it belongs to.", + "Find files and folders anywhere this Google account can read in Drive, including shared " + + "drives. Full-text search examines indexed file content, descriptions, and OCR text; search " + + "results contain metadata only, while native Google Docs and Sheets can be opened read-only.", "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", "Read metadata and, for a native Google Doc or Sheet, content from one Drive file.", ]); @@ -130,15 +132,20 @@ describe("resourceUrlPatternsToOAuthScopes", () => { }); it("requires account and file grants to expand beyond metadata-only consent", () => { + const drivePatterns = [ + GOOGLE_DRIVE_RESOURCE.urlPattern, + GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern, + GOOGLE_DRIVE_FILE_RESOURCE.urlPattern, + ]; const oldMetadataGrant = [ ...IDENTITY_SCOPES, "https://www.googleapis.com/auth/drive.metadata.readonly", ]; - const granted = grantedResourcesFromScopes(oldMetadataGrant); + const granted = resourcesCoveredByScopes(drivePatterns, oldMetadataGrant); expect(granted).not.toContain(GOOGLE_DRIVE_RESOURCE.urlPattern); expect(granted).not.toContain(GOOGLE_DRIVE_FILE_RESOURCE.urlPattern); - expect(grantedResourcesFromScopes([ + expect(resourcesCoveredByScopes(drivePatterns, [ ...IDENTITY_SCOPES, "https://www.googleapis.com/auth/drive.readonly", ])).toContain(GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern); diff --git a/packages/gatekeeper-google/__tests__/types.test.ts b/packages/gatekeeper-google/__tests__/types.test.ts index f87d4e4fe..d2df2b447 100644 --- a/packages/gatekeeper-google/__tests__/types.test.ts +++ b/packages/gatekeeper-google/__tests__/types.test.ts @@ -4,6 +4,10 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import ts from "typescript6"; +import { + DOCS_TYPES_MODULE_PREFIX, DRIVE_TYPES_MODULE_PREFIX, stripTypeModulePrefix, +} from "../src/type-bundle"; const SOURCE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../src"); @@ -15,7 +19,50 @@ function source(name: string): string { return readFileSync(sourcePath(name), "utf8"); } +function compileAgentTypes(sourceText: string): string[] { + const fileName = "/agent-types.ts"; + const options: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ESNext, + }; + const baseHost = ts.createCompilerHost(options); + const host: ts.CompilerHost = { + ...baseHost, + fileExists: name => name === fileName || baseHost.fileExists(name), + getSourceFile: (name, languageVersion, onError, shouldCreateNewSourceFile) => + name === fileName + ? ts.createSourceFile(name, sourceText, languageVersion, true) + : baseHost.getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile), + readFile: name => name === fileName ? sourceText : baseHost.readFile(name), + }; + const program = ts.createProgram([fileName], options, host); + return ts.getPreEmitDiagnostics(program).map(diagnostic => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +} + describe("embedded agent declarations", () => { + it("compiles the exact Google Doc agent declaration bundle without module dependencies", () => { + const types = [ + source("docs-read-types.txt"), + stripTypeModulePrefix(source("docs-types.txt"), DOCS_TYPES_MODULE_PREFIX), + ].join("\n"); + + expect(compileAgentTypes(types)).toEqual([]); + }); + + it("compiles the exact Google Drive agent declaration bundle without module dependencies", () => { + const types = [ + source("docs-read-types.txt"), + source("sheets-types.txt"), + stripTypeModulePrefix(source("drive-types.txt"), DRIVE_TYPES_MODULE_PREFIX), + ].join("\n"); + + expect(compileAgentTypes(types)).toEqual([]); + }); it("keeps Drive Docs authority read-only", () => { const readTypes = source("docs-read-types.d.ts"); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index 2b46ffe2f..19b6ff753 100644 --- a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -1,5 +1,7 @@ import { RpcStub, RpcTarget } from "cloudflare:workers"; -import type { ApprovalQueue, ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; +import type { + ActionDescription, ApprovalQueue, HookController, HookDescription, ObservationDescription, +} from "@gadgets/workshop-shared/gatekeeper"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GoogleDocsApi } from "../../src/docs-api"; import { DriveApi } from "../../src/drive-api"; @@ -8,17 +10,29 @@ import { GoogleSheetsApi } from "../../src/sheets-api"; const DOC_MIME = "application/vnd.google-apps.document"; const SHEET_MIME = "application/vnd.google-apps.spreadsheet"; +let providerUrls: string[]; async function getAccessToken(): Promise { return "access-token"; } -class TestApprovalQueue extends RpcTarget { +class TestApprovalQueue extends RpcTarget implements ApprovalQueue { readonly observations: ObservationDescription[] = []; async authorizeObservation(description: ObservationDescription): Promise { this.observations.push(description); } + + async submitAction(_action: number, _description: ActionDescription): Promise { + throw new Error("Unexpected action submission"); + } + + async bindHook( + _controller: Fetcher>, _callback: RpcStub, + _description: HookDescription, + ): Promise { + throw new Error("Unexpected hook binding"); + } } function providerFile(id: string, mimeType: string) { @@ -45,8 +59,10 @@ function installProvider() { documentId: "doc-1", title: "Quarterly plan", revisionId: "revision-1", - body: { content: [] }, - lists: {}, + tabs: [{ + documentTab: { body: { content: [] }, lists: {}, namedRanges: {} }, + childTabs: [], + }], }); } throw new Error(`Unexpected provider request: ${url.origin}${url.pathname}`); @@ -56,55 +72,69 @@ function installProvider() { function newSession() { const queue = new TestApprovalQueue(); - const session = new GoogleDriveSessionImpl( - new DriveApi(getAccessToken), - new GoogleDocsApi(getAccessToken), - new GoogleSheetsApi(getAccessToken), - { kind: "account" }, - new RpcStub(queue) as unknown as RpcStub, - ); - return { queue, session }; + const queueStub: RpcStub = new RpcStub(queue); + return { + queue, + session: new RpcStub(new GoogleDriveSessionImpl( + new DriveApi(getAccessToken), + new GoogleDocsApi(getAccessToken), + new GoogleSheetsApi(getAccessToken), + { kind: "account" }, + queueStub, + async fileIds => ({ pendingSets: fileIds, commit() {} }), + () => [], + )), + }; } -beforeEach(() => installProvider()); +beforeEach(() => { + providerUrls = installProvider(); +}); afterEach(() => vi.unstubAllGlobals()); describe("Drive nested native sessions", () => { - it("returns a Doc target with only the read surface", async () => { - const { session } = newSession(); + it("pipelines a Doc call before resolving its disposable child stub", async () => { + using session = newSession().session; - const doc = await session.openGoogleDoc("doc-1"); + const docPromise = session.openGoogleDoc("doc-1"); + const metadataPromise = docPromise.getMetadata(); + using doc = await docPromise; - expect(await doc.getMetadata()).toEqual({ + expect(await metadataPromise).toEqual({ title: "Quarterly plan", lastModified: new Date("2026-08-20T12:00:00Z"), }); expect(await doc.getContent()).toBe(""); - expect("replaceText" in doc).toBe(false); - expect("appendText" in doc).toBe(false); }); it("returns the existing Sheet target with bounded range validation", async () => { - const urls = installProvider(); - const { session } = newSession(); - - const sheet = await session.openGoogleSheet("sheet-1"); + using session = newSession().session; + using sheet = await session.openGoogleSheet("sheet-1"); - await expect(sheet.readRange("A:A")).rejects.toThrow(/Invalid or unbounded A1 range/); - expect(urls.some(url => url.includes("sheets.googleapis.com"))).toBe(false); + await expect(Promise.resolve(sheet.readRange("A:A"))) + .rejects.toThrow(/Invalid or unbounded A1 range/); + expect(providerUrls.some(url => new URL(url).hostname === "sheets.googleapis.com")) + .toBe(false); }); it("gives each child an independently disposable approval-queue stub", async () => { const { queue, session } = newSession(); - const doc = await session.openGoogleDoc("doc-1"); - - session[Symbol.dispose](); - await expect(doc.getMetadata()).resolves.toEqual(expect.objectContaining({ - title: "Quarterly plan", - })); - expect(queue.observations).toHaveLength(2); - - (doc as typeof doc & Disposable)[Symbol.dispose](); - await expect(doc.getContent()).rejects.toThrow(); + try { + const doc = await session.openGoogleDoc("doc-1"); + try { + session[Symbol.dispose](); + await expect(doc.getMetadata()).resolves.toEqual(expect.objectContaining({ + title: "Quarterly plan", + })); + expect(queue.observations).toHaveLength(2); + + doc[Symbol.dispose](); + await expect(Promise.resolve(doc.getContent())).rejects.toThrow(); + } finally { + doc[Symbol.dispose](); + } + } finally { + session[Symbol.dispose](); + } }); }); diff --git a/packages/gatekeeper-google/package.json b/packages/gatekeeper-google/package.json index b6a3d44ef..7fd9fbca9 100644 --- a/packages/gatekeeper-google/package.json +++ b/packages/gatekeeper-google/package.json @@ -23,6 +23,7 @@ "@cloudflare/vitest-pool-workers": "catalog:", "miniflare": "5.20260801.0-alpha", "typescript": "catalog:", + "typescript6": "npm:typescript@6.0.3", "vitest": "catalog:", "wrangler": "catalog:" } diff --git a/packages/gatekeeper-google/src/docs-api.ts b/packages/gatekeeper-google/src/docs-api.ts index b2f9a99d3..9201c145a 100644 --- a/packages/gatekeeper-google/src/docs-api.ts +++ b/packages/gatekeeper-google/src/docs-api.ts @@ -19,6 +19,7 @@ export type GoogleDocsDocument = { revisionId: string; body: { content: StructuralElement[] }; lists: Record; + namedRanges: Record; } /** A list definition, referenced by paragraphs that are list items. */ @@ -83,6 +84,47 @@ export type TextStyle = { link?: { url: string }; } +type GoogleDocsTabContent = Pick & { + lists?: GoogleDocsDocument["lists"]; + namedRanges?: GoogleDocsDocument["namedRanges"]; +}; + +type GoogleDocsTab = { + documentTab?: GoogleDocsTabContent; + childTabs?: GoogleDocsTab[]; +}; + +type GoogleDocsResponse = Pick< + GoogleDocsDocument, "documentId" | "title" | "revisionId" +> & { tabs?: GoogleDocsTab[] }; + +type GoogleDocsWriteMarker = { name: string; rangeStart: number }; + +function singleTabDocument(document: GoogleDocsResponse): GoogleDocsDocument { + let tabs = document.tabs; + if (!tabs || tabs.length === 0) { + throw new Error("Google Docs returned no document tab"); + } + + let [tab] = tabs; + if (tabs.length !== 1 || tab.childTabs?.length) { + throw new Error("Multi-tab Google Docs are not supported"); + } + let tabContent = tab.documentTab; + if (!tabContent) { + throw new Error("Google Docs returned a tab without document content"); + } + + return { + documentId: document.documentId, + title: document.title, + revisionId: document.revisionId, + body: tabContent.body, + lists: tabContent.lists ?? {}, + namedRanges: tabContent.namedRanges ?? {}, + }; +} + // --------------------------------------------------------------------------- // API client // --------------------------------------------------------------------------- @@ -108,13 +150,14 @@ export class GoogleDocsApi { }); } - /** Fetch the full document. */ + /** Fetch and normalize a single-tab document. */ async getDocument(documentId: string): Promise { - return this.#request( - `${DOCS_API_BASE}/${encodeURIComponent(documentId)}`, + let document = await this.#request( + `${DOCS_API_BASE}/${encodeURIComponent(documentId)}?includeTabsContent=true`, {}, "get document", ); + return singleTabDocument(document); } /** @@ -136,20 +179,37 @@ export class GoogleDocsApi { /** * Send a batchUpdate request to modify the document. * - * If `targetRevisionId` is provided, the update is applied against that - * revision. Google Docs will merge the changes with any concurrent edits - * (OT-style). The revision ID should come from a previous `getDocument()` - * call. + * `revisionId` is normally a merge target. A marked write instead requires that exact revision, + * so concurrent retries cannot both commit. The ID should come from `getDocument()`. * * Returns the new revision ID after the update. */ async batchUpdate( documentId: string, - requests: any[], - targetRevisionId?: string, + requests: unknown[], + revisionId?: string, + writeMarker?: GoogleDocsWriteMarker, ): Promise { - let body: any = { requests }; - if (targetRevisionId) body.writeControl = { targetRevisionId }; + let markedRequests = writeMarker + ? [{ + createNamedRange: { + name: writeMarker.name, + range: { + startIndex: writeMarker.rangeStart, + endIndex: writeMarker.rangeStart + 1, + }, + }, + }, ...requests] + : requests; + let body: { + requests: unknown[]; + writeControl?: { requiredRevisionId: string } | { targetRevisionId: string }; + } = { requests: markedRequests }; + if (revisionId) { + body.writeControl = writeMarker + ? { requiredRevisionId: revisionId } + : { targetRevisionId: revisionId }; + } let result = await this.#request<{ writeControl?: { requiredRevisionId?: string }; diff --git a/packages/gatekeeper-google/src/docs-read-types.d.ts b/packages/gatekeeper-google/src/docs-read-types.d.ts index 1caf8d6f0..1fdd2154c 100644 --- a/packages/gatekeeper-google/src/docs-read-types.d.ts +++ b/packages/gatekeeper-google/src/docs-read-types.d.ts @@ -12,6 +12,6 @@ export interface GoogleDocReadSession { /** Return current document metadata. */ getMetadata(): Promise; - /** Return the document body converted to Markdown. */ + /** Return the document body as Markdown. Throws if the document has multiple tabs. */ getContent(): Promise; } diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index e5f66e59f..2482510e3 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -268,8 +268,7 @@ export class DriveSessionCore { description: string, ): Promise { if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#outsideScope(); - let file = await this.#api.getFile(fileId); - if (!this.#inScope(file)) this.#outsideScope(); + let file = await this.#getFileInScope(fileId); await this.#authorizeIds( [file.id], `Open ${description} from Google Drive`, diff --git a/packages/gatekeeper-google/src/google-response.ts b/packages/gatekeeper-google/src/google-response.ts index a939c4b60..d593083a9 100644 --- a/packages/gatekeeper-google/src/google-response.ts +++ b/packages/gatekeeper-google/src/google-response.ts @@ -1,9 +1,77 @@ +import { obsContext } from "./observability"; + +const MAX_PROVIDER_ERROR_BYTES = 64 * 1024; +const MAX_PROVIDER_REASONS = 8; +const PROVIDER_CODE_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; +const logger = obsContext.createLogger({ + component: "gatekeeper.google.api", vendorId: "google", +}); + +type GoogleProviderDiagnostics = { + providerCode?: number; + providerStatus?: string; + providerReasons?: string[]; +}; + type GoogleJsonResponseOptions = { provider: string; operation: string; maxBytes: number; }; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function safeProviderCode(value: unknown): string | undefined { + return typeof value === "string" && PROVIDER_CODE_PATTERN.test(value) ? value : undefined; +} + +function addProviderReasons(value: unknown, reasons: Set): void { + if (!Array.isArray(value)) return; + for (let entry of value) { + if (reasons.size >= MAX_PROVIDER_REASONS) return; + if (!isRecord(entry)) continue; + let reason = safeProviderCode(entry.reason); + if (reason !== undefined) reasons.add(reason); + } +} + +function parseProviderDiagnostics(text: string): GoogleProviderDiagnostics { + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return {}; + } + if (!isRecord(payload) || !isRecord(payload.error)) return {}; + + let providerError = payload.error; + let providerCode = typeof providerError.code === "number" && + Number.isSafeInteger(providerError.code) ? providerError.code : undefined; + let providerStatus = safeProviderCode(providerError.status); + let reasons = new Set(); + addProviderReasons(providerError.errors, reasons); + addProviderReasons(providerError.details, reasons); + + let diagnostics: GoogleProviderDiagnostics = {}; + if (providerCode !== undefined) diagnostics.providerCode = providerCode; + if (providerStatus !== undefined) diagnostics.providerStatus = providerStatus; + if (reasons.size > 0) diagnostics.providerReasons = [...reasons]; + return diagnostics; +} + +async function readProviderDiagnostics( + response: Response, provider: string, +): Promise { + try { + let text = await readBoundedText(response, MAX_PROVIDER_ERROR_BYTES, provider); + return parseProviderDiagnostics(text); + } catch { + return {}; + } +} + async function readBoundedText( response: Response, maxBytes: number, @@ -53,7 +121,11 @@ export async function readGoogleJson( ): Promise { let { provider, operation, maxBytes } = options; if (!response.ok) { - await response.body?.cancel().catch(() => {}); + let diagnostics = await readProviderDiagnostics(response, provider); + logger.warn("Google provider request failed", { + event: "google.api.request.failed", provider, operation, httpStatus: response.status, + ...diagnostics, + }); throw new Error(`${provider} ${operation} failed [http=${response.status}]`); } diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index b414dca4d..9a736f5b5 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -95,11 +95,32 @@ import { type GoogleOAuthEnv, type GoogleOAuthState, } from "./oauth"; +import { + DOCS_TYPES_MODULE_PREFIX, DRIVE_TYPES_MODULE_PREFIX, stripTypeModulePrefix, +} from "./type-bundle"; + +let googleDocTypesCode: string | undefined; +let driveAgentTypesCode: string | undefined; +let googleDriveTypesCode: string | undefined; + +function getGoogleDocTypesCode(): string { + return googleDocTypesCode ??= [ + DOCS_READ_TYPES_CODE, + stripTypeModulePrefix(DOCS_TYPES_CODE, DOCS_TYPES_MODULE_PREFIX), + ].join("\n"); +} -const GOOGLE_DOC_TYPES_CODE = [DOCS_READ_TYPES_CODE, DOCS_TYPES_CODE].join("\n"); -const GOOGLE_DRIVE_TYPES_CODE = [ - DOCS_READ_TYPES_CODE, SHEETS_TYPES_CODE, DRIVE_TYPES_CODE, -].join("\n"); +function getDriveAgentTypesCode(): string { + return driveAgentTypesCode ??= stripTypeModulePrefix( + DRIVE_TYPES_CODE, DRIVE_TYPES_MODULE_PREFIX, + ); +} + +function getGoogleDriveTypesCode(): string { + return googleDriveTypesCode ??= [ + DOCS_READ_TYPES_CODE, SHEETS_TYPES_CODE, getDriveAgentTypesCode(), + ].join("\n"); +} // Vendor id = GATEKEEPER_ binding suffix (lowercased). const VENDOR_ID = "google"; @@ -403,8 +424,8 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe async getTypeScriptTypes(): Promise { return [ - TYPES_CODE, GOOGLE_DOC_TYPES_CODE, SHEETS_TYPES_CODE, CALENDAR_TYPES_CODE, - BIGQUERY_TYPES_CODE, DRIVE_TYPES_CODE, + TYPES_CODE, getGoogleDocTypesCode(), SHEETS_TYPES_CODE, CALENDAR_TYPES_CODE, + BIGQUERY_TYPES_CODE, getDriveAgentTypesCode(), ].join("\n"); } } @@ -1763,6 +1784,7 @@ type GoogleDocActionBase = { documentId: string; submittedAt: number; baseRevisionId: string; + writeId?: string; invalidatedReason?: string; } @@ -1985,7 +2007,7 @@ export class GoogleDocGatekeeperImpl } async getTypeScriptTypes(): Promise { - return GOOGLE_DOC_TYPES_CODE; + return getGoogleDocTypesCode(); } async getAutoApprovableActions(): Promise { @@ -2012,45 +2034,54 @@ export class GoogleDocGatekeeperImpl if (pendingIndex === -1) { throw new Error(`Unknown pending Google Doc action: ${actionId}`); } - let pendingRecord = pending[pendingIndex]; - - let action = pendingRecord.action; + let action = pending[pendingIndex].action; if (action.invalidatedReason) { pendingActions.remove(actionId); this.#simulationCache.current = undefined; return; } - let firstPending = pending.find(({action}) => !action.invalidatedReason); + let firstPending = pending.find(record => !record.action.invalidatedReason); if (firstPending?.id !== actionId) { throw new Error( `Google Doc edits must be approved in order. Approve earlier edit ` + `${firstPending?.id} before edit ${actionId}.`); } + if (!action.writeId) { + action.writeId = crypto.randomUUID(); + pendingActions.put(actionId, action); + } + // deferred: retain receipts; garbage-collect them if named-range growth becomes a real limit. + let writeMarkerName = `gadgets-write-${action.writeId}`; let api = new GoogleDocsApi(opts => this.#getAccessToken(opts)); let doc = await api.getDocument(action.documentId); let snapshot = docToMarkdown(doc); - let requests: any[]; - try { - requests = materializeGoogleDocAction(snapshot, action); - } catch (error) { - logger.error("dropping stale Google Doc action during apply", { - event: "google.doc.action.apply.stale.dropped", - actionId, error, - }); - pendingActions.remove(actionId); - this.#simulationCache.current = undefined; - await this.ctx.storage.put("docSnapshot", snapshot); - invalidateUnreplayableGoogleDocActions( - pendingActions, - snapshot.markdown, - pending.slice(pendingIndex + 1), - `Pending Google Doc edits could not be replayed after edit ${actionId} was dropped`); - return; - } - if (requests.length > 0) { - await api.batchUpdate(action.documentId, requests, snapshot.revisionId); + let requests: any[] = []; + if (!Object.hasOwn(doc.namedRanges, writeMarkerName)) { + try { + requests = materializeGoogleDocAction(snapshot, action); + } catch (error) { + logger.error("dropping stale Google Doc action during apply", { + event: "google.doc.action.apply.stale.dropped", + actionId, error, + }); + pendingActions.remove(actionId); + this.#simulationCache.current = undefined; + await this.ctx.storage.put("docSnapshot", snapshot); + invalidateUnreplayableGoogleDocActions( + pendingActions, + snapshot.markdown, + pending.slice(pendingIndex + 1), + `Pending Google Doc edits could not be replayed after edit ${actionId} was dropped`); + return; + } + if (requests.length > 0) { + await api.batchUpdate(action.documentId, requests, snapshot.revisionId, { + name: writeMarkerName, + rangeStart: snapshot.bodyEndIndex - 1, + }); + } } pendingActions.remove(actionId); this.#simulationCache.current = undefined; @@ -2244,6 +2275,7 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { documentId: this.#documentId, submittedAt: Date.now(), baseRevisionId: snapshot.revisionId, + writeId: crypto.randomUUID(), oldMarkdown, newMarkdown, }; @@ -2280,6 +2312,7 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { documentId: this.#documentId, submittedAt: Date.now(), baseRevisionId: snapshot.revisionId, + writeId: crypto.randomUUID(), markdown, }; @@ -3027,7 +3060,7 @@ export class GoogleDriveGatekeeperImpl } async getTypeScriptTypes(): Promise { - return GOOGLE_DRIVE_TYPES_CODE; + return getGoogleDriveTypesCode(); } async getAutoApprovableActions() { diff --git a/packages/gatekeeper-google/src/observability.ts b/packages/gatekeeper-google/src/observability.ts index dfe6690f9..d8f5cc73e 100644 --- a/packages/gatekeeper-google/src/observability.ts +++ b/packages/gatekeeper-google/src/observability.ts @@ -3,7 +3,13 @@ import { createObservabilityContext } from "@gadgets/backend-utils/observability /** Observability fields emitted by the Google gatekeeper. */ export type GoogleObservabilityFields = { actionId: number | string; + httpStatus: number; messageId: string; + operation: string; + provider: string; + providerCode: number; + providerReasons: string[]; + providerStatus: string; vendorId: string; }; diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index 1d163fbe0..20516aeef 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -171,13 +171,13 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] }, { resource: GOOGLE_SHARED_DRIVE_RESOURCE, - // `drive.readonly` (not `drive.metadata.readonly`, which is all this gatekeeper reads): the - // shared-drive picker and the binding's own `getScope` go through `drives.list`/`drives.get`, - // and those two methods accept only `drive` and `drive.readonly`. It is a restricted scope - // granting account-wide *content* read, so it is the one Drive resource whose consent is - // strictly wider than the authority the binding exercises. Narrowing it means dropping both - // calls: resolving a shared drive's name through `files.get` on the drive root instead, and - // giving up drive enumeration in the configurator. + // `drive.readonly` (not `drive.metadata.readonly`): the shared-drive picker and the binding's + // `getScope` use `drives.list`/`drives.get`, which accept nothing narrower. The same scope already + // authorizes native Docs and Sheets content, so do not add redundant API scopes. It is a + // restricted scope granting account-wide content access, strictly wider than the authority the + // shared-drive binding exercises. Narrowing it means dropping both calls: resolving a shared + // drive's name through `files.get` on the drive root instead, and giving up drive enumeration in + // the configurator. scopes: ["https://www.googleapis.com/auth/drive.readonly"], }, { diff --git a/packages/gatekeeper-google/src/type-bundle.ts b/packages/gatekeeper-google/src/type-bundle.ts new file mode 100644 index 000000000..08459d7e0 --- /dev/null +++ b/packages/gatekeeper-google/src/type-bundle.ts @@ -0,0 +1,17 @@ +/** Module-only prefix of the Google Docs declaration. */ +export const DOCS_TYPES_MODULE_PREFIX = + 'import type { GoogleDocReadSession } from "./docs-read-types";\n' + + 'export type { DocMetadata, GoogleDocReadSession } from "./docs-read-types";\n\n'; + +/** Module-only prefix of the Google Drive declaration. */ +export const DRIVE_TYPES_MODULE_PREFIX = + 'import type { GoogleDocReadSession } from "./docs-read-types";\n' + + 'import type { GoogleSpreadsheetSession } from "./sheets-types";\n\n'; + +/** Remove a declaration's expected module prefix before adding it to the flat agent type bundle. */ +export function stripTypeModulePrefix(source: string, prefix: string): string { + if (!source.startsWith(prefix)) { + throw new Error("Agent type declaration has an unexpected module prefix."); + } + return source.slice(prefix.length); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db62e5539..f076f79f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,6 +377,9 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 + typescript6: + specifier: npm:typescript@6.0.3 + version: typescript@6.0.3 vitest: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))