forked from admin/VolcanicAgenticCore
feat(google-calendar): store each chat's token in its workspace
Tokens now live in <cwd>/.volcanic/google-calendar/token.json, as in the VolcanicSecretary reference, instead of <agentDir>/google-calendar/<chat key>/. GoogleCalendarOptions.TokenDirectory, when set, keeps the old per-chat layout under that root. Spec, docs and CLAUDE.md updated.
This commit is contained in:
@@ -28,29 +28,43 @@ public class GoogleCalendarConnectionTests : IDisposable
|
||||
|
||||
private GoogleCalendarConnection Connected(string scope = "telegram-1", string refreshToken = "rt-1", string? zone = null)
|
||||
{
|
||||
var connection = _connections.For(_settings, scope);
|
||||
var connection = _connections.For(_settings, scope, _root);
|
||||
connection.Store.Write(new StoredToken(refreshToken, TimeZone: zone));
|
||||
return connection;
|
||||
}
|
||||
|
||||
private int Refreshes => _http.To("POST /token").Count;
|
||||
|
||||
[Fact]
|
||||
public void Without_a_token_root_each_chat_keeps_its_token_in_its_workspace()
|
||||
{
|
||||
var settings = _settings with { TokenRoot = null };
|
||||
var wsA = Path.Combine(_root, "workspaces", "1");
|
||||
var wsB = Path.Combine(_root, "workspaces", "2");
|
||||
var a = _connections.For(settings, "telegram-1", wsA);
|
||||
var b = _connections.For(settings, "telegram-2", wsB);
|
||||
Assert.Equal(Path.Combine(wsA, ".volcanic", "google-calendar", "token.json"), a.Store.Path);
|
||||
Assert.Equal(Path.Combine(wsB, ".volcanic", "google-calendar", "token.json"), b.Store.Path);
|
||||
Assert.Same(a, _connections.For(settings, "telegram-1", wsA));
|
||||
Assert.NotSame(a, b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_scopes_get_two_token_files_under_their_own_directories()
|
||||
{
|
||||
var a = _connections.For(_settings, "telegram-1");
|
||||
var b = _connections.For(_settings, "telegram-2");
|
||||
var a = _connections.For(_settings, "telegram-1", _root);
|
||||
var b = _connections.For(_settings, "telegram-2", _root);
|
||||
Assert.Equal(Path.Combine(_root, "telegram-1", "token.json"), a.Store.Path);
|
||||
Assert.Equal(Path.Combine(_root, "telegram-2", "token.json"), b.Store.Path);
|
||||
Assert.Same(a, _connections.For(_settings, "telegram-1"));
|
||||
Assert.Same(a, _connections.For(_settings, "telegram-1", _root));
|
||||
Assert.NotSame(a, b);
|
||||
Assert.StartsWith(Path.Combine(_root, "scope-"), _connections.For(_settings, "../escape").Store.Path);
|
||||
Assert.StartsWith(Path.Combine(_root, "scope-"), _connections.For(_settings, "../escape", _root).Store.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Not_connected_throws_the_connect_hint()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _connections.For(_settings, "telegram-1").GetAccessTokenAsync(CancellationToken.None));
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _connections.For(_settings, "telegram-1", _root).GetAccessTokenAsync(CancellationToken.None));
|
||||
Assert.True(ex.NotConnected);
|
||||
Assert.Equal("Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.", ex.Message);
|
||||
Assert.Empty(_http.Requests);
|
||||
@@ -137,7 +151,7 @@ public class GoogleCalendarConnectionTests : IDisposable
|
||||
public void Resolved_zone_falls_back_to_the_chat_zone()
|
||||
{
|
||||
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
Assert.Same(berlin, _connections.For(_settings, "telegram-9").ResolveTimeZone(berlin)); // not connected
|
||||
Assert.Same(berlin, _connections.For(_settings, "telegram-9", _root).ResolveTimeZone(berlin)); // not connected
|
||||
Assert.Same(berlin, Connected(zone: null).ResolveTimeZone(berlin));
|
||||
}
|
||||
|
||||
|
||||
@@ -54,13 +54,13 @@ public class GoogleCalendarOptionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_directory_defaults_under_the_agent_dir()
|
||||
public void Token_directory_defaults_to_the_chat_workspace()
|
||||
{
|
||||
// No root: each chat's token goes to <cwd>/.volcanic/google-calendar, whatever VOLCANIC_AGENT_DIR says.
|
||||
var agentDir = Path.Combine(Path.GetTempPath(), "agent-dir");
|
||||
Assert.Equal(Path.Combine(agentDir, "google-calendar"),
|
||||
new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
|
||||
Assert.Equal(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent", "google-calendar"),
|
||||
new GoogleCalendarOptions { GetEnv = _ => null }.EffectiveTokenDirectory);
|
||||
Assert.Null(new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
|
||||
Assert.Null(new GoogleCalendarOptions { TokenDirectory = " ", GetEnv = _ => null }.EffectiveTokenDirectory);
|
||||
Assert.Null(new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "csecret", GetEnv = _ => null }.Resolve()!.TokenRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -28,6 +28,20 @@ public class PerChatTests
|
||||
Assert.False(Directory.Exists(Path.Combine(h.TokenRoot, ConnectHarness.ScopeB)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_token_directory_connecting_writes_the_token_into_the_chat_workspace()
|
||||
{
|
||||
using var h = new ConnectHarness(workspaceTokens: true);
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
|
||||
await FlowDriver.CompleteAsync(h.Run("calendar_connect", h.Context(new CalendarRecordingUi(), ConnectHarness.ScopeA)), h.Connection(ConnectHarness.ScopeA), h.Time);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(h.Workspace(ConnectHarness.ScopeA), ".volcanic", "google-calendar", "token.json")));
|
||||
Assert.False(Directory.Exists(Path.Combine(h.Workspace(ConnectHarness.ScopeB), ".volcanic")));
|
||||
var ui = new CalendarRecordingUi();
|
||||
await h.Run("calendar_status", h.Context(ui, ConnectHarness.ScopeA));
|
||||
Assert.StartsWith("Connected as me@example.com", Assert.Single(ui.Notes).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_flows_in_two_chats_each_end_with_their_own_store_and_notify()
|
||||
{
|
||||
|
||||
@@ -26,9 +26,9 @@ internal sealed class FakeSessionManager(string scope) : IReadOnlySessionManager
|
||||
}
|
||||
|
||||
/// <summary>A command/tool context for one chat: its scope, its zone and (optionally) a UI.</summary>
|
||||
internal sealed class CalendarFakeContext(string scope, TimeZoneInfo zone, IUserInterface? ui = null, ILogger? logger = null) : IExtensionCommandContext
|
||||
internal sealed class CalendarFakeContext(string scope, TimeZoneInfo zone, IUserInterface? ui = null, ILogger? logger = null, string? cwd = null) : IExtensionCommandContext
|
||||
{
|
||||
public string Cwd => Path.GetTempPath();
|
||||
public string Cwd => cwd ?? Path.GetTempPath();
|
||||
public string SessionId => "session-1";
|
||||
public bool HasUi => ui is not null;
|
||||
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
|
||||
@@ -159,13 +159,14 @@ internal sealed class ConnectHarness : IDisposable
|
||||
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("gcal-connect-").FullName;
|
||||
|
||||
public ConnectHarness(ILogger? logger = null)
|
||||
/// <param name="workspaceTokens">Leave <c>TokenDirectory</c> unset, so tokens go to each chat's workspace (<see cref="Workspace"/>).</param>
|
||||
public ConnectHarness(ILogger? logger = null, bool workspaceTokens = false)
|
||||
{
|
||||
Http = new GoogleFakeHttp(Time);
|
||||
Api = new CalendarFakeApi(logger);
|
||||
Extension = new GoogleCalendarExtension(new GoogleCalendarOptions
|
||||
{
|
||||
ClientId = "cid", ClientSecret = "csecret", TokenDirectory = _root,
|
||||
ClientId = "cid", ClientSecret = "csecret", TokenDirectory = workspaceTokens ? null : _root,
|
||||
Http = Http.CreateClient(), Time = Time, Connections = Connections, GetEnv = _ => null,
|
||||
});
|
||||
Extension.ActivateAsync(Api, CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
||||
@@ -188,9 +189,12 @@ internal sealed class ConnectHarness : IDisposable
|
||||
public static (System.Net.HttpStatusCode, string) Approved(string refreshToken = "rt-1", string accessToken = "at-1") =>
|
||||
(System.Net.HttpStatusCode.OK, $$"""{"access_token":"{{accessToken}}","expires_in":3599,"refresh_token":"{{refreshToken}}","scope":"https://www.googleapis.com/auth/calendar","token_type":"Bearer"}""");
|
||||
|
||||
public GoogleCalendarConnection Connection(string scope = ScopeA) => Extension.ConnectionFor(scope);
|
||||
/// <summary>The chat's workspace (its session cwd).</summary>
|
||||
public string Workspace(string scope = ScopeA) => Path.Combine(_root, "workspaces", scope);
|
||||
|
||||
public CalendarFakeContext Context(CalendarRecordingUi? ui, string scope = ScopeA) => new(scope, Berlin, ui);
|
||||
public GoogleCalendarConnection Connection(string scope = ScopeA) => Extension.ConnectionFor(scope, Workspace(scope));
|
||||
|
||||
public CalendarFakeContext Context(CalendarRecordingUi? ui, string scope = ScopeA) => new(scope, Berlin, ui, cwd: Workspace(scope));
|
||||
|
||||
public Task Run(string command, CalendarFakeContext ctx) => Api.Commands[command].Handler("", ctx, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ internal sealed class CalendarToolHarness : IDisposable
|
||||
public GoogleFakeHttp Http { get; }
|
||||
public GoogleCalendarSettings Settings { get; }
|
||||
public GoogleCalendarConnections Connections { get; } = new();
|
||||
public GoogleCalendarConnection Connection => Connections.For(Settings, Scope);
|
||||
public GoogleCalendarConnection Connection => Connections.For(Settings, Scope, Path.GetTempPath());
|
||||
public TimeZoneInfo ChatZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
public CalendarTool Tool { get; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user