# Google Drive simulator

A stateful Google Drive simulator.

`google-drive-twin` is a local, stateful simulator of the Google Drive API v3, built from the API's Discovery document (revision `20260916`) converted to OpenAPI.

## Quickstart

You need an API key from [Account → API keys](https://www.rystic.ai/account/api-keys) ([how](https://www.rystic.ai/docs/licensing.md#get-an-api-key)).

```bash
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                                                              # your API key
rystic run google-drive-twin -d --port 0 --scenario google-drive_media_vault   # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version
```

Expect `"service_hosts":["www.googleapis.com"]`. The examples below use `localhost:8080`; use the port `rystic run` printed. On Enterprise, `rystic login ./rystic-license.json` takes the place of the key ([license files](https://www.rystic.ai/docs/licensing.md#enterprise-license-files)).

Read the vault, then upload a file and download it back:

```bash
curl -s -H 'Authorization: Bearer ya29.placeholder' 'localhost:8080/drive/v3/files?q=trashed%3Dfalse&fields=files(id,name)'
# -> {"files":[{"id":"1MVfolder2025Shoots02","name":"2025 Shoots"},...,{"id":"1MVjpgBeachSunrise03","name":"beach-sunrise.jpg"}]}

curl -s -H 'Authorization: Bearer ya29.placeholder' -H 'Content-Type: text/plain' --data-binary 'hello' \
  'localhost:8080/upload/drive/v3/files?uploadType=media&fields=id,name,size'
# -> {"id":"1rysticFile0000000000000000000001","name":"Untitled","size":"5"}
curl -s -H 'Authorization: Bearer ya29.placeholder' 'localhost:8080/drive/v3/files/1rysticFile0000000000000000000001?alt=media'
# -> hello
```

## Point your client at it

Google's client libraries build every URL on `https://www.googleapis.com`, and the base-URL option alone does not move their **upload** URLs. **An upload left unpointed goes to the real Google Drive.**

Node (`@googleapis/drive` or `googleapis`): the client's `rootUrl` skips upload URLs, so hand it a fetch that rewrites Google's origin to the twin's and refuses anything else.

```js
import { auth, drive as makeDrive } from "@googleapis/drive";
const twin = "http://localhost:8080";
const user = new auth.OAuth2();
user.setCredentials({ access_token: "ya29.placeholder" });
const drive = makeDrive({
  version: "v3",
  auth: user,
  fetchImplementation: (url, init) => {
    const raw = String(url instanceof Request ? url.url : url);
    const target = raw.replace(/^https:\/\/www\.googleapis\.com/, twin);
    if (!target.startsWith(twin + "/")) throw new Error(`refusing ${target}`);
    return fetch(target, init);
  },
});
const { data: f } = await drive.files.create({
  requestBody: { name: "hello.txt" },
  media: { mimeType: "text/plain", body: "hello" },
  fields: "id,name",
});
```

Without the fetch, pass `{ rootUrl: "http://localhost:8080/" }` as the second argument of every call that carries `media`, as well as to `drive({...})`.

Python (`google-api-python-client`): `client_options` `api_endpoint` keeps uploads on `https`, so build from the bundled Discovery document with its `rootUrl` rewritten; no request reaches Google, the document included.

```python
import json
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build_from_document
from googleapiclient.discovery_cache import get_static_doc
from googleapiclient.http import MediaInMemoryUpload

doc = json.loads(get_static_doc("drive", "v3"))
doc["rootUrl"] = "http://localhost:8080/"
drive = build_from_document(doc, credentials=Credentials(token="ya29.placeholder"))
f = drive.files().create(body={"name": "hello.txt"},
                         media_body=MediaInMemoryUpload(b"hello", mimetype="text/plain"),
                         fields="id,name").execute()
drive.files().get_media(fileId=f["id"]).execute()   # b"hello"
```

Keep uploads non-resumable: the twin answers `uploadType=resumable` with 501. Both clients send a media upload as `uploadType=multipart` by default.

## Plans

`google-drive-twin` is on Pro and Enterprise, not Free ([plans](https://www.rystic.ai/docs/licensing.md#plans)); on a Free key, `rystic pull google-drive-twin` and a start are refused with `product_not_in_plan`. Both plans serve all 35 Drive routes, the whole control plane, custom latency and fault injection included, and every world. Without `--scenario` the twin boots one user with **no files**, and `POST /_rystic/reset` wipes it back to that.

## Auth

Drive reads an OAuth 2 access token as `Authorization: Bearer ya29.…`. The twin accepts any non-empty bearer token; no Google Cloud project, OAuth client or token is involved.

1. A request with no `Authorization` header, an empty bearer value or another scheme is refused as Drive refuses a missing credential on that method, before the file lookup: 401 `required` "Login Required." with the method's `ErrorInfo` (`CREDENTIALS_MISSING`) on most methods, 403 `forbidden` "Method doesn't allow unregistered callers" on `files.list`, `files.get` and the comments and replies methods, and 403 `forbidden` "The request is missing a valid API key." on `files.get` with `alt=media` and on `files.export` (RYS-2725).
2. Every request speaks as the world's own user, permission id `member`, whatever the token says. `X-Rystic-Actor: <permissionId>` speaks as another of the world's users (`GET /_rystic/inspect` lists them under `state_user`), and `POST /_rystic/credentials` with `{"bindings":{"<token>":"<permissionId>"}}` makes a token speak as one. Loading a world clears the bindings.

## Coverage

The twin serves 35 operations in three scopes. Metadata CRUD on the `files` resource: list files, create a file or a folder from its metadata, read one, update its metadata, copy it and delete it. Content and search: create and update a file by `uploadType=media` and `uploadType=multipart` on `/upload/drive/v3`, download a file's bytes and a revision's bytes with `alt=media` (a `Range` answers 206, and one past the end 416 with Drive's JSON error), export a Google Docs Editors file, `about.get`, list and read revisions, `files.generateIds`, whose ids a create may name, and `files.emptyTrash`. Sharing and collaboration: permissions, comments and replies, each listed, read, created, updated and deleted, and updating and deleting a revision. Every route answers a caller by its role on the file: the owner, or the role a permission on the file or on a folder above it grants, so a file the caller holds no role on is not found, and `ownedByMe`, `capabilities`, `shared`, `permissionIds` and `modifiedByMe` are worked out for each caller. Every comments and replies method but delete requires `fields`. Each route answers Drive's default members of its resource (a file's `kind`, `id`, `name` and `mimeType`), and a `fields` selector narrows a success answer to the members it names from the whole resource, `*` for all of it. `files.list` filters by `q`, `fullText`, `sharedWithMe` and a caller's role among them, and sorts by `orderBy`, newest first without one. `addParents` and `removeParents` move a file, a file inside a trashed folder reads trashed, a file created without parents lands in the caller's `My Drive` root, and the id `root` names that folder on every route. The probe suite measures every route live inside one Google user's own folder in My Drive. What a second user sees — a file not shared with them, a reader, a commenter, a comment's non-author — is measured by the sealed suite's probes that sign as a second user, and exercised by the designated SDK caller, which acts as two users against the twin.

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs google-drive; do not edit) -->

<!-- coverage-claim -->
35 routes across 8 resources, counted from the sim's literal registrations.

| Resource | Operations |
|---|---|
| About (1) | `GET /drive/v3/about` |
| Comment (3) | `POST /drive/v3/files/{fileId}/comments`; `PATCH, DELETE /drive/v3/files/{fileId}/comments/{commentId}` |
| File (22) | `GET, POST /drive/v3/files`; `DELETE /drive/v3/files/trash`; `GET, PATCH, DELETE /drive/v3/files/{fileId}`; `GET /drive/v3/files/{fileId}?alt=media`; `GET /drive/v3/files/{fileId}/comments`; `GET /drive/v3/files/{fileId}/comments/{commentId}`; `GET /drive/v3/files/{fileId}/comments/{commentId}/replies`; `GET /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}`; `POST /drive/v3/files/{fileId}/copy`; `GET /drive/v3/files/{fileId}/export`; `GET /drive/v3/files/{fileId}/permissions`; `GET /drive/v3/files/{fileId}/permissions/{permissionId}`; `GET /drive/v3/files/{fileId}/revisions`; `GET /drive/v3/files/{fileId}/revisions/{revisionId}`; `GET /drive/v3/files/{fileId}/revisions/{revisionId}?alt=media`; `POST /upload/drive/v3/files?uploadType=media`; `POST /upload/drive/v3/files?uploadType=multipart`; `PATCH /upload/drive/v3/files/{fileId}?uploadType=media`; `PATCH /upload/drive/v3/files/{fileId}?uploadType=multipart` |
| Generated file ID (1) | `GET /drive/v3/files/generateIds` |
| Permission (3) | `POST /drive/v3/files/{fileId}/permissions`; `PATCH, DELETE /drive/v3/files/{fileId}/permissions/{permissionId}` |
| Reply (3) | `POST /drive/v3/files/{fileId}/comments/{commentId}/replies`; `PATCH, DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}` |
| Revision (2) | `PATCH, DELETE /drive/v3/files/{fileId}/revisions/{revisionId}` |

<!-- END generated: served-operations -->

## Control plane

Every route under `/_rystic/` answers on both plans; [routes by plan](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) lists them.

## Scenarios

- `google-drive_fresh_mydrive`: A freshly provisioned personal Drive at its floor: one owner and the My Drive root folder with nothing in it, so every listing starts empty, `root` names that folder, and the first created file is unmistakable.
- `google-drive_media_vault`: A photographer's binary archive — colour-coded folders holding a JPEG, an MP4, a camera RAW and a tar.gz of proofs, each with real byte sizes, md5/sha checksums, head revisions and app-set properties, one of them published to anyone with the link.
- `google-drive_shared_inbox`: An account whose interesting files belong to other people: three items shared in by a vendor and a partner with sharedWithMeTime set and ownedByMe false, plus a shortcut filed in the caller's own folder pointing at one of them.
- `google-drive_team_workspace`: A lived-in My Drive tree Dana Okafor owns, shared with Milo Reyes (writer) and Priya Shah (commenter): a two-level folder tree under the My Drive root, three Google-native editor files, a binary PDF and a content-restricted policy doc, so `files.list` returns real rows from the first call and `files.get` reads each seeded file. Each caller's access follows its role on the file: Dana, the default caller, owns every file, Milo edits and shares, Priya can read but not update, share or delete, and a caller with no role gets 404. Its Google-native files carry the mimeTypes `files.export` converts from.
- `google-drive_trash_backlog`: A My Drive whose bin is already full from boot — a folder trashed outright, a doc trashed only because its parent was, a trashed sheet and image, and one live doc — so `files.list` returns trashed rows and `files.update` with `trashed` false has standing targets. `files.emptyTrash` answers 204 and deletes the folder, the doc inside it, the sheet and the image, leaving the root and the live doc.

## Seeding state

`POST /_rystic/seed` takes a register map — `state_user` (keyed by permission id), `state_file` (keyed by file id), `state_revision` (keyed `<fileId>/<revisionId>`, its bytes in `body_b64`), `state_permission`, `state_comment`, `state_reply` — and merges it into the running Drive; [Worlds](https://www.rystic.ai/docs/concepts/worlds.md) covers seed files, `GET /_rystic/registers` and the seed gate. A file with bytes is a `state_file` whose `headRevisionId` names a `state_revision` holding them:

```bash
curl -X POST localhost:8080/_rystic/seed -d '{
  "state_file": {"notes1": {"id": "notes1", "name": "notes.txt", "mimeType": "text/plain",
    "parents": [], "owners": [{"permissionId": "member"}], "headRevisionId": "r1"}},
  "state_revision": {"notes1/r1": {"fileId": "notes1", "id": "r1", "mimeType": "text/plain",
    "body_b64": "aGVsbG8gd29ybGQ="}}
}'
# -> 204; alt=media on notes1 then answers "hello world"
```

## Determinism & reset

Ids are sequences the reset rewinds, so the first file created after a reset has the same id every time, and the clock starts at a pinned instant — `2026-01-01T00:00:00Z` empty, `2026-02-02T00:00:00Z` in every world — and moves one millisecond per request that stamps a time — every write, none of the reads — never by wall time. Two identical runs from the same reset give byte-identical files. [Reset and determinism](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Egress

Drive declares no outbound effects: no request sends an email or calls a webhook, and `GET /_rystic/egress` is always empty.

## Limitations

- `fields` selects as Drive does: it narrows the 2xx answer and leaves an error reply whole, a selector that does not parse answers 400 `invalidParameter`, `required` on `about.get` (a create refused for its selector still writes the file), and an empty one answers `{}`, except on the comments and replies methods, which answer an absent or empty selector with 400 `required` as Drive does. A selector naming a member the method's resource does not have, at any depth and in any case but its own (`nope`, `owners(nope)`, `capabilities/nope`, `ID`), answers 400 `invalidParameter` "Invalid field selection nope" at location `fields`, as Drive does; a `files.create` refused this way still writes the file, while a media or multipart upload, create or update, refused this way writes nothing and is refused before any other check.
- `files.list` answers `q=name < 'x'` with 400 `invalid`, where Drive answers 500 `internalError` (RYS-2831). `visibility` and Drive's other unlisted search terms answer 400 `invalid`.
- A file shared with the caller answers no `sharedWithMeTime`, and `viewedByMe` is false for every caller. What a writer is answered when it trashes, restores or deletes a file it does not own, and the writer's and commenter's `capabilities`, are worked out from the owner's and the reader's measured sets, not measured (RYS-2672).
- What one user sees of another's files is measured by the sealed probe suite, whose sharing probes sign as a second user (a reader, a commenter, and a user the file is not shared with), and exercised by the designated SDK caller.
- A shipped world's binary files download small real payloads that match their `size` and checksums; a file you upload or copy downloads byte for byte. An upload holds at most 64 MiB: a request body over it, a multipart body's metadata and part headers included, answers 413 `request body too large`, where Drive takes up to 5,120 GB. Exports are stand-ins built from the file's name and description, never a rendering.
- A file's `permissions` member answers `[]` for a file you create and keeps a seeded file's old roles after a share or transfer, where `permissions.list` is right (RYS-2767).
- An unserved route answers 404 `notFound` naming a file that does not exist, and a resumable upload answers 501. There are no rate limits: never a 403 `userRateLimitExceeded` or a 429.

Left out on purpose, with no later scope planned before the first release: shared drives, changes, `files.download` with `operations.get`, resumable upload and batch; `files.watch`, `changes.watch` and `channels.stop`, whose notifications go to an address the caller registers; approvals; access proposals; `files.listLabels` and `files.modifyLabels`; apps and `files.generateCseToken`; and the deprecated `teamdrives` methods.

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs google-drive; do not edit) -->

**Known issues.**

- A file's `permissions` member answers `[]` for a file you create, and a seeded file's inline `permissions` keep their old roles after a share or an ownership transfer; `permissions.list` is right (RYS-2767)
- `files.list` with `q=name < 'x'` answers 400 `invalid`, where Drive answers 500 `internalError` (RYS-2831)
- `DELETE /drive/v3/files/trash` permanently deletes a collaborator's file inside the caller's trashed folder, where Drive deletes only the caller's own files (RYS-2884)
- A folder's owner can't read a collaborator's file in her own folder (404, and `'<folder>' in parents` leaves it out), and that file lists two owners (RYS-2886)
- A link-only file (`anyone` with `allowFileDiscovery: false`) shows up in any user's `files.list` (RYS-2887)
- `starred` is one flag shared by every user, a reader or commenter can't star a file, and starring bumps `version` and `modifiedTime` (RYS-2888)
- Restrictions the twin shows in `capabilities` are not enforced: `addParents` into a folder with `canAddChildren: false`, a rename or comment on a read-only file, and a copy under `copyRequiresWriterPermission` (RYS-2889)
- `files.copy` points the copy's `webViewLink`, `webContentLink` and `thumbnailLink` at the source, ignores an id from `generateIds`, and leaves a reader's copy with no parent (RYS-2890)
- `comments.get` never returns `replies`, and its `photoLink` reads `https://` where Drive answers `//` (RYS-2891)
- The folder tree's write rules are not enforced: a file can take two parents, a folder can move into itself or its child, a plain file is accepted as a parent, a body can change `mimeType`, and read-only members such as `size` are ignored rather than refused (RYS-2892)
- A `DELETE` or trash of `root` is unmeasured: Drive reports `canDelete` and `canTrash` true on the root, so neither is ever sent to Drive, and the twin's answer to them is not Drive's (RYS-2894)
- A media upload onto a folder answers 200 and leaves the folder unchanged, as Drive does; what Drive does with the uploaded bytes is unmeasured (RYS-2895)

**Not served.** 30 of the 65 operations in the published spec are not served.

- files, 14 of 19 served: `GET /drive/v3/files/generateCseToken`, `POST /drive/v3/files/{fileId}/download`, `GET /drive/v3/files/{fileId}/listLabels`, `POST /drive/v3/files/{fileId}/modifyLabels`, `POST /drive/v3/files/{fileId}/watch`
- Not served at all: accessproposals (3), approvals (8), apps (2), changes (3), channels (1), drives (7), operations (1)

**Not measured.** The fidelity suite runs 363 probes, and 157 documented branches have none. 58 need a credential the measuring account does not hold (401 ×28, 403 ×30). The other 99:

| Operation | Status | Why |
|---|---:|---|
| `DELETE /drive/v3/files/trash` | 400 | a well-formed request does not provoke it |
| `DELETE /drive/v3/files/trash` | 429 | no request a client sends provokes it |
| `DELETE /drive/v3/files/trash` | 500 | no request a client sends provokes it |
| `DELETE /drive/v3/files/{fileId}` | 400 | a well-formed request does not provoke it |
| `DELETE /drive/v3/files/{fileId}` | 429 | no request a client sends provokes it |
| `DELETE /drive/v3/files/{fileId}` | 500 | no request a client sends provokes it |
| `DELETE /drive/v3/files/{fileId}/comments/{commentId}` | 429 | no request a client sends provokes it |
| `DELETE /drive/v3/files/{fileId}/comments/{commentId}` | 500 | no request a client sends provokes it |
| `DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}` | 404 | nothing the twin serves creates the record it needs |
| `DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}` | 429 | no request a client sends provokes it |
| `DELETE /drive/v3/files/{fileId}/revisions/{revisionId}` | 204 | nothing the twin serves creates the record it needs |
| `GET /drive/v3/about` | 400 | a well-formed request does not provoke it |
| `GET /drive/v3/about` | 429 | no request a client sends provokes it |
| `GET /drive/v3/about` | 500 | no request a client sends provokes it |
| `GET /drive/v3/files` | 429 | no request a client sends provokes it |
| `GET /drive/v3/files` | 500 | no request a client sends provokes it |
| `GET /drive/v3/files/generateIds` | 429 | no request a client sends provokes it |
| `GET /drive/v3/files/generateIds` | 500 | no request a client sends provokes it |
| `GET /drive/v3/files/{fileId}` | 400 | nothing the twin serves creates the record it needs |
| `GET /drive/v3/files/{fileId}` | 429 | no request a client sends provokes it |
| … and 75 more | | |

1 probe from earlier rounds is kept as unmeasured.

**Not modeled.** What the model's operations state they leave out:

- Deletion visibility lags: a trashed file read right after the 204 can still answer 200 for about 2 s. That lag belongs to the reading vector's metadata.x_eventually_consistent; this writer declares none. (`DELETE /drive/v3/files/trash`)
- The 400 branch's trigger on this method is unmeasured: the evidence's invalid-selector and invalid-value samples are from files.get and files.list, so no violating query value is declared here. (`DELETE /drive/v3/files/trash`)
- driveId: no shared-drive state family is in scope and the oracle account seeds none, so the shared-drive 404 and any shared-drive success are documented but carry no executable recipe. (`DELETE /drive/v3/files/trash`)
- Deleting or trashing the root itself (fileId root, or the root's id) is unmeasured and is never sent to the oracle: Drive reports capabilities canDelete and canTrash true on the root (root-and-folder-writes/01), so a measurement could empty the oracle account's My Drive. (RYS-2894) (`DELETE /drive/v3/files/{fileId}`)
- No evidence names a request that provokes the 400 branch on this operation (the BadRequest sample is files.create with an unparsable body, and DELETE carries no body); whether a non-boolean supportsAllDrives is rejected, coerced or defaulted is unmeasured, so the branch carries no derivable recipe. (`DELETE /drive/v3/files/{fileId}`)
- The 429 and 500 branches are usage-limit and backend conditions the harness cannot provoke deliberately. (`DELETE /drive/v3/files/{fileId}`)
- The shared-drive path (supportsAllDrives=true, driveId-bearing files, organizer-on-parent-folder permission) is documented but not reachable with the declared state families. (`DELETE /drive/v3/files/{fileId}`)
- Whether the deletion is visible to a later read immediately or only after a delay is unmeasured: no evidence states a lag, and the 204 act is itself the compared step, so no settled readback is declared. The same is true of a folder's descendants. (`DELETE /drive/v3/files/{fileId}`)
- The operation documents a 400 BadRequest response but the evidence names no trigger for it on this method: it takes no `fields` and no request body, so no condition in the bundle reaches a 400. The service-wide malformed-selector refusal (state_model.partial_response.malformed, applies_write true) does not list this vector among partial_response.vectors, so whether a malformed `fields` value is refused or ignored here is unmeasured. (`DELETE /drive/v3/files/{fileId}/comments/{commentId}`)
- Whether the 403 is reached only by a comment authored by another user, or also by a file the caller may read but not comment on (the file-level insufficientFilePermissions case), is not separated by the evidence; both are modelled as one branch. (`DELETE /drive/v3/files/{fileId}/comments/{commentId}`)
- The operation declares a 400 BadRequest and a 500 BackendError; it takes no request body and no fields parameter, and the evidence names no condition on this method that provokes either, so no branch is modeled for them. (`DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}`)
- The query parameters $.xgafv, access_token, alt, callback, fields, key, oauth_token, prettyPrint, quotaUser, uploadType and upload_protocol are service-wide system parameters; the evidence states this method takes no fields and documents no behavior for the others here, so none is declared in query_shape. (`DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}`)
- Whether a reply may be deleted by a caller who is neither its author nor the file owner (the 403 trigger) is not measured. (`DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}`)
- 429 rateLimitExceeded and 500 BackendError are declared by the shared responses but have no evidenced per-request trigger here; they stay unmodeled. (`DELETE /drive/v3/files/{fileId}/permissions/{permissionId}`)
- Cross-actor access lag after a delete (about 1.8 s before actor B's read turns 404) is a property of the reading operation, not of this delete. (`DELETE /drive/v3/files/{fileId}/permissions/{permissionId}`)
- The documented 400 BadRequest on this method has no evidenced trigger for a delete that carries no body (the fields selector selects nothing on a 204); no branch is declared rather than guessing one. (`DELETE /drive/v3/files/{fileId}/permissions/{permissionId}`)
- supportsAllDrives / useDomainAdminAccess only matter for shared drives, which the oracle account's My Drive fixture does not exercise. (`DELETE /drive/v3/files/{fileId}/permissions/{permissionId}`)
- 400 BadRequest on a malformed fields selector: the service-wide partial_response policy answers it; no operation-specific 400 trigger is documented for this method. (`DELETE /drive/v3/files/{fileId}/revisions/{revisionId}`)
- 403 insufficientFilePermissions when the caller lacks write access; the live note records only that a reader deleting a non-head revision answers 404 (a reader sees only the head), so the reader-specific 404 is not modelled as its own branch. (`DELETE /drive/v3/files/{fileId}/revisions/{revisionId}`)
- 429 rateLimitExceeded and 500 backend error: ambient usage-limit and backend conditions, not provokable by a well-formed probe. (`DELETE /drive/v3/files/{fileId}/revisions/{revisionId}`)
- … and 122 more

<!-- END generated: known-gaps -->

Release notes: [Changelog](https://www.rystic.ai/docs/changelog.md#google-drive)

---

Source: https://www.rystic.ai/docs/simulators/google-drive · Markdown: https://www.rystic.ai/docs/simulators/google-drive.md
