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.
This commit is contained in:
EugeneTes
2026-10-02 08:31:24 +00:00
parent 28bbfbc759
commit d2fd7ffa02
16 changed files with 436 additions and 31 deletions

View File

@@ -110,9 +110,15 @@ Each chat's refresh token is stored in its workspace, at `<workspace>/.volcanic/
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.
- The file is inside the workspace, so the agent can read it with `read` or `bash` and could send it with `send`.
The refresh token gives full access to that chat's calendar; treat the workspace accordingly.
- Deleting a chat's workspace disconnects its calendar locally. Run `/calendar_disconnect` first to also revoke the
token at Google.
@@ -123,6 +129,22 @@ A host can keep tokens out of the workspaces with `GoogleCalendarOptions.TokenDi
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