forked from admin/VolcanicAgenticCore
feat(claude-code): headless claude auth login driver
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user