feat(worker): add Claude CLI preflight on startup
Worker now runs `claude --version` before listening; on non-zero exit it logs critical and exits with code 1. Skippable via env var CLAUDEDO_SKIP_CLI_PREFLIGHT=1 for environments without the CLI (tests, dev). Closes verification step 2 / open.md item 3.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
38
src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs
Normal file
38
src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
|
public static class ClaudeCliPreflight
|
||||||
|
{
|
||||||
|
public sealed record Result(bool Ok, string Version, string Error, int ExitCode);
|
||||||
|
|
||||||
|
public static async Task<Result> CheckAsync(string claudeBin, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = claudeBin,
|
||||||
|
Arguments = "--version",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
using var proc = Process.Start(psi);
|
||||||
|
if (proc is null) return new Result(false, "", "Process.Start returned null", -1);
|
||||||
|
|
||||||
|
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
|
||||||
|
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
|
||||||
|
await proc.WaitForExitAsync(ct);
|
||||||
|
|
||||||
|
var stdout = (await stdoutTask).Trim();
|
||||||
|
var stderr = (await stderrTask).Trim();
|
||||||
|
return new Result(proc.ExitCode == 0, stdout, stderr, proc.ExitCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new Result(false, "", ex.Message, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,22 @@ app.UseMiddleware<PlanningTokenAuthMiddleware>();
|
|||||||
app.MapHub<WorkerHub>("/hub");
|
app.MapHub<WorkerHub>("/hub");
|
||||||
app.MapMcp("/mcp");
|
app.MapMcp("/mcp");
|
||||||
|
|
||||||
|
// Claude CLI preflight: fail fast if the configured binary is unreachable or non-zero.
|
||||||
|
// Skippable via CLAUDEDO_SKIP_CLI_PREFLIGHT=1 for environments without the CLI (e.g. tests).
|
||||||
|
if (Environment.GetEnvironmentVariable("CLAUDEDO_SKIP_CLI_PREFLIGHT") != "1")
|
||||||
|
{
|
||||||
|
var preflight = await ClaudeCliPreflight.CheckAsync(cfg.ClaudeBin);
|
||||||
|
if (!preflight.Ok)
|
||||||
|
{
|
||||||
|
app.Logger.LogCritical(
|
||||||
|
"Claude CLI preflight failed (bin: '{Bin}', exit: {Exit}): {Error}. " +
|
||||||
|
"Fix `claude_bin` in worker.config.json or set CLAUDEDO_SKIP_CLI_PREFLIGHT=1 to bypass.",
|
||||||
|
cfg.ClaudeBin, preflight.ExitCode, preflight.Error);
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
app.Logger.LogInformation("Claude CLI preflight OK: {Version}", preflight.Version);
|
||||||
|
}
|
||||||
|
|
||||||
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
||||||
cfg.SignalRPort, cfg.DbPath);
|
cfg.SignalRPort, cfg.DbPath);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Lifecycle;
|
||||||
|
|
||||||
|
public sealed class ClaudeCliPreflightTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task NonExistentBinary_ReturnsNotOk_WithError()
|
||||||
|
{
|
||||||
|
var result = await ClaudeCliPreflight.CheckAsync("definitely-not-a-real-bin-xyz123");
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
|
Assert.NotEmpty(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BinaryAcceptingVersionFlag_ReturnsOk_WithVersionOutput()
|
||||||
|
{
|
||||||
|
// `dotnet --version` is always available in this build environment, exits 0,
|
||||||
|
// and prints the SDK version on stdout — same shape as `claude --version`.
|
||||||
|
var result = await ClaudeCliPreflight.CheckAsync("dotnet");
|
||||||
|
|
||||||
|
Assert.True(result.Ok, $"expected ok, got exit={result.ExitCode} err='{result.Error}'");
|
||||||
|
Assert.Equal(0, result.ExitCode);
|
||||||
|
Assert.NotEmpty(result.Version);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user