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

@@ -95,6 +95,8 @@ builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
builder.Services.AddSingleton<PollingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
if (scheduling.Enabled) SchedulerSetup.Register(builder.Services); // the hosted service starts after PollingService
// Watchers get a short-lived GOOGLE_ACCESS_TOKEN for their chat's calendar, never the client secret or refresh token.
if (scheduling.Enabled && calendar is not null) builder.Services.AddSingleton<IScriptEnvironment, CalendarScriptEnvironment>();
builder.Services.AddHostedService<BotSetupService>();
using var host = builder.Build();

View File

@@ -0,0 +1,27 @@
using System.Globalization;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Chats;
namespace Volcanic.AgenticCore.Telegram.Scheduling;
/// <summary>
/// Gives a chat's watcher scripts <c>GOOGLE_ACCESS_TOKEN</c>: an access token for that chat's calendar, valid for the
/// run's timeout plus a minute (as far as Google's one-hour lifetime allows). The bot refreshes it; the refresh token
/// and the client secret never reach the script. A chat without a connected calendar gets nothing.
/// </summary>
public sealed class CalendarScriptEnvironment(ChatRegistry chats, GoogleCalendarOptions calendar) : IScriptEnvironment
{
public const string AccessTokenVariable = "GOOGLE_ACCESS_TOKEN";
private static readonly TimeSpan Margin = TimeSpan.FromMinutes(1);
private static readonly IReadOnlyDictionary<string, string> None = new Dictionary<string, string>();
public async Task<IReadOnlyDictionary<string, string>> ForRunAsync(string target, TimeSpan timeout, CancellationToken cancellationToken)
{
if (!long.TryParse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var chatId)) return None;
var token = await (calendar.Connections ?? GoogleCalendarConnections.Shared)
.TryGetAccessTokenAsync(calendar, ChatRegistry.ScopeFor(chatId), chats.WorkspaceFor(chatId), timeout + Margin, cancellationToken)
.ConfigureAwait(false);
return token is null ? None : new Dictionary<string, string> { [AccessTokenVariable] = token };
}
}