fix(claude-code): bound the login flow and cover the held-Start path

This commit is contained in:
EugeneTes
2026-09-25 17:32:41 +00:00
parent c7edc1d802
commit d43fa52e4a
5 changed files with 248 additions and 10 deletions

View File

@@ -121,9 +121,15 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
public async Task<LoginOutcome> RunLoginFlowAsync(Func<CancellationToken, Task<IUserInterface?>> loginUi, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(loginUi);
if (string.IsNullOrEmpty(Options.ClaudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
var ui = await loginUi(ct).ConfigureAwait(false);
if (ui is null) return LoginOutcome.Failed("the owner could not be reached; the first allowed user must start a chat with the bot");
const string unreachable = "the owner could not be reached; the first allowed user must start a chat with the bot";
IUserInterface? ui;
using (var resolve = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
resolve.CancelAfter((Options.LoginOptions ?? new ClaudeLoginOptions()).OwnerTimeout);
try { ui = await loginUi(resolve.Token).WaitAsync(resolve.Token).ConfigureAwait(false); }
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { ui = null; }
}
if (ui is null) return LoginOutcome.Failed(unreachable);
return await LoginThroughAsync(ui, ct).ConfigureAwait(false);
}
@@ -131,9 +137,10 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
public async Task<LoginOutcome> LoginThroughAsync(IUserInterface ui, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ui);
if (string.IsNullOrEmpty(Options.ClaudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
var claudePath = Options.ClaudePath;
if (string.IsNullOrEmpty(claudePath) || !File.Exists(claudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
var env = ClaudeArgs.AllowListed(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens: null);
var outcome = await ClaudeLogin.RunAsync(Options.ClaudePath, env, new UiLoginPrompt(ui), Options.LoginOptions, ct).ConfigureAwait(false);
var outcome = await ClaudeLogin.RunAsync(claudePath, env, new UiLoginPrompt(ui), Options.LoginOptions, ct).ConfigureAwait(false);
if (outcome.Success) ui.Notify("Claude Code login complete ✓");
else ui.Notify($"Claude Code login failed: {outcome.Error}", NotifyKind.Error);
return outcome;
@@ -162,9 +169,19 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
var terminatedForHandoff = false;
var responseAnnounced = false;
var wroteAny = false;
var responsePending = false;
AssistantMessageEvent? heldStart = null;
// A deferring attempt holds the init Start back until content follows, so a replay never publishes two Starts.
// After-response observers run once per published message, ahead of its Start.
async ValueTask AnnounceResponseAsync()
{
if (!responsePending) return;
responsePending = false;
if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false);
}
// A deferring attempt holds the init Start (and the response announcement) back until something else is published,
// so a replay never publishes two Starts and a dropped attempt is never announced.
async ValueTask PublishAsync(AssistantMessageEvent e)
{
if (deferLoginFailure && !wroteAny && heldStart is null && e is AssistantMessageEvent.Start)
@@ -172,6 +189,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
heldStart = e;
return;
}
await AnnounceResponseAsync().ConfigureAwait(false);
if (heldStart is { } held)
{
heldStart = null;
@@ -270,7 +288,8 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
// A validated initialization is this transport's analogue of a received response; announce it before
// any content so after-response observers run ahead of the Start event.
responseAnnounced = true;
if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false);
responsePending = true;
if (!deferLoginFailure) await AnnounceResponseAsync().ConfigureAwait(false);
}
foreach (var e in events) await PublishAsync(e).ConfigureAwait(false);
if (mapper.HandoffLatched && !terminatedForHandoff)
@@ -295,12 +314,15 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
if (mapper.HandoffLatched)
{
// The provider terminated Claude on purpose: 143 (SIGTERM), 137 (SIGKILL), or whatever a late exit reports.
await AnnounceResponseAsync().ConfigureAwait(false);
done = mapper.Complete();
}
else if (mapper.HasSuccessfulResult)
{
if (exitCode != 0)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit, $"Claude Code exited after a successful result (code {exitCode}){StderrDetail(process, tempDir)}");
// Announced before Complete finishes the builder, so a throwing observer still gets a failure published.
await AnnounceResponseAsync().ConfigureAwait(false);
done = mapper.Complete();
}
else if (mapper.AuthenticationFailed)
@@ -325,6 +347,11 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
var aborted = ct.IsCancellationRequested || ex is OperationCanceledException;
if (process is not null) await process.TerminateAsync().ConfigureAwait(false);
if (deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login) return login;
if (responsePending)
{
// The request is already failing; an observer error must not keep the failure from being published.
try { await AnnounceResponseAsync().ConfigureAwait(false); } catch (Exception) { /* already failing */ }
}
var failure = builder.IsFinished
? null
: aborted ? builder.Fail("aborted", aborted: true) : builder.Fail(FormatFailure(ex));

View File

@@ -58,7 +58,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
GetEnv = getEnv,
ServerName = _options.ServerName,
LoginUi = _options.LoginUi,
Coordinator = _options.Coordinator ?? ClaudeLoginCoordinator.Shared,
Coordinator = _options.Coordinator,
LoginOptions = _options.LoginOptions,
};
var claude = new ClaudeCodeApi(registry, apiOptions);

View File

@@ -30,6 +30,12 @@ public sealed record ClaudeLoginOptions
public TimeSpan UrlTimeout { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan ExchangeTimeout { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan StatusTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>How long the person has to paste a code; an unanswered prompt then counts as no code.</summary>
public TimeSpan CodeTimeout { get; init; } = TimeSpan.FromMinutes(10);
/// <summary>How long resolving where the owner is reached may take before the owner counts as unreachable.</summary>
public TimeSpan OwnerTimeout { get; init; } = TimeSpan.FromSeconds(30);
}
/// <summary>
@@ -83,7 +89,14 @@ public static partial class ClaudeLogin
return LoginOutcome.Failed($"claude auth login printed no login URL{Excerpt(stderr)}");
}
var code = (await prompt.AskForCodeAsync(url, attempt, ct).ConfigureAwait(false))?.Trim();
string? code;
using (var answer = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
answer.CancelAfter(options.CodeTimeout);
// WaitAsync bounds a prompt that ignores its token: the coordinator slot and this child must not outlive it.
try { code = (await prompt.AskForCodeAsync(url, attempt, answer.Token).WaitAsync(answer.Token).ConfigureAwait(false))?.Trim(); }
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { code = null; }
}
if (string.IsNullOrEmpty(code))
{
Kill(process);

View File

@@ -369,4 +369,171 @@ public class ClaudeCodeApiTests : IDisposable
Assert.StartsWith("[auth]", error.Message.ErrorMessage);
Assert.Contains("claude auth login", error.Message.ErrorMessage);
}
// ---- deferring mode: every request with a login UI holds its init Start until something else is published ----
private int _loginRequests;
/// <summary>A login UI that counts how often a login flow asked for the owner.</summary>
private Func<CancellationToken, Task<IUserInterface?>> CountingLoginUi() => _ =>
{
Interlocked.Increment(ref _loginRequests);
return Task.FromResult<IUserInterface?>(new RecordingUi());
};
/// <summary>Collects the stream, noting for each OnResponse call how many Starts the consumer had already seen.</summary>
private static async Task<(List<AssistantMessageEvent> Events, List<int> StartsBeforeResponse)> CollectWithResponses(
Func<StreamOptions, IAsyncEnumerable<AssistantMessageEvent>> stream, Action<StreamOptions>? configure = null)
{
var events = new List<AssistantMessageEvent>();
var startsBeforeResponse = new List<int>();
var options = new StreamOptions
{
SessionId = "session-1",
OnResponse = _ =>
{
lock (events) startsBeforeResponse.Add(events.Count(e => e is AssistantMessageEvent.Start));
return ValueTask.CompletedTask;
},
};
await foreach (var e in stream(options)) lock (events) events.Add(e);
return (events, startsBeforeResponse);
}
private string TextFixture()
{
var fixture = Path.Combine(_root, "text.jsonl");
File.WriteAllLines(fixture, [
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\":\"hello there\"}}}",
"{\"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\":3}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}",
"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello there\",\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}",
]);
return fixture;
}
[Fact]
public async Task Deferring_text_turn_publishes_one_start_after_one_response()
{
if (!Fixtures.IsPosix) return;
var api = Api(FakeClaude($"cat \"{TextFixture()}\"", tail: "exit 0"), loginUi: CountingLoginUi());
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
Assert.Equal("hello there", Assert.IsType<AssistantMessageEvent.Done>(events[^1]).Message.Text);
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Deferring_tool_handoff_is_unchanged()
{
if (!Fixtures.IsPosix) return;
var api = Api(FakeClaude($"cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\""), loginUi: CountingLoginUi());
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, ToolContext(), o, CancellationToken.None));
Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
Assert.Equal(StopReason.ToolUse, Assert.IsType<AssistantMessageEvent.Done>(events[^1]).Reason);
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Deferring_non_login_failure_after_init_publishes_the_held_start()
{
if (!Fixtures.IsPosix) return;
var api = Api(FakeClaude($"echo '{Fixtures.Init().GetRawText()}'\necho \"boom: fake failure\" >&2", tail: "exit 1"), loginUi: CountingLoginUi());
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray());
Assert.StartsWith("[process_exit]", Assert.IsType<AssistantMessageEvent.Error>(events[^1]).Message.ErrorMessage);
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Deferring_cancellation_after_init_reports_aborted_after_the_held_start()
{
if (!Fixtures.IsPosix) return;
using var cts = new CancellationTokenSource();
var api = Api(FakeClaude($"echo '{Fixtures.Init().GetRawText()}'"), loginUi: CountingLoginUi());
var stopwatch = Stopwatch.StartNew();
// The Start is held, so cancel on the init record itself.
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"),
o with { OnProviderStreamEvent = _ => { cts.CancelAfter(100); return ValueTask.CompletedTask; } }, cts.Token));
stopwatch.Stop();
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(8), $"took {stopwatch.Elapsed}");
Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray());
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
Assert.Equal(StopReason.Aborted, error.Reason);
Assert.Equal("aborted", error.Message.ErrorMessage);
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Login_required_after_content_is_published_without_a_login()
{
if (!Fixtures.IsPosix) return;
var fixture = Path.Combine(_root, "expired-mid.jsonl");
File.WriteAllLines(fixture, [
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\":\"partial\"}}}",
"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"api_error_status\":401,\"result\":\"Failed to authenticate. API Error: 401 OAuth token has expired.\",\"usage\":{\"input_tokens\":7,\"output_tokens\":1}}",
]);
var runs = Path.Combine(_root, "runs");
var api = Api(FakeClaude($"echo run >> \"{runs}\"\ncat \"{fixture}\"", tail: "exit 1"), loginUi: CountingLoginUi());
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
Assert.Equal(["Start", "TextStart", "TextDelta", "Error"], events.Select(e => e.GetType().Name).ToArray());
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
Assert.StartsWith("[auth] Claude Code session expired", error.Message.ErrorMessage);
Assert.Equal("partial", error.Message.Text);
Assert.Single(File.ReadAllLines(runs));
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Replay_after_login_announces_one_response()
{
if (!Fixtures.IsPosix) return;
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi("the-code")));
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, ToolContext(), o, CancellationToken.None));
Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
Assert.Single(events, e => e is AssistantMessageEvent.Start);
Assert.Equal([0], responses);
Assert.Equal(2, Runs());
}
[Fact]
public async Task Owner_lookup_that_never_answers_is_unreachable()
{
if (!Fixtures.IsPosix) return;
var api = new ClaudeCodeApi(_registry, new ClaudeCodeApiOptions
{
ClaudePath = ExpiringClaude(),
GetEnv = _ => null,
LoginOptions = new ClaudeLoginOptions { OwnerTimeout = TimeSpan.FromMilliseconds(200) },
});
var outcome = await api.RunLoginFlowAsync(_ => new TaskCompletionSource<IUserInterface?>().Task, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
Assert.False(outcome.Success);
Assert.StartsWith("the owner could not be reached", outcome.Error);
}
}

View File

@@ -33,7 +33,7 @@ public class ClaudeLoginTests : IDisposable
#!/bin/sh
STATE="{{state}}"
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
n=$(cat "$STATE/n" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "$STATE/n"
n=$(cat "$STATE/n" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "$STATE/n"; echo $$ > "$STATE/pid"
echo "Opening browser to sign in…"
{{(printUrl ? "echo \"If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&state=s$n\"" : "exit 3")}}
printf "Paste code here if prompted > "
@@ -117,4 +117,35 @@ public class ClaudeLoginTests : IDisposable
var prompt = new ScriptedPrompt("good") { BeforeAnswer = async ct => { cts.Cancel(); await Task.Delay(Timeout.Infinite, ct); } };
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, cts.Token));
}
[Fact]
public async Task Unanswered_prompt_times_out_as_no_code_and_ends_the_login_process()
{
if (!Fixtures.IsPosix) return;
// The prompt ignores its token entirely, like a host dialog that never returns.
var prompt = new ScriptedPrompt("good") { BeforeAnswer = _ => Task.Delay(Timeout.Infinite, CancellationToken.None) };
var options = Fast with { CodeTimeout = TimeSpan.FromMilliseconds(300) };
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, options, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
Assert.False(outcome.Success);
Assert.Equal("no code was entered", outcome.Error);
Assert.Single(prompt.Urls);
if (!OperatingSystem.IsLinux()) return;
var pid = File.ReadAllText(Path.Combine(_root, "state", "pid")).Trim();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (IsAlive(pid) && DateTime.UtcNow < deadline) await Task.Delay(25);
Assert.False(IsAlive(pid), $"claude auth login (pid {pid}) is still running");
}
/// <summary>Alive unless gone from /proc or a zombie awaiting its reaper.</summary>
private static bool IsAlive(string pid)
{
try
{
var stat = File.ReadAllText($"/proc/{pid}/stat");
return stat[(stat.LastIndexOf(')') + 2)] != 'Z';
}
catch (IOException) { return false; }
}
}