feat(claude-code): headless claude auth login driver

This commit is contained in:
EugeneTes
2026-09-25 17:03:42 +00:00
parent 0c972480a8
commit aa859a0dcf
2 changed files with 306 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>Where the login link goes and where the pasted code comes from.</summary>
public interface ILoginPrompt
{
/// <summary>Show the link and wait for the code. Null when there is no answer (timeout, cancelled dialog, unreachable user).</summary>
Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken cancellationToken);
/// <summary>A progress or retry message for the person logging in.</summary>
void Report(string message);
}
/// <summary>The result of a <see cref="ClaudeLogin"/> run.</summary>
public sealed record LoginOutcome(bool Success, string? Error)
{
public static readonly LoginOutcome Ok = new(true, null);
public static LoginOutcome Failed(string error) => new(false, error);
}
/// <summary>Timeouts and retry budget for <see cref="ClaudeLogin.RunAsync"/>.</summary>
public sealed record ClaudeLoginOptions
{
public int MaxAttempts { get; init; } = 3;
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>
/// Runs <c>claude auth login --claudeai</c> headlessly: it prints the authorize URL and waits for the code on stdin.
/// Each attempt is a fresh process (its own PKCE state), so a rejected code gets a new link. Success is confirmed by
/// <c>claude auth status</c>. The URL and code never appear in errors.
/// </summary>
public static partial class ClaudeLogin
{
public static async Task<LoginOutcome> RunAsync(string claudePath, IReadOnlyDictionary<string, string> environment, ILoginPrompt prompt,
ClaudeLoginOptions? options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(prompt);
options ??= new ClaudeLoginOptions();
var env = new Dictionary<string, string>(environment, StringComparer.Ordinal) { ["BROWSER"] = OperatingSystem.IsWindows() ? "none" : "/bin/false" };
for (var attempt = 1; attempt <= options.MaxAttempts; attempt++)
{
var result = await AttemptAsync(claudePath, env, prompt, attempt, options, cancellationToken).ConfigureAwait(false);
if (result is not null) return result;
if (attempt < options.MaxAttempts) prompt.Report("That code was not accepted; here is a new link.");
}
return LoginOutcome.Failed($"the code was rejected {options.MaxAttempts} times");
}
/// <summary>A final outcome, or null when the code was rejected and another attempt may follow.</summary>
private static async Task<LoginOutcome?> AttemptAsync(string claudePath, Dictionary<string, string> env, ILoginPrompt prompt, int attempt,
ClaudeLoginOptions options, CancellationToken ct)
{
var psi = new ProcessStartInfo(claudePath)
{
UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true,
};
psi.ArgumentList.Add("auth"); psi.ArgumentList.Add("login"); psi.ArgumentList.Add("--claudeai");
psi.Environment.Clear();
foreach (var (name, value) in env) psi.Environment[name] = value;
using var process = new Process { StartInfo = psi };
try { process.Start(); }
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
{
return LoginOutcome.Failed($"claude auth login could not be started: {ex.Message}");
}
var stderr = new StringBuilder();
var stderrPump = PumpAsync(process.StandardError, stderr);
try
{
var url = await ReadUrlAsync(process, options.UrlTimeout, ct).ConfigureAwait(false);
if (url is null)
{
Kill(process);
return LoginOutcome.Failed($"claude auth login printed no login URL{Excerpt(stderr)}");
}
var code = (await prompt.AskForCodeAsync(url, attempt, ct).ConfigureAwait(false))?.Trim();
if (string.IsNullOrEmpty(code))
{
Kill(process);
return LoginOutcome.Failed("no code was entered");
}
try
{
await process.StandardInput.WriteLineAsync(code.AsMemory(), ct).ConfigureAwait(false);
await process.StandardInput.FlushAsync(ct).ConfigureAwait(false);
process.StandardInput.Close();
}
catch (IOException) { /* exited early; the status check decides */ }
using (var exchange = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
exchange.CancelAfter(options.ExchangeTimeout);
try { await process.WaitForExitAsync(exchange.Token).ConfigureAwait(false); }
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Kill(process);
return null; // the code hung the exchange: treat as rejected
}
}
return process.ExitCode == 0 && await IsLoggedInAsync(claudePath, env, options.StatusTimeout, ct).ConfigureAwait(false)
? LoginOutcome.Ok
: null;
}
finally
{
Kill(process);
try { await stderrPump.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); } catch (TimeoutException) { }
}
}
/// <summary>Reads stdout in chunks (the paste prompt has no newline) until the authorize URL appears.</summary>
private static async Task<string?> ReadUrlAsync(Process process, TimeSpan timeout, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
var text = new StringBuilder();
var buffer = new char[1024];
try
{
while (true)
{
var read = await process.StandardOutput.ReadAsync(buffer.AsMemory(), cts.Token).ConfigureAwait(false);
if (read == 0) return null;
text.Append(buffer, 0, read);
var match = UrlPattern().Match(text.ToString());
// The URL is complete once whitespace (or anything else) follows it.
if (match.Success && match.Index + match.Length < text.Length) return match.Value;
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return null;
}
}
private static async Task<bool> IsLoggedInAsync(string claudePath, IReadOnlyDictionary<string, string> env, TimeSpan timeout, CancellationToken ct)
{
var status = await ClaudeInstallation.RunAsync(claudePath, ["auth", "status"], env, timeout, ct).ConfigureAwait(false);
var json = status.Stdout.Trim();
var start = json.IndexOf('{'); var end = json.LastIndexOf('}');
if (start < 0 || end <= start) return false;
try
{
using var doc = JsonDocument.Parse(json[start..(end + 1)]);
var root = doc.RootElement;
var loggedIn = root.TryGetProperty("loggedIn", out var li) && li.ValueKind == JsonValueKind.True;
var method = root.TryGetProperty("authMethod", out var am) && am.ValueKind == JsonValueKind.String ? am.GetString() : null;
return loggedIn && method == "claude.ai";
}
catch (JsonException) { return false; }
}
private static async Task PumpAsync(StreamReader reader, StringBuilder sink)
{
var buffer = new char[1024];
int read;
try { while ((read = await reader.ReadAsync(buffer.AsMemory()).ConfigureAwait(false)) > 0) lock (sink) sink.Append(buffer, 0, read); }
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { }
}
private static string Excerpt(StringBuilder stderr)
{
string text; lock (stderr) text = stderr.ToString().Trim();
if (text.Length > 300) text = text[^300..];
return text.Length == 0 ? "" : $": {text}";
}
private static void Kill(Process process)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { }
}
[GeneratedRegex(@"https://\S*oauth/authorize\S*")]
private static partial Regex UrlPattern();
}

View File

@@ -0,0 +1,119 @@
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
public class ClaudeLoginTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-login-test-").FullName;
public void Dispose() { try { Directory.Delete(_root, true); } catch (IOException) { } }
private sealed class ScriptedPrompt(params string?[] answers) : ILoginPrompt
{
private int _next;
public List<string> Urls { get; } = [];
public List<string> Reports { get; } = [];
public Func<CancellationToken, Task>? BeforeAnswer { get; init; }
public async Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken ct)
{
Urls.Add(url);
if (BeforeAnswer is not null) await BeforeAnswer(ct);
return _next < answers.Length ? answers[_next++] : null;
}
public void Report(string message) => Reports.Add(message);
}
/// <summary>printUrl=false simulates a CLI that exits without printing a login link.</summary>
private string FakeClaude(bool printUrl = true)
{
var script = Path.Combine(_root, "claude");
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
File.WriteAllText(script, $$"""
#!/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"
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 > "
read code
echo "$code" > "$STATE/code"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
if [ "$(cat "$STATE/code" 2>/dev/null)" = "good" ]; then
echo '{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty"}'
else
echo '{"loggedIn":false}'
fi
exit 0
fi
exit 1
""".Replace("\r\n", "\n"));
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
private static IReadOnlyDictionary<string, string> Env() =>
new Dictionary<string, string> { ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin" };
private static readonly ClaudeLoginOptions Fast = new() { UrlTimeout = TimeSpan.FromSeconds(5), ExchangeTimeout = TimeSpan.FromSeconds(5) };
[Fact]
public async Task Valid_code_logs_in()
{
if (!Fixtures.IsPosix) return;
var prompt = new ScriptedPrompt(" good ");
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, CancellationToken.None);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal(["https://claude.com/cai/oauth/authorize?code=true&state=s1"], prompt.Urls);
}
[Fact]
public async Task Rejected_code_gets_a_new_link()
{
if (!Fixtures.IsPosix) return;
var prompt = new ScriptedPrompt("bad", "good");
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, CancellationToken.None);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal(2, prompt.Urls.Count);
Assert.EndsWith("state=s2", prompt.Urls[1]);
Assert.Single(prompt.Reports);
}
[Fact]
public async Task Three_rejected_codes_fail()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), new ScriptedPrompt("a", "b", "c", "good"), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal("the code was rejected 3 times", outcome.Error);
}
[Fact]
public async Task No_answer_fails()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), new ScriptedPrompt(), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal("no code was entered", outcome.Error);
}
[Fact]
public async Task Missing_url_fails()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(printUrl: false), Env(), new ScriptedPrompt("good"), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.StartsWith("claude auth login printed no login URL", outcome.Error);
}
[Fact]
public async Task Cancellation_throws()
{
if (!Fixtures.IsPosix) return;
using var cts = new CancellationTokenSource();
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));
}
}