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