diff --git a/CLAUDE.md b/CLAUDE.md index 4c9eabd..c334eea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,8 +83,8 @@ Read `docs/architecture.md` first. Key points: Extensions built on the public API only: providers `Anthropic` (Messages API), `OpenAI` (chat-completions compatible), `ClaudeCode` + `ClaudeCode.Bridge`; `Mcp` (an MCP client: stdio servers as tools, plus the Playwright browser preset; `docs/mcp.md`); the `GoogleCalendar` feature extension (`calendar` tool, -`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens under -`/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide +`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens in the chat +workspace under `.volcanic/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide `JobScheduler` on `TimeProvider`, `jobs.json` under `/scheduler/`, the per-chat `schedule` tool, watcher scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece (`docs/claude-code-provider.md`): diff --git a/docs/docker.md b/docs/docker.md index 38e6511..8fbfbb6 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -131,8 +131,8 @@ The image sets these; normally leave them alone: | Volume | Path | Contents | |---|---|---| -| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/` | -| `agent` | `/data/agent` | Sessions (`sessions/telegram-/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`), `scheduler/jobs.json`, Google Calendar tokens (`google-calendar/`) | +| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`) | +| `agent` | `/data/agent` | Sessions (`sessions/telegram-/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`), `scheduler/jobs.json` | | `claude` | `/data/claude` | The Claude login (credentials and onboarding state) | Named volumes survive redeploys. Coolify prefixes them with the resource's uuid and deletes them only when you delete diff --git a/docs/google-calendar.md b/docs/google-calendar.md index aebe6e9..b088e9a 100644 --- a/docs/google-calendar.md +++ b/docs/google-calendar.md @@ -106,17 +106,19 @@ at [myaccount.google.com/permissions](https://myaccount.google.com/permissions). ## Where the token lives -Each chat's refresh token is stored in `/google-calendar//token.json`. The agent dir is -`$VOLCANIC_AGENT_DIR`, or `~/.volcanic/agent` when that's unset. Hosts can move the root with -`GoogleCalendarOptions.TokenDirectory`. +Each chat's refresh token is stored in its workspace, at `/.volcanic/google-calendar/token.json` (in the +Telegram bot, `//.volcanic/google-calendar/token.json`). It stays there across `/new`, +resume and bot restarts. - On Unix the file has mode 0600 and its directory 0700. -- The directory is outside every chat workspace, so the agent doesn't come across it while it reads and searches the - workspace. -- The chat key is the session scope (`telegram-`) when it matches `[A-Za-z0-9._-]{1,100}`. Any other scope - becomes `scope-<16 hex digits>`, the start of the scope's SHA-256 hash. -- Removing a chat from the allow-list doesn't delete its directory. Run `/calendar_disconnect` first, or delete the - directory yourself. +- 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. + +A host can keep tokens out of the workspaces with `GoogleCalendarOptions.TokenDirectory`. Each chat then gets +`//token.json`, where the chat key is the session scope (`telegram-`) 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. diff --git a/docs/superpowers/specs/2026-09-26-google-calendar-design.md b/docs/superpowers/specs/2026-09-26-google-calendar-design.md index 1ac54c9..635d996 100644 --- a/docs/superpowers/specs/2026-09-26-google-calendar-design.md +++ b/docs/superpowers/specs/2026-09-26-google-calendar-design.md @@ -30,11 +30,13 @@ reshaped for this harness: an extension on the public API, one Google account pe `SessionId`. A headless host gets the default scope derived from the cwd, so one account per workspace. **No Abstractions change.** Tokens are only needed from tools and commands, which always have a context, so nothing is keyed during activation. -- **Tokens live outside every workspace, one directory per chat:** - `/google-calendar//token.json` (agent dir = `VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`), mode - 0600, directory 0700. The chat key is the scope when it matches `[A-Za-z0-9._-]{1,100}` and isn't `.`/`..`, else - `scope-`. The reference kept the token in the workspace, which the agent's - `read`/`bash` tools browse routinely. The root is overridable through `GoogleCalendarOptions.TokenDirectory`. +- **Tokens live in each chat's workspace:** `/.volcanic/google-calendar/token.json`, mode 0600, directory 0700, + as in the reference. (Changed after implementation, at the owner's request; the first version kept them under + `/google-calendar/`.) The workspace is per chat and survives `/new` and resume. The agent's `read`, + `bash` and `send` tools can reach the file; that's accepted, since `bash` could already read a token anywhere under + the bot's OS user. A host that wants tokens out of the workspaces sets `GoogleCalendarOptions.TokenDirectory`: each + chat then gets `//token.json`, where the chat key is the scope when it matches + `[A-Za-z0-9._-]{1,100}` and isn't `.`/`..`, else `scope-`. - **Scope: `https://www.googleapis.com/auth/calendar`**, the same as the reference. Confirmed working with the device flow in practice, although Google's limited-input scope list doesn't mention it. We ask for one scope and nothing else (no `openid`/`email`); the account shown by `/calendar_status` is the primary calendar's id, which @@ -86,7 +88,7 @@ public sealed record GoogleCalendarOptions { public string? ClientId { get; init; } // else GOOGLE_CLIENT_ID public string? ClientSecret { get; init; } // else GOOGLE_CLIENT_SECRET - public string? TokenDirectory { get; init; } // else /google-calendar; one /token.json per chat + public string? TokenDirectory { get; init; } // else /.volcanic/google-calendar/token.json; set: //token.json public Func? GetEnv { get; init; } public HttpClient? Http { get; init; } // tests pass a FakeHttpHandler client public TimeProvider? Time { get; init; } // tests pass a fake clock for polling and token expiry diff --git a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/CalendarCommands.cs b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/CalendarCommands.cs index 60bac5e..fa43e49 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/CalendarCommands.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/CalendarCommands.cs @@ -27,7 +27,7 @@ internal static class ConnectCommand CommandReply.Send(api, ctx, Name, $"/{Name} needs an interactive UI (a chat front end such as the Telegram bot)."); return; } - var connection = extension.ConnectionFor(ctx.SessionManager.Scope); + var connection = extension.ConnectionFor(ctx); var flow = await ConnectFlow.StartOrResendAsync(connection, ctx.Ui, ct).ConfigureAwait(false); if (flow is not null) await flow.RunAsync(ctx.Ui, ctx.Logger).ConfigureAwait(false); }); @@ -41,7 +41,7 @@ internal static class StatusCommand public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, (_, ctx, _) => { - var connection = extension.ConnectionFor(ctx.SessionManager.Scope); + var connection = extension.ConnectionFor(ctx); CommandReply.Send(api, ctx, Name, Describe(connection)); return Task.CompletedTask; }); @@ -67,7 +67,7 @@ internal static class DisconnectCommand public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) => { - var connection = extension.ConnectionFor(ctx.SessionManager.Scope); + var connection = extension.ConnectionFor(ctx); string? revokeError; // The gate a completing connect flow writes its token under: after CancelPending it can no longer write. await connection.FlowGate.WaitAsync(ct).ConfigureAwait(false); diff --git a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/CalendarTool.cs b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/CalendarTool.cs index 470e9fe..a67e676 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/CalendarTool.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/CalendarTool.cs @@ -61,7 +61,7 @@ internal sealed partial class CalendarTool(GoogleCalendarSettings settings, Goog protected override async Task ExecuteAsync(CalendarArgs args, ToolInvocation invocation, CancellationToken cancellationToken) { var ctx = invocation.Context; - var connection = connections.For(settings, ctx.SessionManager.Scope); + var connection = connections.For(settings, ctx.SessionManager.Scope, ctx.Cwd); var resolved = connection.ResolveTimeZone(ctx.TimeZone, ctx.Logger); var eventZone = args.TimeZone is { Length: > 0 } requested ? ZoneIds.TryFind(requested, out var z) ? z : throw new ArgumentException($"Unknown time zone \"{requested}\". Use an IANA id such as Europe/Berlin.") diff --git a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarConnections.cs b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarConnections.cs index 0b064de..955e827 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarConnections.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarConnections.cs @@ -13,16 +13,21 @@ public sealed class GoogleCalendarConnections /// The process-wide instance the extension uses unless its options pass another. public static GoogleCalendarConnections Shared { get; } = new(); + /// Where a chat's token lives inside its workspace when no token root is configured. + public static readonly string WorkspaceTokenDirectory = Path.Combine(".volcanic", "google-calendar"); + private readonly ConcurrentDictionary _connections = new(StringComparer.Ordinal); /// - /// The connection for a session scope, keyed by its token directory (<root>/<chat key>). - /// The first caller's settings create it. + /// The connection for a chat, keyed by its token directory: <workspace>/.volcanic/google-calendar, or + /// <root>/<chat key> when the options set a token root. The first caller's settings create it. /// - internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope) + internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope, string workspace) { ArgumentNullException.ThrowIfNull(settings); - var directory = Path.GetFullPath(Path.Combine(settings.TokenRoot, ChatKeys.For(scope))); + var directory = Path.GetFullPath(settings.TokenRoot is { } root + ? Path.Combine(root, ChatKeys.For(scope)) + : Path.Combine(workspace, WorkspaceTokenDirectory)); return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings, new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName)))); } } diff --git a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarExtension.cs b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarExtension.cs index 43ea62d..4017928 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarExtension.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarExtension.cs @@ -17,14 +17,17 @@ public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = nul private GoogleCalendarSettings? _settings; private ILogger? _logger; private volatile string? _scope; + private volatile string? _cwd; private volatile TimeZoneInfo? _chatZone; public string Name => ExtensionName; internal GoogleCalendarConnections Connections => _options.Connections ?? GoogleCalendarConnections.Shared; - internal GoogleCalendarConnection ConnectionFor(string scope) => - Connections.For(_settings ?? throw new InvalidOperationException("google-calendar is not configured."), scope); + internal GoogleCalendarConnection ConnectionFor(IExtensionContext ctx) => ConnectionFor(ctx.SessionManager.Scope, ctx.Cwd); + + internal GoogleCalendarConnection ConnectionFor(string scope, string workspace) => + Connections.For(_settings ?? throw new InvalidOperationException("google-calendar is not configured."), scope, workspace); public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken) { @@ -54,13 +57,14 @@ public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = nul /// The zone the tool's description and guideline name: the calendar's, else the chat's. Null before the session starts. internal string? CurrentZoneName() => - _scope is { } scope && _settings is not null - ? ZoneIds.Name(ConnectionFor(scope).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger)) + _scope is { } scope && _cwd is { } cwd && _settings is not null + ? ZoneIds.Name(ConnectionFor(scope, cwd).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger)) : null; private ValueTask Track(IExtensionContext ctx) { _chatZone = ctx.TimeZone; + _cwd = ctx.Cwd; _scope = ctx.SessionManager.Scope; return ValueTask.CompletedTask; } diff --git a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarOptions.cs b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarOptions.cs index 786cbb7..4668c67 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarOptions.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarOptions.cs @@ -10,7 +10,10 @@ public sealed record GoogleCalendarOptions public string? ClientId { get; init; } /// OAuth client secret. Else GOOGLE_CLIENT_SECRET. public string? ClientSecret { get; init; } - /// Root of the per-chat token directories. Else <agentDir>/google-calendar. + /// + /// Root of per-chat token directories (<root>/<chat key>/token.json). Unset (the default): each chat's token + /// lives in its workspace, <cwd>/.volcanic/google-calendar/token.json. + /// public string? TokenDirectory { get; init; } /// Environment lookup; defaults to . public Func? GetEnv { get; init; } @@ -29,9 +32,8 @@ public sealed record GoogleCalendarOptions /// True when both the client id and the secret are set (here or in the environment): the extension registers its tool and commands. public bool IsConfigured => EffectiveClientId is not null && EffectiveClientSecret is not null; - internal string EffectiveTokenDirectory => NonEmpty(TokenDirectory) ?? Path.Combine( - NonEmpty(Env("VOLCANIC_AGENT_DIR")) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent"), - "google-calendar"); + /// The configured token root, or null to keep each chat's token in its workspace. + internal string? EffectiveTokenDirectory => NonEmpty(TokenDirectory); /// The settings a connection needs, or null when the client id or secret is missing. internal GoogleCalendarSettings? Resolve() => @@ -46,7 +48,7 @@ public sealed record GoogleCalendarOptions } /// Resolved, validated configuration shared by every chat's connection. -internal sealed record GoogleCalendarSettings(string ClientId, string ClientSecret, string TokenRoot, HttpClient Http, TimeProvider Time) +internal sealed record GoogleCalendarSettings(string ClientId, string ClientSecret, string? TokenRoot, HttpClient Http, TimeProvider Time) { public override string ToString() => $"GoogleCalendarSettings {{ ClientId = {ClientId}, TokenRoot = {TokenRoot} }}"; } diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarConnectionTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarConnectionTests.cs index fe27734..2a01a97 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarConnectionTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarConnectionTests.cs @@ -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(() => _connections.For(_settings, "telegram-1").GetAccessTokenAsync(CancellationToken.None)); + var ex = await Assert.ThrowsAsync(() => _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)); } diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarOptionsTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarOptionsTests.cs index 76ab4b2..5fd95a4 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarOptionsTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarOptionsTests.cs @@ -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 /.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] diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/PerChatTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/PerChatTests.cs index b65e14f..c58f59e 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/PerChatTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/PerChatTests.cs @@ -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() { diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/Support/CalendarFakes.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/Support/CalendarFakes.cs index 8bbd362..5ea6b2b 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/Support/CalendarFakes.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/Support/CalendarFakes.cs @@ -26,9 +26,9 @@ internal sealed class FakeSessionManager(string scope) : IReadOnlySessionManager } /// A command/tool context for one chat: its scope, its zone and (optionally) a UI. -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) + /// Leave TokenDirectory unset, so tokens go to each chat's workspace (). + 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); + /// The chat's workspace (its session cwd). + 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); diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/Support/CalendarToolHarness.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/Support/CalendarToolHarness.cs index fb2b951..0ac185a 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/Support/CalendarToolHarness.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/Support/CalendarToolHarness.cs @@ -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; }