forked from admin/VolcanicAgenticCore
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.
209 lines
9.6 KiB
C#
209 lines
9.6 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Volcanic.AgenticCore.Abstractions.Extensions;
|
|
using Volcanic.AgenticCore.Abstractions.Processes;
|
|
using Volcanic.AgenticCore.Abstractions.Providers;
|
|
using Volcanic.AgenticCore.Abstractions.Sessions;
|
|
using Volcanic.AgenticCore.Abstractions.Ui;
|
|
using Volcanic.AgenticCore.Extensions.Scheduler;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
|
|
|
|
/// <summary>Records every delivery call; can hold sends and prompt runs open, fail sends, and switch a target's zone.</summary>
|
|
internal sealed class FakeJobDelivery : IJobDelivery
|
|
{
|
|
private readonly Lock _lock = new();
|
|
private readonly List<(string Target, string Text)> _texts = [];
|
|
private readonly List<(string Target, string Prompt)> _prompts = [];
|
|
private readonly List<string> _calls = [];
|
|
|
|
public Dictionary<string, TimeZoneInfo> Zones { get; } = new();
|
|
public TimeZoneInfo DefaultZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
|
public HashSet<string> Undeliverable { get; } = [];
|
|
/// <summary>Targets whose <see cref="TimeZoneFor"/> throws (a store entry the host can't map).</summary>
|
|
public HashSet<string> BadTargets { get; } = [];
|
|
public TaskCompletionSource? HoldTexts { get; set; }
|
|
public TaskCompletionSource? HoldPrompts { get; set; }
|
|
public Exception? FailSends { get; set; }
|
|
public string WorkspaceRoot { get; set; } = Path.Combine(Path.GetTempPath(), "fake-workspaces");
|
|
|
|
public IReadOnlyList<(string Target, string Text)> Texts { get { lock (_lock) return _texts.ToList(); } }
|
|
public IReadOnlyList<(string Target, string Prompt)> Prompts { get { lock (_lock) return _prompts.ToList(); } }
|
|
/// <summary>"SendText:111", "RunPrompt:111", "WorkspaceFor:111", "TimeZoneFor:111", "IsDeliverable:111".</summary>
|
|
public IReadOnlyList<string> Calls { get { lock (_lock) return _calls.ToList(); } }
|
|
|
|
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock) { _calls.Add("SendText:" + target); _texts.Add((target, markdown)); }
|
|
if (HoldTexts is { } hold) await hold.Task.WaitAsync(cancellationToken);
|
|
if (FailSends is { } error) throw error;
|
|
}
|
|
|
|
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock) { _calls.Add("RunPrompt:" + target); _prompts.Add((target, prompt)); }
|
|
if (HoldPrompts is { } hold) await hold.Task.WaitAsync(cancellationToken);
|
|
if (FailSends is { } error) throw error;
|
|
}
|
|
|
|
public string WorkspaceFor(string target)
|
|
{
|
|
lock (_lock) _calls.Add("WorkspaceFor:" + target);
|
|
return Path.Combine(WorkspaceRoot, target);
|
|
}
|
|
|
|
public IEnumerable<string> KnownTargets() =>
|
|
Directory.Exists(WorkspaceRoot) ? Directory.EnumerateDirectories(WorkspaceRoot).Select(d => Path.GetFileName(d)) : [];
|
|
|
|
public bool IsDeliverable(string target)
|
|
{
|
|
lock (_lock) _calls.Add("IsDeliverable:" + target);
|
|
return !Undeliverable.Contains(target);
|
|
}
|
|
|
|
public TimeZoneInfo TimeZoneFor(string target)
|
|
{
|
|
lock (_lock) _calls.Add("TimeZoneFor:" + target);
|
|
if (BadTargets.Contains(target)) throw new FormatException($"bad target {target}");
|
|
return Zones.GetValueOrDefault(target, DefaultZone);
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns queued outcomes (then <see cref="Fallback"/>); can hold runs open, honouring or ignoring cancellation.</summary>
|
|
internal sealed class FakeScriptRunner : IScriptRunner
|
|
{
|
|
private readonly Lock _lock = new();
|
|
private readonly Queue<ScriptOutcome> _outcomes = new();
|
|
private readonly List<ScriptRequest> _requests = [];
|
|
private int _cancellations;
|
|
|
|
public ScriptOutcome Fallback { get; set; } = new ScriptOutcome.Silent();
|
|
public TaskCompletionSource? Hold { get; set; }
|
|
public bool IgnoreCancellation { get; set; }
|
|
public IReadOnlyList<ScriptRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
|
|
public int CancellationsSeen => Volatile.Read(ref _cancellations);
|
|
|
|
public FakeScriptRunner Then(params ScriptOutcome[] outcomes)
|
|
{
|
|
lock (_lock) foreach (var o in outcomes) _outcomes.Enqueue(o);
|
|
return this;
|
|
}
|
|
|
|
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
|
|
{
|
|
ScriptOutcome outcome;
|
|
lock (_lock)
|
|
{
|
|
_requests.Add(request);
|
|
outcome = _outcomes.Count > 0 ? _outcomes.Dequeue() : Fallback;
|
|
}
|
|
if (Hold is { } hold)
|
|
{
|
|
if (IgnoreCancellation)
|
|
{
|
|
using var _ = cancellationToken.Register(() => Interlocked.Increment(ref _cancellations));
|
|
await hold.Task;
|
|
}
|
|
else
|
|
{
|
|
try { await hold.Task.WaitAsync(cancellationToken); }
|
|
catch (OperationCanceledException) { Interlocked.Increment(ref _cancellations); throw; }
|
|
}
|
|
}
|
|
return outcome;
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns <see cref="Variables"/> (or throws <see cref="Fail"/>) and records each call.</summary>
|
|
internal sealed class FakeScriptEnvironment : IScriptEnvironment
|
|
{
|
|
private readonly List<(string Target, TimeSpan Timeout)> _calls = [];
|
|
public Dictionary<string, string> Variables { get; } = new();
|
|
public Exception? Fail { get; set; }
|
|
public IReadOnlyList<(string Target, TimeSpan Timeout)> Calls { get { lock (_calls) return _calls.ToList(); } }
|
|
|
|
public Task<IReadOnlyDictionary<string, string>> ForRunAsync(string target, TimeSpan timeout, CancellationToken cancellationToken)
|
|
{
|
|
lock (_calls) _calls.Add((target, timeout));
|
|
if (Fail is { } error) throw error;
|
|
return Task.FromResult<IReadOnlyDictionary<string, string>>(new Dictionary<string, string>(Variables));
|
|
}
|
|
}
|
|
|
|
internal sealed class FakeRedactor(params string[] secrets) : ISecretRedactor
|
|
{
|
|
public string Redact(string text) => secrets.Aggregate(text, (t, s) => t.Replace(s, "[REDACTED]", StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>A <see cref="JobScheduler"/> on a <see cref="FakeTimeProvider"/> at Sat 2026-09-26 14:03 Berlin, a temp store and the fakes.</summary>
|
|
internal sealed class SchedulerHarness : IAsyncDisposable
|
|
{
|
|
public static readonly DateTimeOffset Start = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero);
|
|
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
|
public static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
|
|
|
|
public SchedulerHarness(DateTimeOffset? start = null, Action<SchedulerOptions>? configure = null, IScriptEnvironment? environment = null)
|
|
{
|
|
Time = new FakeTimeProvider(start ?? Start);
|
|
Options = new SchedulerOptions { LegacyStorePath = Path.Combine(Root, "scheduler", "jobs.json") };
|
|
configure?.Invoke(Options);
|
|
Delivery.WorkspaceRoot = Path.Combine(Root, "workspaces");
|
|
Scheduler = new JobScheduler(Options, Runner, Redactor, Time, NullLogger<JobScheduler>.Instance, environment);
|
|
}
|
|
|
|
public string Root { get; } = Directory.CreateTempSubdirectory("scheduler-").FullName;
|
|
public FakeTimeProvider Time { get; }
|
|
public SchedulerOptions Options { get; }
|
|
public FakeJobDelivery Delivery { get; } = new();
|
|
public FakeScriptRunner Runner { get; } = new();
|
|
public FakeRedactor Redactor { get; } = new("hunter2-secret");
|
|
public JobScheduler Scheduler { get; }
|
|
/// <summary>Target 111's file.</summary>
|
|
public JobStore Store => StoreFor("111");
|
|
public JobStore StoreFor(string target) => new(JobStore.PathIn(Path.Combine(Delivery.WorkspaceRoot, target)), Time, NullLogger.Instance, target);
|
|
/// <summary>The retired shared file.</summary>
|
|
public JobStore LegacyStore => new(Options.LegacyStorePath!, Time, NullLogger.Instance);
|
|
|
|
public Task StartAsync() => Scheduler.StartAsync(Delivery, CancellationToken.None);
|
|
|
|
public Task<JobView> CreateAsync(NewJob job, string target = "111") => Scheduler.CreateAsync(target, job, CancellationToken.None);
|
|
|
|
/// <summary>Advance the clock and wait for the tick and every fire it started (don't use while a fire is held open).</summary>
|
|
public async Task AdvanceAsync(TimeSpan by)
|
|
{
|
|
Time.Advance(by);
|
|
await Scheduler.IdleAsync();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
Runner.Hold?.TrySetResult();
|
|
Delivery.HoldTexts?.TrySetResult();
|
|
Delivery.HoldPrompts?.TrySetResult();
|
|
await Scheduler.StopAsync(CancellationToken.None);
|
|
try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
|
|
}
|
|
}
|
|
|
|
/// <summary>The context a tool gets; the schedule tool reads nothing from it (the zone comes from the scheduler).</summary>
|
|
internal sealed class SchedulerStubContext : IExtensionContext
|
|
{
|
|
public string Cwd => Path.GetTempPath();
|
|
public string SessionId => "scheduler-test";
|
|
public bool HasUi => false;
|
|
public IUserInterface Ui => NullUserInterface.Instance;
|
|
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
|
|
public IModelRegistry ModelRegistry => throw new NotSupportedException();
|
|
public Model? Model => null;
|
|
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
|
|
public CancellationToken Signal => CancellationToken.None;
|
|
public ILogger Logger => NullLogger.Instance;
|
|
public bool IsIdle() => true;
|
|
public void Abort() { }
|
|
public bool HasPendingMessages() => false;
|
|
public ContextUsage? GetContextUsage() => null;
|
|
public string GetSystemPrompt() => "";
|
|
public void Compact(CompactOptions? options = null) { }
|
|
}
|