feat(claude-code): pass the session time zone to claude -p as TZ
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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__<name>__<alias></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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user