Files
Playa/docs/google-calendar.md
EugeneTes d2fd7ffa02 feat(google-calendar): encrypt chat tokens and give watchers a short-lived access token
token.json is sealed with AES-256-GCM (key derived from the OAuth client secret,
bound to the chat), so the agent's processes can read the file but not the
refresh token. Plain files, including VolcanicSecretary's refresh_token format,
are rewritten sealed on first read.

The scheduler gains IScriptEnvironment: extra per-run variables set after the
process environment filter and redacted from the run's output. The Telegram
host uses it to pass GOOGLE_ACCESS_TOKEN, refreshed to cover the job's timeout;
the refresh token and client secret never reach the script.
2026-10-02 08:31:24 +00:00

200 lines
12 KiB
Markdown

# Google Calendar
`extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` gives the agent a Google Calendar. Each chat connects its
own Google account from the chat itself, with no browser on the bot's machine. The Telegram bot loads it in every
chat once the OAuth client is configured.
## What it does
- A `calendar` tool with the actions `list`, `search`, `get`, `list_instances`, `create`, `update`, `delete` and
`calendars`. It's registered whenever the client is configured, connected or not. Before a chat connects, the tool
tells the model to ask the user to run `/calendar_connect`.
- Three commands: `/calendar_connect`, `/calendar_status` and `/calendar_disconnect`.
- One Google account per chat. The account is keyed by the session scope, which the Telegram bot sets to
`telegram-<chatId>`. A headless host uses the default scope derived from the working directory, so it gets one
account per workspace. The tool only uses the current chat's token, so nobody sees another chat's calendar through
the agent.
This controls what the agent shows to whom. It is not a security boundary between allow-listed users: every one of
them can run shell commands as the bot's OS user, and so could read another chat's token file.
## Google Cloud setup
You need one Google Cloud OAuth client for the bot. All chats share it; each chat still approves its own account.
1. Create a project at [console.cloud.google.com](https://console.cloud.google.com). Then go to
*APIs & Services → Library → Google Calendar API* and click **Enable**.
2. Set up the *OAuth consent screen* (in the newer console: *Google Auth Platform → Branding* and *Audience*). Pick
user type **External** and fill in the app name, a support email and the developer contact address. You don't need
to add scopes there: the bot requests `https://www.googleapis.com/auth/calendar` itself.
3. **Publish the app**: *Audience → Publishing status → Publish app*, so the status reads "In production". A handful
of users doesn't need Google's verification, and refresh tokens of a published app don't expire after 7 days.
4. **Expect the "unverified app" warning.** When a user approves access, Google first shows a page titled
"Google hasn't verified this app". It says the app requests access to sensitive info in the Google account and
that the user shouldn't use it until the developer verifies it with Google. The big button on it is
**Back to safety**. To go on, click the small **Advanced** link at the bottom left, then
**Go to <app name> (unsafe)**. On the consent screen that follows, allow "See, edit, share, and permanently delete
all the calendars you can access using Google Calendar" and press **Continue**. Each Google account sees this
once.
5. Go to *Credentials → Create credentials → OAuth client ID* and pick the application type
**TVs and Limited Input devices**. The device flow the bot uses only works with that type. Copy the client id and
the client secret.
## Configure the bot
Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`, or `GoogleCalendar:ClientId` and `GoogleCalendar:ClientSecret` in
`appsettings.json` (`GoogleCalendar__ClientId` and `GoogleCalendar__ClientSecret` in the environment). A value in the
section wins over the environment variable.
```bash
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com \
GOOGLE_CLIENT_SECRET=GOCSPX-... \
TELEGRAM_BOT_TOKEN=123456:ABC... \
TELEGRAM_ALLOWED_USER_IDS=111111111 \
dotnet run --project hosts/Volcanic.AgenticCore.Telegram
```
- Empty values count as unset.
- With neither value set, the extension stays off and logs one information line:
`google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET`.
- With only one of them set, it stays off and logs a warning that names the missing one.
- With both set, every chat gets the tool and the commands, and the three commands appear in the bot's menu and in
`/help`.
The client secret is added to the process policy's `SecretValues`, so it shows up as `[REDACTED]` in tool output.
`GOOGLE_CLIENT_SECRET` is also on the default deny-list (`*_SECRET`), so `bash`, scheduled scripts and MCP servers
never see it in their environment. See [telegram-bot.md](telegram-bot.md#security).
## Connecting a chat
`/calendar_connect` sends one message with the link and the code:
```
Connect Google Calendar:
1. Open https://www.google.com/device
2. Enter the code ABCD-EFGH
3. Approve access. I'll confirm here when it's done (code valid for 30 min).
```
Open the link on any device, sign in with the Google account for this chat, enter the code and approve. The bot polls
Google on its own, so there's nothing more to type. When you approve, the chat gets:
```
✅ Google Calendar connected as you@gmail.com (time zone Europe/Berlin).
```
The account shown is the primary calendar's id, which is the account's email address.
- Sending `/calendar_connect` again while the bot waits re-sends the same link and code with the minutes left. It
doesn't start a second flow, because a new code would invalidate the one you may be typing.
- A denial ("Google Calendar access was denied."), an expired code ("The Google code expired. Run /calendar_connect
again.") and a failure ("Google Calendar connection failed: ...") each end the wait with one message.
- `/stop` doesn't cancel the wait; it only stops agent runs. `/calendar_disconnect` does cancel it.
- A restart of the bot loses a pending flow. Run `/calendar_connect` again.
- Connecting again while connected switches the chat to the newly approved account. The old token is replaced once the
new one arrives, but it is not revoked.
`/calendar_status` shows one of three things:
- `Connected as <account> since <date>, time zone <tz>.`
- `Not connected. Run /calendar_connect.`
- `Waiting for approval: open <url> and enter <code> (<N> min left).`
`/calendar_disconnect` cancels a pending flow, revokes the token at Google (best effort) and deletes the local token.
If Google can't be reached or refuses, the token is still deleted locally and the reply says so; remove the app by hand
at [myaccount.google.com/permissions](https://myaccount.google.com/permissions).
## Where the token lives
Each chat's refresh token is stored in its workspace, at `<workspace>/.volcanic/google-calendar/token.json` (in the
Telegram bot, `<WorkspaceRoot>/<chatId>/.volcanic/google-calendar/token.json`). It stays there across `/new`,
resume and bot restarts.
- The file is encrypted: `{ "version": 2, "sealed": "..." }`, AES-256-GCM with a key derived from the OAuth client
secret and bound to the chat. The agent can read the file with `read` or `bash` (it runs as the bot's OS user, and
on some disks, such as NTFS, file modes mean nothing), but it never sees the client secret, so it can't decrypt
the file. A file copied into another chat's folder doesn't open there either.
- **A new client secret disconnects every chat**: their files no longer decrypt, and each chat runs
`/calendar_connect` again.
- A plain file, written before encryption or copied from VolcanicSecretary (`{ "refresh_token": "..." }`), is read
once and rewritten encrypted.
- On Unix the file has mode 0600 and its directory 0700.
- Deleting a chat's workspace disconnects its calendar locally. Run `/calendar_disconnect` first to also revoke the
token at Google.
A host can keep tokens out of the workspaces with `GoogleCalendarOptions.TokenDirectory`. Each chat then gets
`<TokenDirectory>/<chat key>/token.json`, where the chat key is the session scope (`telegram-<chatId>`) when it matches
`[A-Za-z0-9._-]{1,100}`, and otherwise `scope-<16 hex digits>`, the start of the scope's SHA-256 hash.
The file holds the refresh token, the granted scope, the account, the calendar's time zone and the connection time.
The refresh token never appears in logs, tool results or error messages.
## Watcher scripts
In the Telegram bot, a chat's [watcher scripts](telegram-bot.md#scheduling) get `GOOGLE_ACCESS_TOKEN` when the chat
has a connected calendar. The bot refreshes it before each run so that it is valid for the job's timeout plus a minute,
up to Google's one-hour limit. A script calls the Calendar API with it directly:
```bash
curl -s -H "Authorization: Bearer $GOOGLE_ACCESS_TOKEN" \
"https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=$(date -u +%FT%TZ)&maxResults=5"
```
The script never gets the refresh token or the client secret: if it leaks the access token, that token only works on
this chat's calendar and expires within the hour. Its value is redacted from the script's output and failure alerts.
Without a connected calendar, or when Google rejects the stored token, the variable is absent. Keep calendar watchers'
timeouts short: a run that outlasts the token gets `401` from Google.
## Time zones
- `create` and `update` take an optional `timeZone` (an IANA id such as `Europe/Berlin`) for the event. Times without
an offset are then in that zone.
- Otherwise times without an offset (`2026-09-30T10:00`) are in the calendar's own time zone, which the bot reads when
the chat connects. If that's unknown, they are in the chat's zone.
- A time with an offset (`2026-09-30T10:00:00+02:00`) keeps its offset.
- Every tool result starts with the zone it uses, such as `Times in Europe/Berlin.` When the calendar's zone differs
from the chat's, the header names both:
`Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).`
- A date alone (`2026-09-30`) is an all-day event. All-day ends are exclusive, as in Google: an event on the 30th ends
on the 1st.
- In a `list` or `search` window, a `timeMax` date includes that day: `timeMin` and `timeMax` both `2026-09-30` cover
the whole 30th.
- For recurring events, `list_instances` returns the ids of single occurrences. `update` and `delete` detect whether an
id is one occurrence or a whole series, so `scope` (`instance` or `series`) only needs to be passed to be explicit.
"This and following" edits aren't supported.
## Testing status instead of publishing
You can leave the app in *Testing* status instead of publishing it:
- Add every user's Google account under *Audience → Test users*. Anyone else gets `access_denied`, which the bot
reports as "Google Calendar access was denied."
- Google expires refresh tokens of apps in testing status after 7 days. After that the tool reports that Google
rejected the stored calendar token, and the chat has to run `/calendar_connect` again, about once a week.
## Other hosts
The extension only depends on the public API, so any host can load it:
```csharp
services.AddSingleton(new GoogleCalendarOptions { ClientId = "...", ClientSecret = "..." });
services.AddAgenticCore(o => o.AddExtension<GoogleCalendarExtension>());
```
`AddExtension<T>()` creates one instance per session, which the extension needs because it keeps per-session fields.
Don't use the overload that takes a shared instance. Without the singleton, the extension reads `GOOGLE_CLIENT_ID`
and `GOOGLE_CLIENT_SECRET` from the environment. To add it for particular sessions only, pass an
`ExtensionRegistration` in `AgentSessionOptions.ExtraExtensions`, as the Telegram host does
(`GoogleCalendarSetup.Registration`). `/calendar_connect` needs a UI (`ctx.HasUi`) to show the link and code; without
one it replies that it needs an interactive UI.
## Troubleshooting
| Message | Cause and fix |
|---|---|
| "Google Calendar is not connected. Ask the user to run /calendar_connect in this chat." | This chat never connected, or it disconnected. Run `/calendar_connect` |
| "Google rejected the stored calendar token (revoked or expired)." | The access was revoked, the account's password changed, or the app is in testing status and 7 days have passed. Run `/calendar_connect` again |
| "Google Calendar connection failed: Google refused the device code request: invalid_client ..." | Wrong client id or secret, or the OAuth client isn't of type *TVs and Limited Input devices* |
| "Google returned no refresh token." | Google already had a grant for this app and didn't issue a new token. Remove the app at [myaccount.google.com/permissions](https://myaccount.google.com/permissions) and run `/calendar_connect` again |
| No calendar tool or commands at all | The client id or secret is missing. Check the startup log for the `google-calendar disabled` line |