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

@@ -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;
}
}
}