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:
EugeneTes
2026-09-28 20:42:58 +02:00
parent 809261b29e
commit c3a0cf234c
14 changed files with 102 additions and 55 deletions

View File

@@ -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), 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 `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, 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 `/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens in the chat
`<agentDir>/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide workspace under `.volcanic/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
`JobScheduler` on `TimeProvider`, `jobs.json` under `<agentDir>/scheduler/`, the per-chat `schedule` tool, watcher `JobScheduler` on `TimeProvider`, `jobs.json` under `<agentDir>/scheduler/`, the per-chat `schedule` tool, watcher
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece
(`docs/claude-code-provider.md`): (`docs/claude-code-provider.md`):

View File

@@ -131,8 +131,8 @@ The image sets these; normally leave them alone:
| Volume | Path | Contents | | Volume | Path | Contents |
|---|---|---| |---|---|---|
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/` | | `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `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-<chatId>/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`), `scheduler/jobs.json`, Google Calendar tokens (`google-calendar/`) | | `agent` | `/data/agent` | Sessions (`sessions/telegram-<chatId>/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`), `scheduler/jobs.json` |
| `claude` | `/data/claude` | The Claude login (credentials and onboarding state) | | `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 Named volumes survive redeploys. Coolify prefixes them with the resource's uuid and deletes them only when you delete

View File

@@ -106,17 +106,19 @@ at [myaccount.google.com/permissions](https://myaccount.google.com/permissions).
## Where the token lives ## Where the token lives
Each chat's refresh token is stored in `<agentDir>/google-calendar/<chat key>/token.json`. The agent dir is Each chat's refresh token is stored in its workspace, at `<workspace>/.volcanic/google-calendar/token.json` (in the
`$VOLCANIC_AGENT_DIR`, or `~/.volcanic/agent` when that's unset. Hosts can move the root with Telegram bot, `<WorkspaceRoot>/<chatId>/.volcanic/google-calendar/token.json`). It stays there across `/new`,
`GoogleCalendarOptions.TokenDirectory`. resume and bot restarts.
- On Unix the file has mode 0600 and its directory 0700. - 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 - The file is inside the workspace, so the agent can read it with `read` or `bash` and could send it with `send`.
workspace. The refresh token gives full access to that chat's calendar; treat the workspace accordingly.
- The chat key is the session scope (`telegram-<chatId>`) when it matches `[A-Za-z0-9._-]{1,100}`. Any other scope - Deleting a chat's workspace disconnects its calendar locally. Run `/calendar_disconnect` first to also revoke the
becomes `scope-<16 hex digits>`, the start of the scope's SHA-256 hash. token at Google.
- Removing a chat from the allow-list doesn't delete its directory. Run `/calendar_disconnect` first, or delete the
directory yourself. A host can keep tokens out of the workspaces with `GoogleCalendarOptions.TokenDirectory`. Each chat then gets
`<TokenDirectory>/<chat key>/token.json`, where the chat key is the session scope (`telegram-<chatId>`) 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 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. The refresh token never appears in logs, tool results or error messages.

View File

@@ -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 `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 Abstractions change.** Tokens are only needed from tools and commands, which always have a context, so nothing is
keyed during activation. keyed during activation.
- **Tokens live outside every workspace, one directory per chat:** - **Tokens live in each chat's workspace:** `<cwd>/.volcanic/google-calendar/token.json`, mode 0600, directory 0700,
`<agentDir>/google-calendar/<chat key>/token.json` (agent dir = `VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`), mode as in the reference. (Changed after implementation, at the owner's request; the first version kept them under
0600, directory 0700. The chat key is the scope when it matches `[A-Za-z0-9._-]{1,100}` and isn't `.`/`..`, else `<agentDir>/google-calendar/`.) The workspace is per chat and survives `/new` and resume. The agent's `read`,
`scope-<first 16 hex of SHA-256(scope)>`. The reference kept the token in the workspace, which the agent's `bash` and `send` tools can reach the file; that's accepted, since `bash` could already read a token anywhere under
`read`/`bash` tools browse routinely. The root is overridable through `GoogleCalendarOptions.TokenDirectory`. the bot's OS user. A host that wants tokens out of the workspaces sets `GoogleCalendarOptions.TokenDirectory`: each
chat then gets `<root>/<chat key>/token.json`, where the chat key is the scope when it matches
`[A-Za-z0-9._-]{1,100}` and isn't `.`/`..`, else `scope-<first 16 hex of SHA-256(scope)>`.
- **Scope: `https://www.googleapis.com/auth/calendar`**, the same as the reference. Confirmed working with - **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 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 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? ClientId { get; init; } // else GOOGLE_CLIENT_ID
public string? ClientSecret { get; init; } // else GOOGLE_CLIENT_SECRET public string? ClientSecret { get; init; } // else GOOGLE_CLIENT_SECRET
public string? TokenDirectory { get; init; } // else <agentDir>/google-calendar; one <chat key>/token.json per chat public string? TokenDirectory { get; init; } // else <cwd>/.volcanic/google-calendar/token.json; set: <root>/<chat key>/token.json
public Func<string, string?>? GetEnv { get; init; } public Func<string, string?>? GetEnv { get; init; }
public HttpClient? Http { get; init; } // tests pass a FakeHttpHandler client 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 public TimeProvider? Time { get; init; } // tests pass a fake clock for polling and token expiry

View File

@@ -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)."); CommandReply.Send(api, ctx, Name, $"/{Name} needs an interactive UI (a chat front end such as the Telegram bot).");
return; return;
} }
var connection = extension.ConnectionFor(ctx.SessionManager.Scope); var connection = extension.ConnectionFor(ctx);
var flow = await ConnectFlow.StartOrResendAsync(connection, ctx.Ui, ct).ConfigureAwait(false); 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); 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, _) => 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)); CommandReply.Send(api, ctx, Name, Describe(connection));
return Task.CompletedTask; return Task.CompletedTask;
}); });
@@ -67,7 +67,7 @@ internal static class DisconnectCommand
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) => 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; string? revokeError;
// The gate a completing connect flow writes its token under: after CancelPending it can no longer write. // The gate a completing connect flow writes its token under: after CancelPending it can no longer write.
await connection.FlowGate.WaitAsync(ct).ConfigureAwait(false); await connection.FlowGate.WaitAsync(ct).ConfigureAwait(false);

View File

@@ -61,7 +61,7 @@ internal sealed partial class CalendarTool(GoogleCalendarSettings settings, Goog
protected override async Task<ToolResult> ExecuteAsync(CalendarArgs args, ToolInvocation invocation, CancellationToken cancellationToken) protected override async Task<ToolResult> ExecuteAsync(CalendarArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{ {
var ctx = invocation.Context; 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 resolved = connection.ResolveTimeZone(ctx.TimeZone, ctx.Logger);
var eventZone = args.TimeZone is { Length: > 0 } requested 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.") ? ZoneIds.TryFind(requested, out var z) ? z : throw new ArgumentException($"Unknown time zone \"{requested}\". Use an IANA id such as Europe/Berlin.")

View File

@@ -13,16 +13,21 @@ public sealed class GoogleCalendarConnections
/// <summary>The process-wide instance the extension uses unless its options pass another.</summary> /// <summary>The process-wide instance the extension uses unless its options pass another.</summary>
public static GoogleCalendarConnections Shared { get; } = new(); public static GoogleCalendarConnections Shared { get; } = new();
/// <summary>Where a chat's token lives inside its workspace when no token root is configured.</summary>
public static readonly string WorkspaceTokenDirectory = Path.Combine(".volcanic", "google-calendar");
private readonly ConcurrentDictionary<string, GoogleCalendarConnection> _connections = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, GoogleCalendarConnection> _connections = new(StringComparer.Ordinal);
/// <summary> /// <summary>
/// The connection for a session scope, keyed by its token directory (<c>&lt;root&gt;/&lt;chat key&gt;</c>). /// The connection for a chat, keyed by its token directory: <c>&lt;workspace&gt;/.volcanic/google-calendar</c>, or
/// The first caller's settings create it. /// <c>&lt;root&gt;/&lt;chat key&gt;</c> when the options set a token root. The first caller's settings create it.
/// </summary> /// </summary>
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope) internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope, string workspace)
{ {
ArgumentNullException.ThrowIfNull(settings); 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)))); return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings, new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName))));
} }
} }

View File

@@ -17,14 +17,17 @@ public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = nul
private GoogleCalendarSettings? _settings; private GoogleCalendarSettings? _settings;
private ILogger? _logger; private ILogger? _logger;
private volatile string? _scope; private volatile string? _scope;
private volatile string? _cwd;
private volatile TimeZoneInfo? _chatZone; private volatile TimeZoneInfo? _chatZone;
public string Name => ExtensionName; public string Name => ExtensionName;
internal GoogleCalendarConnections Connections => _options.Connections ?? GoogleCalendarConnections.Shared; internal GoogleCalendarConnections Connections => _options.Connections ?? GoogleCalendarConnections.Shared;
internal GoogleCalendarConnection ConnectionFor(string scope) => internal GoogleCalendarConnection ConnectionFor(IExtensionContext ctx) => ConnectionFor(ctx.SessionManager.Scope, ctx.Cwd);
Connections.For(_settings ?? throw new InvalidOperationException("google-calendar is not configured."), scope);
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) public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{ {
@@ -54,13 +57,14 @@ public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = nul
/// <summary>The zone the tool's description and guideline name: the calendar's, else the chat's. Null before the session starts.</summary> /// <summary>The zone the tool's description and guideline name: the calendar's, else the chat's. Null before the session starts.</summary>
internal string? CurrentZoneName() => internal string? CurrentZoneName() =>
_scope is { } scope && _settings is not null _scope is { } scope && _cwd is { } cwd && _settings is not null
? ZoneIds.Name(ConnectionFor(scope).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger)) ? ZoneIds.Name(ConnectionFor(scope, cwd).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger))
: null; : null;
private ValueTask Track(IExtensionContext ctx) private ValueTask Track(IExtensionContext ctx)
{ {
_chatZone = ctx.TimeZone; _chatZone = ctx.TimeZone;
_cwd = ctx.Cwd;
_scope = ctx.SessionManager.Scope; _scope = ctx.SessionManager.Scope;
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }

View File

@@ -10,7 +10,10 @@ public sealed record GoogleCalendarOptions
public string? ClientId { get; init; } public string? ClientId { get; init; }
/// <summary>OAuth client secret. Else <c>GOOGLE_CLIENT_SECRET</c>.</summary> /// <summary>OAuth client secret. Else <c>GOOGLE_CLIENT_SECRET</c>.</summary>
public string? ClientSecret { get; init; } public string? ClientSecret { get; init; }
/// <summary>Root of the per-chat token directories. Else <c>&lt;agentDir&gt;/google-calendar</c>.</summary> /// <summary>
/// Root of per-chat token directories (<c>&lt;root&gt;/&lt;chat key&gt;/token.json</c>). Unset (the default): each chat's token
/// lives in its workspace, <c>&lt;cwd&gt;/.volcanic/google-calendar/token.json</c>.
/// </summary>
public string? TokenDirectory { get; init; } public string? TokenDirectory { get; init; }
/// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary> /// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary>
public Func<string, string?>? GetEnv { get; init; } public Func<string, string?>? GetEnv { get; init; }
@@ -29,9 +32,8 @@ public sealed record GoogleCalendarOptions
/// <summary>True when both the client id and the secret are set (here or in the environment): the extension registers its tool and commands.</summary> /// <summary>True when both the client id and the secret are set (here or in the environment): the extension registers its tool and commands.</summary>
public bool IsConfigured => EffectiveClientId is not null && EffectiveClientSecret is not null; public bool IsConfigured => EffectiveClientId is not null && EffectiveClientSecret is not null;
internal string EffectiveTokenDirectory => NonEmpty(TokenDirectory) ?? Path.Combine( /// <summary>The configured token root, or null to keep each chat's token in its workspace.</summary>
NonEmpty(Env("VOLCANIC_AGENT_DIR")) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent"), internal string? EffectiveTokenDirectory => NonEmpty(TokenDirectory);
"google-calendar");
/// <summary>The settings a connection needs, or null when the client id or secret is missing.</summary> /// <summary>The settings a connection needs, or null when the client id or secret is missing.</summary>
internal GoogleCalendarSettings? Resolve() => internal GoogleCalendarSettings? Resolve() =>
@@ -46,7 +48,7 @@ public sealed record GoogleCalendarOptions
} }
/// <summary>Resolved, validated configuration shared by every chat's connection.</summary> /// <summary>Resolved, validated configuration shared by every chat's connection.</summary>
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} }}"; public override string ToString() => $"GoogleCalendarSettings {{ ClientId = {ClientId}, TokenRoot = {TokenRoot} }}";
} }

View File

@@ -28,29 +28,43 @@ public class GoogleCalendarConnectionTests : IDisposable
private GoogleCalendarConnection Connected(string scope = "telegram-1", string refreshToken = "rt-1", string? zone = null) 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)); connection.Store.Write(new StoredToken(refreshToken, TimeZone: zone));
return connection; return connection;
} }
private int Refreshes => _http.To("POST /token").Count; 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] [Fact]
public void Two_scopes_get_two_token_files_under_their_own_directories() public void Two_scopes_get_two_token_files_under_their_own_directories()
{ {
var a = _connections.For(_settings, "telegram-1"); var a = _connections.For(_settings, "telegram-1", _root);
var b = _connections.For(_settings, "telegram-2"); 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-1", "token.json"), a.Store.Path);
Assert.Equal(Path.Combine(_root, "telegram-2", "token.json"), b.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.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] [Fact]
public async Task Not_connected_throws_the_connect_hint() 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.True(ex.NotConnected);
Assert.Equal("Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.", ex.Message); Assert.Equal("Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.", ex.Message);
Assert.Empty(_http.Requests); Assert.Empty(_http.Requests);
@@ -137,7 +151,7 @@ public class GoogleCalendarConnectionTests : IDisposable
public void Resolved_zone_falls_back_to_the_chat_zone() public void Resolved_zone_falls_back_to_the_chat_zone()
{ {
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); 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)); Assert.Same(berlin, Connected(zone: null).ResolveTimeZone(berlin));
} }

View File

@@ -54,13 +54,13 @@ public class GoogleCalendarOptionsTests
} }
[Fact] [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"); var agentDir = Path.Combine(Path.GetTempPath(), "agent-dir");
Assert.Equal(Path.Combine(agentDir, "google-calendar"), Assert.Null(new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory); Assert.Null(new GoogleCalendarOptions { TokenDirectory = " ", GetEnv = _ => null }.EffectiveTokenDirectory);
Assert.Equal(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent", "google-calendar"), Assert.Null(new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "csecret", GetEnv = _ => null }.Resolve()!.TokenRoot);
new GoogleCalendarOptions { GetEnv = _ => null }.EffectiveTokenDirectory);
} }
[Fact] [Fact]

View File

@@ -28,6 +28,20 @@ public class PerChatTests
Assert.False(Directory.Exists(Path.Combine(h.TokenRoot, ConnectHarness.ScopeB))); 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] [Fact]
public async Task Concurrent_flows_in_two_chats_each_end_with_their_own_store_and_notify() public async Task Concurrent_flows_in_two_chats_each_end_with_their_own_store_and_notify()
{ {

View File

@@ -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> /// <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 string SessionId => "session-1";
public bool HasUi => ui is not null; public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance; public IUserInterface Ui => ui ?? NullUserInterface.Instance;
@@ -159,13 +159,14 @@ internal sealed class ConnectHarness : IDisposable
private readonly string _root = Directory.CreateTempSubdirectory("gcal-connect-").FullName; 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); Http = new GoogleFakeHttp(Time);
Api = new CalendarFakeApi(logger); Api = new CalendarFakeApi(logger);
Extension = new GoogleCalendarExtension(new GoogleCalendarOptions 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, Http = Http.CreateClient(), Time = Time, Connections = Connections, GetEnv = _ => null,
}); });
Extension.ActivateAsync(Api, CancellationToken.None).AsTask().GetAwaiter().GetResult(); 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") => 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"}"""); (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); public Task Run(string command, CalendarFakeContext ctx) => Api.Commands[command].Handler("", ctx, CancellationToken.None);

View File

@@ -29,7 +29,7 @@ internal sealed class CalendarToolHarness : IDisposable
public GoogleFakeHttp Http { get; } public GoogleFakeHttp Http { get; }
public GoogleCalendarSettings Settings { get; } public GoogleCalendarSettings Settings { get; }
public GoogleCalendarConnections Connections { get; } = new(); 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 TimeZoneInfo ChatZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public CalendarTool Tool { get; } public CalendarTool Tool { get; }