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

View File

@@ -27,7 +27,7 @@ target formats were checked against this repo's code.
| `workspace/scripts/*` | `workspaces/<id>/scripts/` | Copy. The relative paths in job commands still resolve |
| stray root files (reports, PDFs, …) | `workspaces/<id>/` | Copy |
| `workspace/fortress/` profiles | — | **Drop** (we have no Fortress; Playwright profiles aren't compatible). Logins have to be redone |
| `workspace/.secretary/google-token.json` | `workspaces/<id>/.volcanic/google-calendar/token.json` | **Convert** (§3.2) |
| `workspace/.secretary/google-token.json` | `workspaces/<id>/.volcanic/google-calendar/token.json` | Copy; the bot encrypts it on first read (§3.2) |
| `workspace/.secretary/jobs.json` | `workspaces/<id>/.volcanic/scheduler/jobs.json` | Copy; the scheduler converts it on start (§3.3) |
| `pi-agent/sessions/--workspace--/*.jsonl` | `agent/sessions/telegram-<id>/<sessionId>.jsonl` | **Convert**; a verbatim copy won't load (§3.1) |
| env `SECRETARY_TIMEZONE` (probably Europe/Warsaw) | `agent/telegram/chat-settings.json` | Generate `{"version":1,"chats":{"<id>":{"timeZone":"Europe/Warsaw"}}}` |
@@ -79,10 +79,9 @@ with a fresh session that has a compaction summary built from the old one, inste
### 3.2 Google Calendar token
The source is `{"refresh_token":"1//0e…"}`. Our `GoogleTokenStore` reads `refreshToken`: key case is ignored but
the underscore still matters, so a straight copy reads as "not connected". Write
`{"refreshToken":"…","scope":"https://www.googleapis.com/auth/calendar","account":"<owner gmail>","timeZone":"Europe/Warsaw","connectedAt":"<now>"}`
with mode 0600 and directories 0700.
**Implemented:** `GoogleTokenStore` reads a plain `{"refresh_token":"1//0e…"}` and rewrites it encrypted
(`{"version":2,"sealed":…}`, a key derived from the client secret, bound to the chat), so a straight copy works. The
account and calendar time zone are unknown until a reconnect; the chat's zone is used meanwhile.
Both apps use the device flow with a "TVs and Limited Input devices" client. The refresh token is bound to the
client, so **set our `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` to the Secretary's values**. With a different client,

View File

@@ -164,7 +164,9 @@ script itself, **without the agent**:
Later identical failures stay quiet until the script succeeds again.
Scripts run in the chat's workspace with the same filtered environment as `bash` and `TZ` set to the chat's zone.
Known secrets are redacted from their output.
When the chat has a connected Google Calendar they also get `GOOGLE_ACCESS_TOKEN`, a short-lived token for that
calendar ([google-calendar.md](google-calendar.md#watcher-scripts)). Known secrets and that token are redacted from
their output.
When a job fires while the agent is working, a scheduled prompt waits until the current work is done; it never
interrupts. Occurrences that come due while the same job is still running are skipped.