feat(claude-code): pass the session time zone to claude -p as TZ

This commit is contained in:
EugeneTes
2026-09-27 10:03:25 +02:00
parent 64e67b00d7
commit 96a6a5518b
7 changed files with 186 additions and 6 deletions

View File

@@ -37,7 +37,9 @@ conversation is replayed each time.
`--strict-mcp-config --mcp-config <bridge>`, `--input-format stream-json --output-format stream-json
--include-partial-messages --verbose`, `--system-prompt-file <tmp>`, `--model <id>`, `--effort <level>`,
plus settings that disable hooks, auto-memory, plugins, slash commands and the token reminder (which would
defeat prompt caching). Only an allow-list of environment variables is passed through, and compaction
defeat prompt caching). Only an allow-list of environment variables is passed through, plus `TZ` set to
the session's time zone (the chat's zone on Telegram, read per call so a `/timezone` change applies from
the next model call; never the host's own `TZ`, and not on Windows, which ignores it), and compaction
inside Claude is disabled: the core owns compaction. `claude` and its login/doctor processes don't go
through the host's process spawn policy (`AgenticCoreOptions.ProcessEnvironment`): they already get a
replaced, allow-listed environment. The Bridge inherits `claude`'s environment, and the core tools it

View File

@@ -40,6 +40,9 @@ public static class ClaudeArgs
public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY";
public const string MaxOutputTokensVariable = "CLAUDE_CODE_MAX_OUTPUT_TOKENS";
/// <summary>The session's zone for the child (never inherited from the parent: <c>TZ</c> isn't on the allow-list).</summary>
public const string TimeZoneVariable = "TZ";
private static readonly JsonSerializerOptions ConfigOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
/// <summary>Variables forwarded from the parent environment (compared case-insensitively). <c>LC_*</c> is forwarded as a prefix.</summary>
@@ -143,16 +146,28 @@ public static class ClaudeArgs
}
/// <summary>
/// The complete environment a Claude child receives: the allow-listed parent variables plus the pinned ones.
/// The complete environment a Claude child receives: the allow-listed parent variables, the pinned ones, and
/// <c>TZ</c> = <paramref name="timeZoneId"/> (the session's zone; not on Windows, which ignores it).
/// Throws <c>auth</c> when the parent process carries API-key or third-party-provider authentication.
/// </summary>
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens)
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens, string? timeZoneId = null)
{
ArgumentNullException.ThrowIfNull(parentEnv);
if (FindDeniedVariable(parentEnv) is { } denied)
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
$"{denied} is set; the Claude Code provider only supports subscription authentication and never forwards API-key or third-party credentials. Unset it to use this provider");
return AllowListed(parentEnv, maxOutputTokens);
var env = new Dictionary<string, string>(AllowListed(parentEnv, maxOutputTokens), StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(timeZoneId) && !OperatingSystem.IsWindows()) env[TimeZoneVariable] = timeZoneId;
return env;
}
/// <summary>The IANA id for <c>TZ</c>: the zone's own id, a converted Windows id, or null for an unnamed <c>Local</c> zone.</summary>
public static string? IanaZoneId(TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
if (zone.HasIanaId) return zone.Id;
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
}
/// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>

View File

@@ -28,6 +28,13 @@ public sealed record ClaudeCodeApiOptions
/// <summary>The environment the allow-list is applied to; the process environment when null.</summary>
public IReadOnlyDictionary<string, string?>? ParentEnvironment { get; init; }
/// <summary>
/// The IANA zone for the child's <c>TZ</c>, read per request (the extension reads the session's
/// <c>IExtensionContext.TimeZone</c>, so <c>SetTimeZone</c> applies from the next model call). Null or a null result
/// leaves <c>TZ</c> unset.
/// </summary>
public Func<string?>? TimeZoneId { get; init; }
/// <summary>The MCP server name the bridge is registered under (tools become <c>mcp__&lt;name&gt;__&lt;alias&gt;</c>).</summary>
public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
@@ -250,7 +257,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None);
var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName));
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens);
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens, Options.TimeZoneId?.Invoke());
if (streamOptions.OnPayload is not null)
{

View File

@@ -39,6 +39,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
private readonly ClaudeCodeProviderOptions _options = options ?? new ClaudeCodeProviderOptions();
private string? _ownSessionId;
private IExtensionContext? _context;
public string Name => ExtensionName;
@@ -60,6 +61,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
LoginUi = _options.LoginUi,
Coordinator = _options.Coordinator,
LoginOptions = _options.LoginOptions,
TimeZoneId = CurrentZoneId,
};
var claude = new ClaudeCodeApi(registry, apiOptions);
Api = claude;
@@ -83,6 +85,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
// so the second registration does not orphan the first.
if (_ownSessionId is not null && _ownSessionId != ctx.SessionId) registry.Remove(_ownSessionId);
_ownSessionId = ctx.SessionId;
_context = ctx;
registry.Register(ctx.SessionId, ctx.Cwd, ctx.HasUi ? ctx.Ui.Notify : null);
if (apiOptions.ClaudePath is null && ctx.HasUi)
ctx.Ui.Notify($"[claude-code] Claude Code (claude) was not found; run /{DoctorCommand.Name} for details", NotifyKind.Warning);
@@ -92,7 +95,11 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
api.On<SessionShutdownEvent>((_, ctx, _) =>
{
registry.Remove(ctx.SessionId);
if (_ownSessionId == ctx.SessionId) _ownSessionId = null;
if (_ownSessionId == ctx.SessionId)
{
_ownSessionId = null;
_context = null;
}
return ValueTask.CompletedTask;
});
@@ -109,4 +116,17 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
return ValueTask.CompletedTask;
}
/// <summary>The session's zone as an IANA id, or null without a live session (e.g. a context gone stale after dispose).</summary>
private string? CurrentZoneId()
{
try
{
return _context is { } ctx ? ClaudeArgs.IanaZoneId(ctx.TimeZone) : null;
}
catch (Exception)
{
return null;
}
}
}

View File

@@ -128,4 +128,37 @@ public class ClaudeArgsTests
Assert.Contains(name, ex.Message);
Assert.Equal(name, ClaudeArgs.FindDeniedVariable(parent));
}
[Fact]
public void BuildEnvironment_sets_TZ_from_the_zone_id()
{
if (OperatingSystem.IsWindows()) return; // Windows ignores TZ; see BuildEnvironment_never_sets_TZ_on_Windows
var env = ClaudeArgs.BuildEnvironment(new Dictionary<string, string?> { ["PATH"] = "/usr/bin" }, 1000, "Asia/Tokyo");
Assert.Equal("Asia/Tokyo", env["TZ"]);
}
[Fact]
public void BuildEnvironment_without_a_zone_leaves_TZ_unset_and_never_copies_the_parents()
{
var parent = new Dictionary<string, string?> { ["PATH"] = "/usr/bin", ["TZ"] = "America/Chicago" };
Assert.False(ClaudeArgs.BuildEnvironment(parent, 1000).ContainsKey("TZ"));
Assert.False(ClaudeArgs.AllowListed(parent, null).ContainsKey("TZ"));
var withZone = ClaudeArgs.BuildEnvironment(parent, 1000, "Asia/Tokyo");
Assert.NotEqual("America/Chicago", withZone.GetValueOrDefault("TZ"));
}
[Fact]
public void BuildEnvironment_never_sets_TZ_on_Windows()
{
if (!OperatingSystem.IsWindows()) return;
Assert.False(ClaudeArgs.BuildEnvironment(new Dictionary<string, string?>(), 1000, "Asia/Tokyo").ContainsKey("TZ"));
}
[Fact]
public void IanaZoneId_converts_and_rejects_Local()
{
Assert.Equal("Asia/Tokyo", ClaudeArgs.IanaZoneId(TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo")));
Assert.Equal("Asia/Tokyo", ClaudeArgs.IanaZoneId(TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time")));
Assert.Null(ClaudeArgs.IanaZoneId(TimeZoneInfo.CreateCustomTimeZone("Local", TimeSpan.FromHours(1), "Local", "Local")));
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.DependencyInjection;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
/// <summary>The session's zone reaches <c>claude -p</c> as <c>TZ</c>, per request. No test spawns the real CLI.</summary>
[Collection(FakeScripts.Collection)]
public sealed class ClaudeCodeTimeZoneTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-tz-test-").FullName;
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
/// <summary>Appends $TZ to tz.log, drains stdin and prints a complete text-only turn.</summary>
private string FakeClaude()
{
var lines = new[]
{
Fixtures.Init().GetRawText(),
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_t\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":7}}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}",
"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":7,\"output_tokens\":1}}",
};
var fixture = Path.Combine(_root, "text.jsonl");
File.WriteAllLines(fixture, lines);
var script = Path.Combine(_root, "fake-claude.sh");
File.WriteAllText(script, "#!/bin/sh\n" +
$"printf '%s\\n' \"${{TZ-unset}}\" >> \"{Path.Combine(_root, "tz.log")}\"\n" +
"cat > /dev/null\n" +
$"cat \"{fixture}\"\n");
File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
[Fact]
public async Task The_claude_child_gets_the_session_zone_and_a_new_one_after_SetTimeZone()
{
if (!Fixtures.IsPosix) return;
var services = new ServiceCollection();
services.AddSingleton(new ClaudeCodeProviderOptions
{
ClaudePath = FakeClaude(),
GetEnv = _ => null,
Registry = new SessionRegistry(),
Coordinator = new ClaudeLoginCoordinator(),
ServerName = Fixtures.ServerName, // the name the recorded init fixtures use, as in ClaudeCodeApiTests
});
services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(_root, "agent");
o.DiscoverExtensionAssemblies = false;
o.IncludeBuiltInTools = false; // no tools: the fake needs no proposal bridge
o.DefaultModel = "claude-code/sonnet";
o.SessionStoreFactory = _ => new InMemorySessionStore();
o.ProcessEnvironment.HardenHostProcess = false;
o.AddExtension<ClaudeCodeProviderExtension>();
});
await using var provider = services.BuildServiceProvider();
var cwd = Directory.CreateDirectory(Path.Combine(_root, "work")).FullName;
await using var session = await provider.GetRequiredService<IAgentSessionFactory>().CreateAsync(new AgentSessionOptions
{
Cwd = cwd,
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo"),
});
await session.PromptAsync("hi");
session.SetTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"));
await session.PromptAsync("again");
Assert.Equal(["Asia/Tokyo", "Europe/Berlin"], File.ReadAllLines(Path.Combine(_root, "tz.log")));
}
}

View File

@@ -60,6 +60,7 @@ public class ProviderExtensionTests
{
public string Cwd => cwd;
public string SessionId => sessionId;
public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Local;
public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
@@ -189,4 +190,24 @@ public class ProviderExtensionTests
var notice = Assert.Single(ui.Notices);
Assert.Equal(NotifyKind.Warning, notice.Kind);
}
[Fact]
public async Task Time_zone_id_follows_the_session_context_per_request()
{
var api = new FakeExtensionApi();
var extension = new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = null, Registry = new SessionRegistry(), GetEnv = _ => null });
await extension.ActivateAsync(api, CancellationToken.None);
var zoneId = extension.Api!.Options.TimeZoneId!;
Assert.Null(zoneId()); // no session yet
var ctx = new FakeContext(Path.GetTempPath(), "session-1") { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") };
await api.Raise(new SessionStartEvent(SessionStartReason.Startup), ctx);
Assert.Equal("Asia/Tokyo", zoneId());
ctx.TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); // SetTimeZone on the session
Assert.Equal("Europe/Berlin", zoneId());
await api.Raise(new SessionShutdownEvent(SessionShutdownReason.Quit), ctx);
Assert.Null(zoneId());
}
}