docs(plan): Claude Code re-login implementation plan
This commit is contained in:
921
docs/superpowers/plans/2026-09-25-claude-code-relogin.md
Normal file
921
docs/superpowers/plans/2026-09-25-claude-code-relogin.md
Normal file
@@ -0,0 +1,921 @@
|
||||
# Claude Code Re-login Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When the `claude` CLI's subscription session expires, the Claude Code provider pauses the request, sends the bot owner a login link through the host UI, takes the pasted code, completes `claude auth login` and replays the request.
|
||||
|
||||
**Architecture:** Detection lives in `StreamEventMapper` (a `LoginRequired` flag on `ClaudeCodeException`). A UI-agnostic `ClaudeLogin` drives `claude auth login --claudeai` over pipes through an `ILoginPrompt`. `ClaudeLoginCoordinator` keeps it to one flow per process. `ClaudeCodeApi` retries once after a successful login. The host supplies `ClaudeCodeProviderOptions.LoginUi` (Telegram: the first allowed user's chat).
|
||||
|
||||
**Tech Stack:** .NET 10, C#, xUnit, Telegram.Bot. Spec: `docs/superpowers/specs/2026-09-25-claude-code-relogin-design.md`.
|
||||
|
||||
**Conventions:** Match surrounding code: file-scoped namespaces, `ConfigureAwait(false)`, XML `<summary>` doc comments on public types, sealed classes/records. Tests that spawn shell scripts return early when `!Fixtures.IsPosix`. Build/test: `dotnet test VolcanicAgenticCore.slnx` from the repo root (filter with `--filter "FullyQualifiedName~<Class>"`).
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `extensions/…ClaudeCode/ClaudeCodeException.cs` | add `LoginRequired` |
|
||||
| `extensions/…ClaudeCode/StreamEventMapper.cs` | classify expired-session results |
|
||||
| `extensions/…ClaudeCode/ClaudeLogin.cs` | **new**: `ILoginPrompt`, `LoginOutcome`, `ClaudeLoginOptions`, `ClaudeLogin` |
|
||||
| `extensions/…ClaudeCode/ClaudeLoginCoordinator.cs` | **new**: single-flight coordinator |
|
||||
| `extensions/…ClaudeCode/UiLoginPrompt.cs` | **new**: `ILoginPrompt` over `IUserInterface` |
|
||||
| `extensions/…ClaudeCode/ClaudeCodeApi.cs` | options `LoginUi`/`Coordinator`/`LoginOptions`; retry after login |
|
||||
| `extensions/…ClaudeCode/ClaudeCodeProviderExtension.cs` | new options, register `/claude-code-login` |
|
||||
| `extensions/…ClaudeCode/LoginCommand.cs` | **new**: `/claude-code-login` |
|
||||
| `extensions/…ClaudeCode/DoctorCommand.cs` | "not logged in" hint |
|
||||
| `hosts/Volcanic.AgenticCore.Telegram/Program.cs` | register `ClaudeCodeProviderOptions` with `LoginUi` |
|
||||
| `hosts/Volcanic.AgenticCore.Telegram/Chats/OwnerLoginChannel.cs` | **new**: resolves the owner chat's UI |
|
||||
| tests under `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/` and `tests/Volcanic.AgenticCore.Telegram.Tests/` | new tests + 2 fixtures |
|
||||
| `docs/claude-code-provider.md`, `docs/telegram-bot.md` | document the flow |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Detect an expired session
|
||||
|
||||
**Files:**
|
||||
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs`
|
||||
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs` (`AcceptResult` ~line 427, `RejectInterruptedStream` ~line 503, the record dispatch that handles `system/api_retry`)
|
||||
- Create: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/claude-2.1.281-stream-auth-expired.jsonl`
|
||||
- Create: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/claude-2.1.281-stream-auth-login.jsonl`
|
||||
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs`
|
||||
|
||||
- [ ] **Step 1: Fixtures.** Copy the first line (the `system/init` record) of `claude-2.1.281-stream-http529.jsonl` as the first line of both new files, so the init validation passes with the probe catalogue. Then add:
|
||||
|
||||
`claude-2.1.281-stream-auth-expired.jsonl`, after the init line:
|
||||
```
|
||||
{"type":"system","subtype":"status","status":"requesting","session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000002"}
|
||||
{"type":"assistant","message":{"id":"00000000-0000-4000-8000-00000000000a","model":"<synthetic>","role":"assistant","type":"message","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"OAuth token has expired. Please obtain a new token or refresh your existing token.\"}}"}]},"error":"authentication_failed","session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000003"}
|
||||
{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"duration_ms":312,"num_turns":1,"result":"Failed to authenticate. API Error: 401 OAuth token has expired. Please obtain a new token or refresh your existing token.","stop_reason":"stop_sequence","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"uuid":"00000000-0000-4000-8000-000000000004"}
|
||||
```
|
||||
|
||||
`claude-2.1.281-stream-auth-login.jsonl`, after the init line:
|
||||
```
|
||||
{"type":"assistant","message":{"id":"00000000-0000-4000-8000-00000000000a","model":"<synthetic>","role":"assistant","type":"message","content":[{"type":"text","text":"Invalid API key · Please run /login"}]},"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000003"}
|
||||
{"type":"result","subtype":"success","is_error":true,"duration_ms":120,"num_turns":1,"result":"Invalid API key · Please run /login","stop_reason":"stop_sequence","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"uuid":"00000000-0000-4000-8000-000000000004"}
|
||||
```
|
||||
Add a line for both to `Fixtures/README.md` saying they're hand-written from Claude's documented 401 output. Check the test csproj copies `Fixtures/*.jsonl` by glob (it already does for the others; if it lists files, add these).
|
||||
|
||||
- [ ] **Step 2: Failing tests.** Find how the existing `StreamEventMapperTests` drive a whole fixture: look at the test for `http529` or `prompt-too-long`, and the helper that feeds `Fixtures.StreamRecords(scenario)` into `new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(...)`. Reuse that helper. Add:
|
||||
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData("auth-expired")]
|
||||
[InlineData("auth-login")]
|
||||
public void Expired_session_is_a_login_required_auth_failure(string scenario)
|
||||
{
|
||||
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
|
||||
var ex = Assert.Throws<ClaudeCodeException>(() => { foreach (var r in Fixtures.StreamRecords(scenario)) mapper.Accept(r); });
|
||||
Assert.Equal(ClaudeCodeException.Codes.Auth, ex.Code);
|
||||
Assert.True(ex.LoginRequired);
|
||||
Assert.StartsWith("Claude Code session expired", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Other_error_results_do_not_require_login()
|
||||
{
|
||||
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
|
||||
var ex = Assert.Throws<ClaudeCodeException>(() => { foreach (var r in Fixtures.StreamRecords("sse-error")) mapper.Accept(r); });
|
||||
Assert.False(ex.LoginRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Api_key_source_failure_does_not_require_login()
|
||||
{
|
||||
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
|
||||
var ex = Assert.Throws<ClaudeCodeException>(() => mapper.Accept(Fixtures.ProbeInit(apiKeySource: "ANTHROPIC_API_KEY")));
|
||||
Assert.Equal(ClaudeCodeException.Codes.Auth, ex.Code);
|
||||
Assert.False(ex.LoginRequired);
|
||||
}
|
||||
```
|
||||
If the `sse-error` fixture doesn't end in an exception from `Accept` (check its existing test), use whichever existing fixture whose test asserts a `protocol_stream` throw.
|
||||
|
||||
- [ ] **Step 3: Run it; expect a compile failure (`LoginRequired` missing).** `dotnet test VolcanicAgenticCore.slnx --filter "FullyQualifiedName~StreamEventMapperTests"`
|
||||
|
||||
- [ ] **Step 4: Implement.** `ClaudeCodeException`:
|
||||
```csharp
|
||||
public sealed class ClaudeCodeException(string code, string message, bool retryable = false, bool loginRequired = false) : Exception(message)
|
||||
{
|
||||
...
|
||||
/// <summary>True when the CLI's subscription session expired and <c>claude auth login</c> would clear the failure.</summary>
|
||||
public bool LoginRequired { get; } = loginRequired;
|
||||
```
|
||||
`StreamEventMapper`:
|
||||
```csharp
|
||||
private static readonly string[] LoginPhrases =
|
||||
["OAuth token has expired", "Please run /login", "Invalid API key", "authentication_failed", "Not logged in"];
|
||||
private bool _authenticationFailed;
|
||||
|
||||
/// <summary>Whether an error result's detail (or status) says the subscription session is gone.</summary>
|
||||
public static bool IsLoginRequired(int? status, string? detail) =>
|
||||
status == 401 || (detail is not null && LoginPhrases.Any(p => detail.Contains(p, StringComparison.OrdinalIgnoreCase)));
|
||||
```
|
||||
- Where `system/api_retry` records are handled (before `RejectInterruptedStream`'s early return), set `_authenticationFailed = true` when `Str(record, "error") == "authentication_failed"`. In `AcceptAssistant`, also set it when `Str(record, "error") == "authentication_failed"`.
|
||||
- In `AcceptResult`'s `isError` branch, after `detail` is computed and before the overflow check:
|
||||
```csharp
|
||||
int? statusCode = record.TryGetProperty("api_error_status", out var s) && s.ValueKind == JsonValueKind.Number && s.TryGetInt32(out var code) ? code : null;
|
||||
if (_authenticationFailed || IsLoginRequired(statusCode, detail))
|
||||
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth, $"Claude Code session expired: {Truncate(detail, 512)}", loginRequired: true);
|
||||
```
|
||||
(`Truncate` already exists in the mapper; if its signature differs, adapt.) `ThrowDeferredFailure()` runs before this check. If a deferred rate-limit failure could mask a 401, move the login check above `ThrowDeferredFailure()`.
|
||||
|
||||
- [ ] **Step 5: Run the mapper tests, then the full extension test project; all pass.**
|
||||
|
||||
- [ ] **Step 6: Commit** `git commit -m "feat(claude-code): classify an expired subscription session as login-required auth failure"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `ClaudeLogin` — drive `claude auth login` over pipes
|
||||
|
||||
**Files:**
|
||||
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeLogin.cs`
|
||||
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeLoginTests.cs`
|
||||
|
||||
The real CLI prints (verified on 2.1.280):
|
||||
```
|
||||
Opening browser to sign in…
|
||||
If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&client_id=…&state=…
|
||||
Paste code here if prompted >
|
||||
```
|
||||
The prompt has **no trailing newline**, so read stdout in chunks, not by line. Then it reads the code line from stdin and exits.
|
||||
|
||||
- [ ] **Step 1: Failing tests.** The fake `claude` script handles `auth login`, which prints a URL containing an attempt counter, reads one line and writes it to `$STATE/code`, exiting 0. It also handles `auth status`, which prints `loggedIn: true` only if the stored code equals `good`.
|
||||
|
||||
```csharp
|
||||
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"));
|
||||
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));
|
||||
}
|
||||
}
|
||||
```
|
||||
`Fixtures.IsPosix` already exists (the other tests use it).
|
||||
|
||||
- [ ] **Step 2: Run; expect a compile failure.**
|
||||
|
||||
- [ ] **Step 3: Implement `ClaudeLogin.cs`.** Use `System.Diagnostics.Process` directly (not `ClaudeProcess`, which is built for the JSONL stream and closes stdin after one write; here a free-text read is needed). Mirror `ClaudeInstallation.RunAsync` for process start and kill.
|
||||
|
||||
```csharp
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
```
|
||||
Check `ClaudeInstallation.RunAsync`'s exact signature and `ProbeResult` member names, and adapt. Cancellation must propagate as `OperationCanceledException` (the `Cancellation_throws` test). The `finally` kills the process.
|
||||
|
||||
- [ ] **Step 4: Run `--filter "FullyQualifiedName~ClaudeLoginTests"`; all pass.**
|
||||
- [ ] **Step 5: Commit** `git commit -m "feat(claude-code): headless claude auth login driver"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `ClaudeLoginCoordinator` — one flow at a time
|
||||
|
||||
**Files:**
|
||||
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeLoginCoordinator.cs`
|
||||
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeLoginCoordinatorTests.cs`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
```csharp
|
||||
using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
|
||||
|
||||
public class ClaudeLoginCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Concurrent_callers_share_one_flow()
|
||||
{
|
||||
var coordinator = new ClaudeLoginCoordinator();
|
||||
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var runs = 0;
|
||||
Func<CancellationToken, Task<LoginOutcome>> flow = _ => { Interlocked.Increment(ref runs); return gate.Task; };
|
||||
|
||||
var first = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
|
||||
var second = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
|
||||
gate.SetResult(LoginOutcome.Ok);
|
||||
|
||||
Assert.True((await first).Success);
|
||||
Assert.True((await second).Success);
|
||||
Assert.Equal(1, runs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failure_reaches_every_waiter_and_the_next_call_starts_a_new_flow()
|
||||
{
|
||||
var coordinator = new ClaudeLoginCoordinator();
|
||||
var runs = 0;
|
||||
var a = coordinator.EnsureLoggedInAsync(_ => { runs++; return Task.FromResult(LoginOutcome.Failed("no code was entered")); }, CancellationToken.None);
|
||||
Assert.Equal("no code was entered", (await a).Error);
|
||||
var b = await coordinator.EnsureLoggedInAsync(_ => { runs++; return Task.FromResult(LoginOutcome.Ok); }, CancellationToken.None);
|
||||
Assert.True(b.Success);
|
||||
Assert.Equal(2, runs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cancelled_waiter_does_not_cancel_the_flow()
|
||||
{
|
||||
var coordinator = new ClaudeLoginCoordinator();
|
||||
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
CancellationToken flowToken = default;
|
||||
Func<CancellationToken, Task<LoginOutcome>> flow = ct => { flowToken = ct; return gate.Task; };
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var cancelled = coordinator.EnsureLoggedInAsync(flow, cts.Token);
|
||||
var other = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelled);
|
||||
Assert.False(flowToken.IsCancellationRequested);
|
||||
gate.SetResult(LoginOutcome.Ok);
|
||||
Assert.True((await other).Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_flow_becomes_a_failed_outcome()
|
||||
{
|
||||
var coordinator = new ClaudeLoginCoordinator();
|
||||
var outcome = await coordinator.EnsureLoggedInAsync(_ => throw new InvalidOperationException("boom"), CancellationToken.None);
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Contains("boom", outcome.Error);
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2: Run; compile failure.**
|
||||
- [ ] **Step 3: Implement**
|
||||
```csharp
|
||||
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps Claude Code logins single-flight: every session shares one <c>claude</c> configuration, so concurrent expired
|
||||
/// requests wait on the same flow. The flow runs detached from any caller's token; a caller's cancellation only stops
|
||||
/// its own wait. The slot clears when the flow completes, so a later expiry starts a fresh one.
|
||||
/// </summary>
|
||||
public sealed class ClaudeLoginCoordinator
|
||||
{
|
||||
public static ClaudeLoginCoordinator Shared { get; } = new();
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private Task<LoginOutcome>? _current;
|
||||
|
||||
public Task<LoginOutcome> EnsureLoggedInAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runFlow);
|
||||
Task<LoginOutcome> flow;
|
||||
lock (_lock)
|
||||
{
|
||||
flow = _current ??= StartAsync(runFlow);
|
||||
}
|
||||
return flow.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<LoginOutcome> StartAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow)
|
||||
{
|
||||
await Task.Yield(); // assign _current before the flow body runs
|
||||
try
|
||||
{
|
||||
return await runFlow(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return LoginOutcome.Failed(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock) _current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
If the repo's target framework lacks `System.Threading.Lock` (it's .NET 9+; the repo targets net10.0), `Lock` is fine.
|
||||
- [ ] **Step 4: Run the tests; pass.**
|
||||
- [ ] **Step 5: Commit** `git commit -m "feat(claude-code): single-flight login coordinator"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Provider retries after login; `UiLoginPrompt`; options plumbing
|
||||
|
||||
**Files:**
|
||||
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/UiLoginPrompt.cs`
|
||||
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs`
|
||||
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs`
|
||||
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs`, plus `UiLoginPromptTests.cs`
|
||||
|
||||
- [ ] **Step 1: `UiLoginPrompt` test (new file `UiLoginPromptTests.cs`).** A small recording `IUserInterface`:
|
||||
```csharp
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
|
||||
|
||||
internal sealed class RecordingUi(params string?[] inputs) : IUserInterface
|
||||
{
|
||||
private int _next;
|
||||
public List<(string Message, NotifyKind Kind)> Notes { get; } = [];
|
||||
public List<string> Questions { get; } = [];
|
||||
public Task<string?> SelectAsync(string t, IReadOnlyList<string> o, UiRequestOptions? r, CancellationToken c) => Task.FromResult<string?>(null);
|
||||
public Task<bool> ConfirmAsync(string t, string m, UiRequestOptions? r, CancellationToken c) => Task.FromResult(false);
|
||||
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? r, CancellationToken c)
|
||||
{
|
||||
Questions.Add(title);
|
||||
return Task.FromResult(_next < inputs.Length ? inputs[_next++] : null);
|
||||
}
|
||||
public void Notify(string message, NotifyKind kind = NotifyKind.Info) => Notes.Add((message, kind));
|
||||
public void SetStatus(string key, string? text) { }
|
||||
}
|
||||
|
||||
public class UiLoginPromptTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Sends_the_link_then_asks_for_the_code()
|
||||
{
|
||||
var ui = new RecordingUi("abc");
|
||||
var code = await new UiLoginPrompt(ui).AskForCodeAsync("https://x/oauth/authorize?s=1", 1, CancellationToken.None);
|
||||
Assert.Equal("abc", code);
|
||||
Assert.Contains("https://x/oauth/authorize?s=1", ui.Notes.Single().Message);
|
||||
Assert.Equal(["Paste the Claude login code"], ui.Questions);
|
||||
}
|
||||
}
|
||||
```
|
||||
Implement `UiLoginPrompt`:
|
||||
```csharp
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
|
||||
/// <summary><see cref="ILoginPrompt"/> over a host dialog surface: the link as a notification, the code as a free-text answer.</summary>
|
||||
public sealed class UiLoginPrompt(IUserInterface ui) : ILoginPrompt
|
||||
{
|
||||
public const string CodeQuestion = "Paste the Claude login code";
|
||||
|
||||
public async Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken cancellationToken)
|
||||
{
|
||||
ui.Notify("Claude Code needs you to sign in again.\n" +
|
||||
$"1. Open: {url}\n" +
|
||||
"2. Sign in with the Claude subscription account.\n" +
|
||||
"3. Paste the code shown on the page as a reply.");
|
||||
return await ui.InputAsync(CodeQuestion, "code", null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Report(string message) => ui.Notify(message, NotifyKind.Warning);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Options.** In `ClaudeCodeApiOptions` add:
|
||||
```csharp
|
||||
/// <summary>Where the login link goes when the session expires; null disables the automatic login (headless hosts).</summary>
|
||||
public Func<CancellationToken, Task<IUserInterface?>>? LoginUi { get; init; }
|
||||
/// <summary>The single-flight login coordinator; <see cref="ClaudeLoginCoordinator.Shared"/> when null.</summary>
|
||||
public ClaudeLoginCoordinator? Coordinator { get; init; }
|
||||
public ClaudeLoginOptions? LoginOptions { get; init; }
|
||||
```
|
||||
In `ClaudeCodeProviderOptions` add the same three properties, with the same docs. In `ClaudeCodeProviderExtension.ActivateAsync`, pass them into `apiOptions` (`Coordinator = _options.Coordinator ?? ClaudeLoginCoordinator.Shared`).
|
||||
|
||||
- [ ] **Step 3: Failing provider tests** in `ClaudeCodeApiTests`. Extend the `Api(...)` helper with optional `loginUi` and `coordinator` parameters (always pass `new ClaudeLoginCoordinator()` in tests so there's no shared state). The fake claude has to act differently per call. Use a state dir: a `-p` request emits `auth-expired` until `$STATE/logged-in` exists, and `tool-ok` after that. `auth login` prints the URL, reads the code and touches `logged-in`. `auth status` reports logged in when `logged-in` exists. `FakeClaude(body)` already injects the ready-marker prelude, so `body` can be:
|
||||
```sh
|
||||
STATE="<root>/state"; mkdir -p "$STATE"
|
||||
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
|
||||
echo "If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?state=x"
|
||||
printf "Paste code here if prompted > "; read code; : > "$STATE/logged-in"; exit 0
|
||||
fi
|
||||
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
|
||||
if [ -f "$STATE/logged-in" ]; then echo '{"loggedIn":true,"authMethod":"claude.ai"}'; else echo '{"loggedIn":false}'; fi; exit 0
|
||||
fi
|
||||
echo run >> "$STATE/runs"
|
||||
if [ -f "$STATE/logged-in" ] && [ ! -f "$STATE/expire-always" ]; then cat "<tool-ok fixture>"; else cat "<auth-expired fixture>"; fi
|
||||
```
|
||||
(For `auth` subcommands the prelude only looks for `--system-prompt-file`, which is harmless. Make sure `exit` in the auth branches runs before the helper's `tail`.) Tests:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Expired_session_logs_in_through_the_login_ui_and_replays()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var notes = new List<string>();
|
||||
_registry.Register("session-1", _cwd, (m, _) => notes.Add(m));
|
||||
var owner = new RecordingUi("the-code");
|
||||
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(owner));
|
||||
|
||||
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
|
||||
|
||||
Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
|
||||
Assert.Equal(2, File.ReadAllLines(Path.Combine(_root, "state", "runs")).Length);
|
||||
Assert.Contains(notes, n => n.Contains("waiting for the bot owner"));
|
||||
Assert.Contains(owner.Notes, n => n.Message.Contains("oauth/authorize"));
|
||||
Assert.Contains(owner.Notes, n => n.Message.Contains("login complete"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_again_after_login_is_reported_without_a_loop()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
Directory.CreateDirectory(Path.Combine(_root, "state"));
|
||||
File.WriteAllText(Path.Combine(_root, "state", "expire-always"), "");
|
||||
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi("code")));
|
||||
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]); // use the real failure event type name
|
||||
Assert.Contains("[auth]", error.Message.ErrorMessage);
|
||||
Assert.Equal(2, File.ReadAllLines(Path.Combine(_root, "state", "runs")).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_login_ui_the_expiry_is_reported_with_instructions()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var api = Api(ExpiringClaude());
|
||||
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
|
||||
// Assert the error message contains "claude auth login" and "[auth]", and runs == 1.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unanswered_login_fails_the_request()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi()));
|
||||
// Assert the error contains "Claude Code login failed: no code was entered".
|
||||
}
|
||||
```
|
||||
Look at how existing tests assert failures (the `Fail` event type and message property names in `AssistantMessageEvent`) and use those exact names. `RecordingUi` is `internal` in the same test assembly.
|
||||
|
||||
- [ ] **Step 4: Run; failing.**
|
||||
|
||||
- [ ] **Step 5: Implement the retry in `ClaudeCodeApi`.** Rename the current `RunRequestAsync` body into `RunAttemptAsync(..., bool publishFailure)` and have it **return** the exception instead of publishing it when `publishFailure == false` and the exception is a `LoginRequired` `ClaudeCodeException` and **no event has been written yet**. Track a local `var wroteAny = false;`, set to true around every `writer.WriteAsync`. Keep the `finally` (process dispose, temp cleanup, notices) as is, but move `writer.TryComplete()` out to the new outer method. Shape:
|
||||
|
||||
```csharp
|
||||
private async Task RunRequestAsync(Model model, LlmContext context, StreamOptions streamOptions, ChannelWriter<AssistantMessageEvent> writer, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loginUi = Options.LoginUi;
|
||||
var expired = await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: true, ct).ConfigureAwait(false);
|
||||
if (expired is null) return;
|
||||
|
||||
var builder = new AssistantMessageBuilder(model);
|
||||
if (loginUi is null)
|
||||
{
|
||||
await writer.WriteAsync(builder.Fail(new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
|
||||
"Claude Code session expired; run \"claude auth login\" on the host (or /claude-code-login from a chat UI)").FormatForUser()), CancellationToken.None).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
Notify(streamOptions.SessionId, "Claude Code session expired; waiting for the bot owner to sign in…", NotifyKind.Warning);
|
||||
LoginOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await (Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
|
||||
.EnsureLoggedInAsync(flowCt => RunLoginFlowAsync(loginUi, flowCt), ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await writer.WriteAsync(builder.Fail("aborted", aborted: true), CancellationToken.None).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if (!outcome.Success)
|
||||
{
|
||||
await writer.WriteAsync(builder.Fail($"[{ClaudeCodeException.Codes.Auth}] Claude Code login failed: {outcome.Error}"), CancellationToken.None).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The login flow the coordinator runs once for every waiting request: resolve the owner, drive the CLI, report.</summary>
|
||||
public async Task<LoginOutcome> RunLoginFlowAsync(Func<CancellationToken, Task<IUserInterface?>> loginUi, CancellationToken ct)
|
||||
{
|
||||
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");
|
||||
return await LoginThroughAsync(ui, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Runs the CLI login with <paramref name="ui"/> as the prompt and reports the result there.</summary>
|
||||
public async Task<LoginOutcome> LoginThroughAsync(IUserInterface ui, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (outcome.Success) ui.Notify("Claude Code login complete ✓");
|
||||
else ui.Notify($"Claude Code login failed: {outcome.Error}", NotifyKind.Error);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private void Notify(string? sessionId, string message, NotifyKind kind)
|
||||
{
|
||||
if (sessionId is null || Registry.Get(sessionId)?.Notify is not { } notify) return;
|
||||
try { notify(message, kind); } catch (Exception) { /* advisory */ }
|
||||
}
|
||||
```
|
||||
`RunAttemptAsync` returns `ClaudeCodeException?`. In its `catch`: if `deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login`, return `login` without publishing. Otherwise keep the existing publish path. The `builder.IsFinished` check still applies. Make sure the "Owner could not be reached" path works when `InputAsync` returns null because sending failed: `ClaudeLogin` already maps that to "no code was entered". That's acceptable, and the spec table's "owner unreachable" wording covers the `ui is null` case.
|
||||
|
||||
Also check that the `StreamEventMapper` deferred-failure path (`mapper.DeferredError` → `ProtocolStream`) can't swallow the `LoginRequired` exception.
|
||||
|
||||
- [ ] **Step 6: Run all `ClaudeCode` tests; all pass (existing ones unchanged).**
|
||||
- [ ] **Step 7: Commit** `git commit -m "feat(claude-code): pause an expired request, log in through the host UI and replay it"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `/claude-code-login` command and doctor hint
|
||||
|
||||
**Files:**
|
||||
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/LoginCommand.cs`
|
||||
- Modify: `ClaudeCodeProviderExtension.cs` (register), `DoctorCommand.cs` (line with `run \`claude auth login\``)
|
||||
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/LoginCommandTests.cs` and the doctor test that asserts the old wording (update it)
|
||||
|
||||
- [ ] **Step 1: Failing test.** Look at `DoctorCommandTests` / `ProviderExtensionTests` for how a command handler is invoked with a fake `IExtensionApi` / command context (`FakeExtensionApi` exists in `tests/.../OpenAI/FakeExtensionApi.cs`). Write:
|
||||
- `Without_ui_the_command_explains_it_needs_one`: invoke the handler with a context whose `HasUi` is false. Assert that `SendMessage` got a custom message containing "needs an interactive UI".
|
||||
- `With_ui_it_runs_the_login_through_that_ui`: a fake claude as in Task 2 (the login/status branches only), a `RecordingUi("good")` as the context UI. Assert `Notes` contains "login complete".
|
||||
- [ ] **Step 2: Implement**
|
||||
```csharp
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
|
||||
/// <summary><c>/claude-code-login</c>: signs the <c>claude</c> CLI in (again) through the current session's UI.</summary>
|
||||
public static class LoginCommand
|
||||
{
|
||||
public const string Name = "claude-code-login";
|
||||
public const string Description = "Sign Claude Code in to the Claude subscription (link + pasted code)";
|
||||
|
||||
public static CommandDefinition Create(IExtensionApi api, ClaudeCodeApi claude)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
ArgumentNullException.ThrowIfNull(claude);
|
||||
return new CommandDefinition(Description, async (_, ctx, ct) =>
|
||||
{
|
||||
if (!ctx.HasUi)
|
||||
{
|
||||
api.SendMessage(CustomMessage.FromText(Name, $"/{Name} needs an interactive UI; run \"claude auth login\" on the host instead."),
|
||||
MessageDelivery.Steer, triggerTurn: false);
|
||||
return;
|
||||
}
|
||||
var ui = ctx.Ui;
|
||||
await (claude.Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
|
||||
.EnsureLoggedInAsync(flowCt => claude.RunLoginFlowAsync(_ => Task.FromResult<Abstractions.Ui.IUserInterface?>(ui), flowCt), ct)
|
||||
.ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
Match the real `CommandDefinition` handler signature (see `DoctorCommand.Create`). Register it in `ActivateAsync` after the doctor: `api.RegisterCommand(LoginCommand.Name, LoginCommand.Create(api, claude));`.
|
||||
Doctor: change the line to ``"- Auth: **not logged in**; run `/claude-code-login` (or `claude auth login` on the host) with a Claude subscription"`` and update any test asserting the old text.
|
||||
- [ ] **Step 3: Run the extension tests; all pass.**
|
||||
- [ ] **Step 4: Commit** `git commit -m "feat(claude-code): /claude-code-login command"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Telegram host — the owner receives the link
|
||||
|
||||
**Files:**
|
||||
- Create: `hosts/Volcanic.AgenticCore.Telegram/Chats/OwnerLoginChannel.cs`
|
||||
- Modify: `hosts/Volcanic.AgenticCore.Telegram/Program.cs`
|
||||
- Test: `tests/Volcanic.AgenticCore.Telegram.Tests/OwnerLoginTests.cs`
|
||||
- Docs: `docs/claude-code-provider.md`, `docs/telegram-bot.md`
|
||||
|
||||
- [ ] **Step 1: Failing test.** Using `ChatHost` (Support/ChatHost.cs) and `FakeBotClient`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Login_link_and_code_prompt_go_to_the_owner_chat_and_the_reply_answers_it()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var channel = new OwnerLoginChannel(host.Chats, host.Options);
|
||||
var ui = await channel.ResolveAsync(CancellationToken.None);
|
||||
Assert.NotNull(ui);
|
||||
|
||||
var ask = new UiLoginPrompt(ui!).AskForCodeAsync("https://claude.com/cai/oauth/authorize?state=t", 1, CancellationToken.None);
|
||||
// wait until the ForceReply question has been sent to Updates.AllowedUser's chat (inspect FakeBotClient's sent messages; poll briefly)
|
||||
await host.SendAsync("pasted-code"); // plain text from the owner answers the pending InputAsync
|
||||
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
// assert FakeBotClient recorded a message to chat Updates.AllowedUser containing "oauth/authorize"
|
||||
}
|
||||
```
|
||||
Look at `FakeBotClient` and `ChatTests` for how sent messages are inspected and how the tests wait for rendered output (for example `SettleAsync`), and use those helpers. The notification goes through `ChatRenderer.Post`, which is asynchronous, so wait for it the same way the existing notification tests do.
|
||||
- [ ] **Step 2: Implement**
|
||||
```csharp
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>
|
||||
/// The Claude Code login channel: the private chat of the first allowed user (the bot owner). In a private chat the chat
|
||||
/// id is the user id, so the chat is created on demand even if the owner has not written yet (sending then fails
|
||||
/// unless the owner has started the bot, which surfaces as an unanswered login).
|
||||
/// </summary>
|
||||
public sealed class OwnerLoginChannel(ChatRegistry chats, TelegramOptions options)
|
||||
{
|
||||
public async Task<IUserInterface?> ResolveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.AllowedUserIds.Count == 0) return null;
|
||||
return (await chats.GetAsync(options.AllowedUserIds[0], cancellationToken).ConfigureAwait(false)).Ui;
|
||||
}
|
||||
}
|
||||
```
|
||||
`Program.cs`:
|
||||
```csharp
|
||||
builder.Services.AddSingleton<OwnerLoginChannel>();
|
||||
// The extension is constructed per session through ActivatorUtilities, which injects this; LoginUi resolves lazily to
|
||||
// avoid a construction cycle (ChatRegistry -> session factory -> extensions).
|
||||
builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
|
||||
{
|
||||
LoginUi = ct => sp.GetRequiredService<OwnerLoginChannel>().ResolveAsync(ct),
|
||||
});
|
||||
```
|
||||
Check that `ActivatorUtilities.CreateInstance` picks the constructor with the optional parameter when the service is registered. It does when the type is resolvable, but confirm with a quick test or by reading `ExtensionRegistration`. If it doesn't, register the extension with `o.Extensions.Add(ExtensionRegistration.FromFactory(...))` using the options instance.
|
||||
- [ ] **Step 3: Docs.** In `docs/claude-code-provider.md`, add a "Session expiry and re-login" section: detection, pause-and-replay, single flight, `LoginUi`, the headless message, `/claude-code-login`, and 3 attempts / 10-minute prompt. In `docs/telegram-bot.md`, say that the first allowed user gets login links and must have started the bot.
|
||||
- [ ] **Step 4: Run the full suite** `dotnet test VolcanicAgenticCore.slnx`; everything passes. `dotnet build` shows no new warnings (the repo may use TreatWarningsAsErrors via Directory.Build.props).
|
||||
- [ ] **Step 5: Commit** `git commit -m "feat(telegram): send Claude Code login links to the bot owner"`
|
||||
Reference in New Issue
Block a user