diff --git a/test/Capacitor.Cli.Tests.Unit/ClaudeHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/ClaudeHookCommandTests.cs index c5938d7232..985b5fafb9 100644 --- a/test/Capacitor.Cli.Tests.Unit/ClaudeHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ClaudeHookCommandTests.cs @@ -32,26 +32,258 @@ public async Task memory_store_initialization_failure_does_not_suppress_session_ await Assert.That(fx.RouteOrder).Contains("session-start"); } - [Test] + [Test, NotInParallel] public async Task disabled_memory_index_does_not_construct_the_lease_store() { using var fx = new Fixture(); + fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; // decoy — must never be fetched var storeConstructed = false; - AppConfig.SetResolvedState("http://localhost", "default", new Profile { DisableMemoryIndex = true }); - try { - var exit = await ClaudeHookCommand.HandleCore( + + var exit = await WithProfileAsync(new Profile { DisableMemoryIndex = true }, () => + ClaudeHookCommand.HandleCore( fx.Client, AuthStatus.Ok, fx.Spool, System.Diagnostics.Stopwatch.GetTimestamp(), "http://localhost", new StringReader( $$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""), memoryStoreFactory: () => { storeConstructed = true; return new SessionStartMemoryLeaseStore(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); - }); + })); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(storeConstructed).IsFalse(); + await Assert.That(fx.MemoryIndexRequested).IsFalse(); + await Assert.That(fx.RouteOrder).Contains("session-start"); + } + + // ── SessionStart team-memory index: behavioral baseline ───────────────────────────────── + // Characterizes today's byte-level SessionStart output on the shared + // SessionStartMemoryOrchestrator/ContextProvider/LeaseStore foundation (StartMemoryIndexTask + // below) — memory-index GET runs parallel with the session-start POST, joined within the hook + // budget, composed with lessons/version-nudge into one hookSpecificOutput.additionalContext + // envelope — so a future change to that wiring can't silently regress it. + + [Test, NotInParallel] + public async Task session_start_joins_lessons_nudge_and_memory_fragments_in_order() { + using var fx = new Fixture(); + const string responseJson = + """{"top_clusters":[{"text":"seal secrets","category":"safety"},{"text":"run tests first","category":"agent_guidance"}],"version":"999.999.999"}"""; + fx.RespondJson = responseJson; + fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; + + var sid = Guid.NewGuid().ToString("N"); + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + + var responseNode = JsonNode.Parse(responseJson); + var expectedLessons = SessionGuidelinesEmitter.BuildFragment(responseNode, disabled: false); + var expectedNudge = VersionNudgeEmitter.BuildFragment(responseNode, CapacitorVersion.CurrentDisplay()); + var expectedMemory = MemoryIndexEmitter.BuildFragment(JsonNode.Parse(fx.MemoryIndexBody), disabled: false); + var expectedEnvelope = SessionStartAdditionalContext.BuildEnvelope(expectedLessons, expectedNudge, expectedMemory); + + // Byte-exact: today's wiring order is lessons, then nudge, then memory — joined by + // BuildEnvelope and written via a single Console.WriteLine (hence the trailing "\n"). + await Assert.That(stdout).IsEqualTo(expectedEnvelope + "\n"); + + var ctx = JsonNode.Parse(stdout)!["hookSpecificOutput"]!["additionalContext"]!.GetValue(); + var lessonsIdx = ctx.IndexOf("## Known patterns", StringComparison.Ordinal); + var nudgeIdx = ctx.IndexOf("newer kcap version", StringComparison.Ordinal); + var memoryIdx = ctx.IndexOf("Team memory", StringComparison.Ordinal); + await Assert.That(lessonsIdx).IsGreaterThanOrEqualTo(0); + await Assert.That(nudgeIdx).IsGreaterThan(lessonsIdx); + await Assert.That(memoryIdx).IsGreaterThan(nudgeIdx); + } + + [Test, NotInParallel] + public async Task session_start_with_only_a_ready_memory_index_emits_just_the_memory_fragment() { + using var fx = new Fixture(); + fx.RespondJson = "{}"; // no top_clusters/version — lessons and nudge fragments are both null + fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; + + var sid = Guid.NewGuid().ToString("N"); + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + + var expectedMemory = MemoryIndexEmitter.BuildFragment(JsonNode.Parse(fx.MemoryIndexBody), disabled: false); + var expectedEnvelope = SessionStartAdditionalContext.BuildEnvelope(null, null, expectedMemory); + await Assert.That(stdout).IsEqualTo(expectedEnvelope + "\n"); + await Assert.That(stdout).Contains("Team memory"); + } + + [Test, NotInParallel] + public async Task session_start_with_an_empty_memory_index_array_emits_nothing() { + // CompleteWithoutContext disposition (a successful, empty fetch) — with no lessons/nudge + // either, BuildEnvelope collapses to null and NOTHING is written to stdout at all. + using var fx = new Fixture(); + fx.RespondJson = "{}"; + fx.MemoryIndexBody = "[]"; + + var sid = Guid.NewGuid().ToString("N"); + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + await Assert.That(fx.MemoryIndexRequested).IsTrue(); + await Assert.That(stdout).IsEqualTo(""); + } + + [Test, NotInParallel] + public async Task session_start_with_a_204_memory_index_response_emits_nothing() { + // The provider special-cases 204 NoContent as CompleteWithoutContext without even + // reading a body. + using var fx = new Fixture(); + fx.RespondJson = "{}"; + fx.MemoryIndexStatus = HttpStatusCode.NoContent; + fx.MemoryIndexBody = ""; + + var sid = Guid.NewGuid().ToString("N"); + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + await Assert.That(fx.MemoryIndexRequested).IsTrue(); + await Assert.That(stdout).IsEqualTo(""); + } + + [Test, NotInParallel] + public async Task session_start_with_a_5xx_memory_index_response_emits_nothing_and_does_not_fail_the_hook() { + // RetryableFailure disposition — fail-open: the hook still succeeds and nothing about + // the memory fetch surfaces in the envelope (there is none, since lessons/nudge are + // absent here too). + using var fx = new Fixture(); + fx.RespondJson = "{}"; + fx.MemoryIndexStatus = HttpStatusCode.InternalServerError; + fx.MemoryIndexBody = ""; + + var sid = Guid.NewGuid().ToString("N"); + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + await Assert.That(fx.MemoryIndexRequested).IsTrue(); + await Assert.That(stdout).IsEqualTo(""); + } - await Assert.That(exit).IsEqualTo(0); - await Assert.That(storeConstructed).IsFalse(); - await Assert.That(fx.RouteOrder).Contains("session-start"); + [Test, NotInParallel] + public async Task memory_fetch_is_not_repeated_on_a_second_session_start_for_the_same_session() { + // Once the shared lease store commits a disposition — Ready OR CompleteWithoutContext — + // for a session_id, a later SessionStart for that SAME session never re-fetches: a + // resolved, non-repeating lifecycle is exactly-once, not "repeat until non-empty". + using var fx = new Fixture(); + fx.RespondJson = "{}"; + fx.MemoryIndexBody = "[]"; // CompleteWithoutContext on the first call + var sid = Guid.NewGuid().ToString("N"); + var payload = $$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""; + + var (exit1, stdout1) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => fx.HandleAsync(payload))); + await Assert.That(exit1).IsEqualTo(0); + await Assert.That(stdout1).IsEqualTo(""); + await Assert.That(fx.MemoryIndexRequestCount).IsEqualTo(1); + + // A decoy non-empty index — if the second call re-fetched, this WOULD surface. + fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; + + var (exit2, stdout2) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => fx.HandleAsync(payload))); + await Assert.That(exit2).IsEqualTo(0); + await Assert.That(stdout2).IsEqualTo(""); + await Assert.That(fx.MemoryIndexRequestCount).IsEqualTo(1); // NOT re-fetched + } + + [Test, NotInParallel] + public async Task memory_index_get_timing_out_does_not_suppress_lessons_or_nudge() { + // The memory-index GET runs in parallel with the POST and is joined ONLY within the + // remaining hook budget: a GET that outlives that budget yields a null fragment + // (fail-open) without delaying — or breaking — the lessons fragment the same response + // already carries. + using var fx = new Fixture(); + fx.MemoryIndexDelay = TimeSpan.FromSeconds(30); // never resolves inside the session-start budget + fx.RespondJson = """{"top_clusters":[{"text":"seal secrets","category":"safety"}]}"""; + + // Default (near-"now") processStart, exactly like this file's other hung-server tests + // (e.g. subagent_stop_against_hung_server_is_spooled_within_budget) — the full ~3.5s of + // session-start's usable budget (5s ceiling minus the 1.5s safety margin) is comfortably + // enough for watcher-start + repo enrichment + the fast, undelayed POST, but far short of + // the 30s memory-index delay above. + var sid = Guid.NewGuid().ToString("N"); + var sw = System.Diagnostics.Stopwatch.StartNew(); + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync( + $$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + sw.Stop(); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(sw.Elapsed).IsLessThan(TimeSpan.FromSeconds(5)); // did not wait out the 30s delay + await Assert.That(stdout).Contains("## Known patterns"); // lessons fragment still injected + await Assert.That(stdout).DoesNotContain("Team memory"); // memory fragment never joined in time + } + + [Test, NotInParallel] + public async Task exhausted_budget_before_the_memory_task_starts_never_touches_the_provider() { + // When HookBudget.Remaining("session-start") is already <= 0 by the time + // StartMemoryIndexTask is reached, the memory subsystem must never touch the network at + // all (same short-circuit as the `disabled` guard) — and, at that point, neither can the + // session-start POST itself, which the ordering/spool path below already covers. + using var fx = new Fixture(); + fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; // decoy + fx.RespondJson = """{"top_clusters":[{"text":"seal secrets","category":"safety"}]}"""; + + var processStart = System.Diagnostics.Stopwatch.GetTimestamp() + - (long)(4 * System.Diagnostics.Stopwatch.Frequency); + + var sid = Guid.NewGuid().ToString("N"); + var exit = await fx.HandleAsync( + $$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}""", + processStart); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(fx.MemoryIndexRequested).IsFalse(); + } + + [Test, NotInParallel] + public async Task memory_index_ready_is_discarded_when_the_session_start_post_fails() { + // GET-succeeds-but-POST-fails: the POST failure short-circuits BEFORE the response is + // ever read, so no envelope is built at all — even a Ready memory fragment never + // surfaces. The memory task may be left running in the background (abandoned). + using var fx = new Fixture(HttpStatusCode.InternalServerError); + fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; + + var sid = Guid.NewGuid().ToString("N"); + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(stdout).IsEqualTo(""); + await Assert.That(fx.SpoolFiles.Any()).IsTrue(); // still durably spooled for retry + } + + /// Runs with set to + /// , restoring whatever was resolved before (or the closest + /// equivalent to "untouched" — see AppConfig.SetResolvedState's lack of an "unset" + /// primitive) regardless of run order or a mid-test exception. + static async Task WithProfileAsync(Profile profile, Func> action) { + var originalServerUrl = AppConfig.ResolvedServerUrl; + var originalResolved = AppConfig.ResolvedProfile; + AppConfig.SetResolvedState("http://localhost", "default", profile); + try { + return await action(); + } finally { + AppConfig.SetResolvedState( + originalServerUrl ?? "http://localhost", + originalResolved?.ProfileName ?? "default", + originalResolved?.Profile ?? new Profile()); + } + } + + /// Redirects to a buffer for the duration of + /// (a fresh with NewLine = "\n" so + /// the captured bytes are platform-independent), restoring the original writer even if + /// throws. + static async Task<(int Exit, string Stdout)> RunCapturingStdoutAsync(Func> action) { + var originalOut = Console.Out; + var writer = new StringWriter { NewLine = "\n" }; + try { + Console.SetOut(writer); + var exit = await action(); + return (exit, writer.ToString()); } finally { - AppConfig.SetResolvedState("http://localhost", "default", new Profile()); + Console.SetOut(originalOut); } } @@ -376,6 +608,14 @@ sealed class Fixture : IDisposable { public string? RespondJson { get; set; } readonly HttpStatusCode _postStatus; + // Lets a test fake the shared SessionStart memory-index endpoint distinctly from the + // generic GET fallback below (which stays 404) — mirrors CursorHookCommandTests.Fixture. + public string MemoryIndexBody { get; set; } = "[]"; + public HttpStatusCode MemoryIndexStatus { get; set; } = HttpStatusCode.OK; + public TimeSpan MemoryIndexDelay { get; set; } = TimeSpan.Zero; + public bool MemoryIndexRequested => MemoryIndexRequestCount > 0; + public int MemoryIndexRequestCount { get; private set; } + public Fixture(HttpStatusCode postStatus = HttpStatusCode.OK) { Directory.CreateDirectory(_tmpHome); _spoolPath = Path.Combine(_tmpHome, "spool"); @@ -386,6 +626,13 @@ public Fixture(HttpStatusCode postStatus = HttpStatusCode.OK) { var path = req.RequestUri!.AbsolutePath; Sent.Add($"{path}|{body}"); if (path.StartsWith("/hooks/")) RouteOrder.Add(path.Replace("/hooks/", "")); + if (path == "/api/memories/index") { + MemoryIndexRequestCount++; + if (MemoryIndexDelay > TimeSpan.Zero) await Task.Delay(MemoryIndexDelay, ct); + return new HttpResponseMessage(MemoryIndexStatus) { + Content = new System.Net.Http.StringContent(MemoryIndexBody, System.Text.Encoding.UTF8, "application/json") + }; + } if (req.Method == HttpMethod.Get) return new HttpResponseMessage(HttpStatusCode.NotFound); if (HoldOnPost > TimeSpan.Zero) await Task.Delay(HoldOnPost, ct); var resp = new HttpResponseMessage(_postStatus); diff --git a/test/Capacitor.Cli.Tests.Unit/ClaudeMemoryIndexLiveCertTests.cs b/test/Capacitor.Cli.Tests.Unit/ClaudeMemoryIndexLiveCertTests.cs new file mode 100644 index 0000000000..a3c021cef7 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/ClaudeMemoryIndexLiveCertTests.cs @@ -0,0 +1,402 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// Manual, env-gated release-gate cert that the shared SessionStart memory index reaches the +/// model for Claude Code — mirrors ; routing, +/// budget math, lifecycle, and envelope ordering are covered by the unit suite in +/// against fakes, so this is the one place asserting the real +/// end-to-end claim (a nonce saved as a memory reaches the model via SessionStart's +/// hookSpecificOutput.additionalContext). +/// +/// Gated behind (unset by default): never runs in CI or an ordinary +/// local run. Requires claude on PATH and logged in, its SessionStart hook already wired to +/// kcap, pointing at a reachable kcap server, and +/// kcap login already done against it. Run manually by a human/controller before +/// (re-)certifying this wiring. +/// +/// Claude Code's --print output shape is unverified against a live process for this +/// purpose — parses defensively (JSON-lines or plain text); +/// confirm/tighten it on the first live run and record the observed version +/// (). +/// +public class ClaudeMemoryIndexLiveCertTests { + const string LiveGateEnvVar = "KCAP_CLAUDE_MEMORY_LIVE"; + const string ServerUrlEnvVar = "KCAP_URL"; + + static readonly TimeSpan ProcessTimeout = TimeSpan.FromSeconds(90); + + // Both gated live tests are [NotInParallel]: they read/mutate the SAME process-global + // `disable_memory_index` profile config (a real `kcap config set` subprocess writing the + // actual config file this machine's `kcap` resolves), so — mirroring + // CursorMemoryIndexLiveCertTests' own precedent — they must never interleave with each other + // (a concurrent negative-control run could leave disable_memory_index=true set while the + // positive test is trying to observe an injection). + [Test, NotInParallel] + public async Task Nonce_saved_as_a_memory_is_reproduced_by_a_real_claude_sessionStart() { + SkipUnlessLiveGateReady(); + + var baseUrl = Environment.GetEnvironmentVariable(ServerUrlEnvVar)!; + var nonce = $"kcap-live-nonce-{Guid.NewGuid():N}"; + + using var client = await HttpClientExtensions.CreateAuthenticatedClientAsync(baseUrl); + var memoryId = await SaveNonceMemoryAsync(client, baseUrl, nonce); + + try { + await RecordClaudeVersionAsync(); + + var worktree = Directory.CreateTempSubdirectory("kcap-claude-memory-live-"); + try { + var answer = await RunClaudePrintAsync( + worktree.FullName, + "A team-memory index may have been injected into your context under a " + + "'## Team memory' heading. If a memory slug looks relevant, call get_memory " + + $"on it and reply with ONLY the exact string it contains matching this pattern: " + + $"kcap-live-nonce-<32 hex chars>. Reply with nothing else."); + + await Assert.That(answer).Contains(nonce); + } finally { + try { worktree.Delete(recursive: true); } catch { /* best-effort */ } + } + } finally { + await ArchiveMemoryAsync(client, baseUrl, memoryId); + } + } + + /// Negative control: with disable_memory_index set, the SAME nonce must NOT + /// reach the model — proving a false positive (e.g. the nonce leaking via some other channel, + /// or the assertion being trivially satisfiable) isn't what the positive test is actually + /// observing. + [Test, NotInParallel] + public async Task Disabled_memory_index_does_not_leak_the_nonce_to_a_real_claude_sessionStart() { + SkipUnlessLiveGateReady(); + + var baseUrl = Environment.GetEnvironmentVariable(ServerUrlEnvVar)!; + var nonce = $"kcap-live-nonce-{Guid.NewGuid():N}"; + + using var client = await HttpClientExtensions.CreateAuthenticatedClientAsync(baseUrl); + var memoryId = await SaveNonceMemoryAsync(client, baseUrl, nonce); + + // Capture the ORIGINAL disable_memory_index value BEFORE the first mutation, and put the + // mutate/run/restore sequence in a try/finally that begins right here — so a failed + // assertion (or the config-set call itself throwing) still restores the setting and + // archives the memory instead of leaking a "disabled" profile on this machine. + var originalDisableMemoryIndex = await ReadDisableMemoryIndexAsync(); + + try { + var configResult = await RunKcapConfigAsync("disable_memory_index", "true"); + await Assert.That(configResult.ExitCode).IsEqualTo(0); + + var worktree = Directory.CreateTempSubdirectory("kcap-claude-memory-live-negctrl-"); + try { + var answer = await RunClaudePrintAsync( + worktree.FullName, + $"Reply with ONLY the string kcap-live-nonce-<32 hex chars> if you can see it " + + "anywhere in your context; otherwise reply NONE."); + + await Assert.That(answer).DoesNotContain(nonce); + } finally { + try { worktree.Delete(recursive: true); } catch { /* best-effort */ } + } + } finally { + // Restore the ORIGINAL value, not an unconditional "false" — `kcap config` has no + // "unset" primitive, so an originally-absent (null) flag is restored as "false", + // which is observably identical (both read as "not disabled" via `is true`). + await RunKcapConfigAsync("disable_memory_index", (originalDisableMemoryIndex ?? false) ? "true" : "false"); + await ArchiveMemoryAsync(client, baseUrl, memoryId); + } + } + + /// + /// Reads the active profile's current disable_memory_index value via + /// kcap config show (JSON printed followed by a blank line and a "Path:" line — see + /// ConfigCommand.Show — so the JSON is the text before the first blank line). Returns + /// null if the value is unset (absent/JSON null) or the command failed. + /// + static async Task ReadDisableMemoryIndexAsync() { + var (exitCode, stdout, _) = await RunProcessAsync("kcap", ["config", "show"], workingDirectory: null); + if (exitCode != 0) return null; + + var jsonPart = ExtractLeadingJsonBlock(stdout); + + try { + var root = JsonNode.Parse(jsonPart); + var activeProfile = root?["active_profile"]?.GetValue(); + if (activeProfile is null) return null; + + return root?["profiles"]?[activeProfile]?["disable_memory_index"]?.GetValue(); + } catch { + return null; + } + } + + /// + /// Isolates the leading JSON block from kcap config show's stdout — the text before the + /// first blank line — tolerating both \n and Windows \r\n line endings so a CRLF + /// stdout doesn't fail to find the blank-line boundary (which would otherwise return the whole + /// blob, trailing "Path:" line included, and fail to parse as JSON). + /// + internal static string ExtractLeadingJsonBlock(string stdout) => + stdout.Replace("\r\n", "\n").Split("\n\n", 2)[0]; + + static void SkipUnlessLiveGateReady() { + Skip.Unless( + Environment.GetEnvironmentVariable(LiveGateEnvVar) == "1", + $"Gated live model-receipt certification — set {LiveGateEnvVar}=1 and {ServerUrlEnvVar}= " + + "to run (spends a real `claude --print` turn; requires `claude` on PATH with its SessionStart " + + "hook already wired to `kcap`, and `kcap login` already done against that server)."); + Skip.Unless( + !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ServerUrlEnvVar)), + $"{ServerUrlEnvVar} must point at a reachable kcap server exposing GET /api/memories/index."); + } + + /// + /// Saves a small, user-scoped, repo-independent ("global") memory whose description embeds + /// the nonce, via the same POST /api/memories contract kcap mcp memory's + /// save_memory tool uses () — reused here + /// rather than hand-building a second copy of the request shape. "global: true" sidesteps + /// needing a real repo-hash for this throwaway worktree. + /// + static async Task SaveNonceMemoryAsync(HttpClient client, string baseUrl, string nonce) { + var body = McpMemoryServer.BuildSaveBody(new JsonObject { + ["audience"] = "user", + ["slug"] = $"live-cert-{nonce}", + ["description"] = $"kcap claude memory live-cert nonce: {nonce}", + ["content"] = $"kcap claude memory live-cert nonce: {nonce}. Safe to archive after the run.", + ["kind"] = "reference", + ["global"] = true + }, cwdRepoHash: null, machineId: null); + + using var resp = await client.PostAsJsonAsync($"{baseUrl}/api/memories", body); + resp.EnsureSuccessStatusCode(); + var created = await resp.Content.ReadFromJsonAsync(); + return created?["memory_id"]?.GetValue() + ?? throw new InvalidOperationException("Save response carried no memory_id."); + } + + static async Task ArchiveMemoryAsync(HttpClient client, string baseUrl, string memoryId) { + try { + using var resp = await client.DeleteAsync($"{baseUrl}/api/memories/{Uri.EscapeDataString(memoryId)}"); + if (!resp.IsSuccessStatusCode) { + await Console.Error.WriteLineAsync( + $"[claude-memory-live] failed to archive live-cert memory {memoryId}: HTTP {(int)resp.StatusCode}"); + } + } catch (Exception ex) { + await Console.Error.WriteLineAsync($"[claude-memory-live] failed to archive live-cert memory {memoryId}: {ex.Message}"); + } + } + + static async Task RecordClaudeVersionAsync() { + try { + var (exitCode, stdout, _) = await RunProcessAsync("claude", ["--version"], workingDirectory: null); + await Console.Out.WriteLineAsync($"[claude-memory-live] claude --version (exit {exitCode}): {stdout.Trim()}"); + } catch (Exception ex) { + await Console.Error.WriteLineAsync($"[claude-memory-live] could not record claude version: {ex.Message}"); + } + } + + static async Task<(int ExitCode, string Stdout, string Stderr)> RunKcapConfigAsync(string key, string value) => + await RunProcessAsync("kcap", ["config", "set", key, value], workingDirectory: null); + + /// + /// Runs claude --print <prompt> in (a throwaway worktree — + /// this must be a real session start so the SAME sessionStart hook path under test actually + /// fires) and returns the parsed assistant answer. + /// + static async Task RunClaudePrintAsync(string cwd, string prompt) { + var (exitCode, stdout, stderr) = await RunProcessAsync("claude", ["--print", prompt], cwd); + await Console.Out.WriteLineAsync($"[claude-memory-live] claude exit={exitCode} stderr={stderr}"); + await Assert.That(exitCode).IsEqualTo(0); + return ExtractAssistantAnswer(stdout); + } + + /// + /// Claude Code's own --print output shape (plain text by default — no + /// --output-format flag is passed here) is NOT independently verified against a live + /// process for THIS purpose in this repo. Parses defensively: try JSON — either a single + /// object or newline-delimited JSON objects (in case a future default changes, or an ambient + /// environment variable forces one) — pulling the first string-valued + /// text/message/content/result field found; fall back to the raw + /// trimmed stdout if nothing parses. Tighten this the first time this test is actually run + /// live. + /// + internal static string ExtractAssistantAnswer(string stdout) { + var trimmed = stdout.Trim(); + if (trimmed.Length == 0) return trimmed; + + foreach (var line in trimmed.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { + if (line.Length == 0 || line[0] is not ('{' or '[')) continue; + try { + var node = JsonNode.Parse(line); + if (FindFirstTextField(node) is { } text) return text; + } catch { + // Not JSON (or not the shape we expect) — fall through to plain-text handling below. + } + } + + try { + var whole = JsonNode.Parse(trimmed); + if (FindFirstTextField(whole) is { } text) return text; + } catch { + // Plain text output — return as-is. + } + + return trimmed; + } + + static string? FindFirstTextField(JsonNode? node) { + switch (node) { + case JsonObject obj: + foreach (var key in new[] { "text", "message", "content", "answer", "result" }) { + if (obj[key] is JsonValue v && v.TryGetValue(out var s) && s.Length > 0) return s; + } + foreach (var (_, child) in obj) { + if (FindFirstTextField(child) is { } nested) return nested; + } + return null; + case JsonArray arr: + foreach (var item in arr) { + if (FindFirstTextField(item) is { } nested) return nested; + } + return null; + default: + return null; + } + } + + /// Test-only seam: notified with the PID of every process this method spawns, so the + /// orphan-cleanup test below can confirm a timed-out child is actually gone afterward. Never + /// set by production code paths. + internal static Action? OnProcessStarted; + + internal static async Task<(int ExitCode, string Stdout, string Stderr)> RunProcessAsync( + string fileName, IReadOnlyList args, string? workingDirectory, TimeSpan? timeout = null) { + var psi = new ProcessStartInfo(fileName) { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory + }; + foreach (var arg in args) psi.ArgumentList.Add(arg); + + using var process = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start '{fileName}'."); + OnProcessStarted?.Invoke(process.Id); + + using var timeoutCts = new CancellationTokenSource(timeout ?? ProcessTimeout); + try { + var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token); + var stderrTask = process.StandardError.ReadToEndAsync(timeoutCts.Token); + await process.WaitForExitAsync(timeoutCts.Token); + return (process.ExitCode, await stdoutTask, await stderrTask); + } finally { + // On normal completion the process has already exited; on a timeout cancellation (or + // any other exception mid-read) it may still be running — kill the whole tree so a + // hung `claude`/`kcap` child is never left orphaned. Best-effort: it may already have + // exited by the time we check. + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { /* already exited */ } + } + } +} + +/// +/// Ungated, CI-safe coverage for the pure +/// parser — the one piece of the live-cert scaffold that doesn't need a live process to exercise. +/// +public class ClaudeMemoryIndexLiveCertParsingTests { + [Test] + public async Task Plain_text_output_is_returned_as_is() { + var result = ClaudeMemoryIndexLiveCertTests.ExtractAssistantAnswer(" kcap-live-nonce-abc123 \n"); + await Assert.That(result).IsEqualTo("kcap-live-nonce-abc123"); + } + + [Test] + public async Task Single_json_object_with_a_result_field_extracts_the_text() { + var result = ClaudeMemoryIndexLiveCertTests.ExtractAssistantAnswer( + """{"type":"result","result":"kcap-live-nonce-abc123"}"""); + await Assert.That(result).IsEqualTo("kcap-live-nonce-abc123"); + } + + [Test] + public async Task Newline_delimited_json_stream_extracts_the_first_matching_text_field() { + var stdout = """ + {"type":"system","subtype":"init"} + {"type":"assistant","message":{"content":[{"type":"text","text":"kcap-live-nonce-abc123"}]}} + """; + var result = ClaudeMemoryIndexLiveCertTests.ExtractAssistantAnswer(stdout); + await Assert.That(result).IsEqualTo("kcap-live-nonce-abc123"); + } + + [Test] + public async Task Empty_output_returns_empty() { + var result = ClaudeMemoryIndexLiveCertTests.ExtractAssistantAnswer(" \n "); + await Assert.That(result).IsEqualTo(""); + } + + [Test] + public async Task Leading_json_block_extraction_splits_on_an_lf_blank_line() { + var stdout = "{\"active_profile\":\"default\"}\n\nPath: ~/.config/kcap/config.json\n"; + var result = ClaudeMemoryIndexLiveCertTests.ExtractLeadingJsonBlock(stdout); + await Assert.That(result).IsEqualTo("{\"active_profile\":\"default\"}"); + } + + [Test] + public async Task Leading_json_block_extraction_tolerates_a_crlf_blank_line() { + // Windows `kcap config show` stdout: the same "JSON, blank line, Path:" shape but with + // \r\n line endings — must still isolate the JSON, not the whole CRLF blob. + var stdout = "{\"active_profile\":\"default\"}\r\n\r\nPath: C:\\Users\\me\\config.json\r\n"; + var result = ClaudeMemoryIndexLiveCertTests.ExtractLeadingJsonBlock(stdout); + await Assert.That(result).IsEqualTo("{\"active_profile\":\"default\"}"); + } +} + +/// +/// Ungated, CI-safe coverage for 's +/// timeout-cleanup path — the other piece of the live-cert scaffold that doesn't need a live +/// `claude`/`kcap` process to exercise. +/// +public class ClaudeMemoryIndexLiveCertProcessTests { + [Test] + public async Task A_timed_out_process_is_killed_rather_than_left_running() { + // A process that vastly outlives the tiny timeout below, spawned the same cross-platform + // way as Daemon.DummyProcess.StartSleep (ping's ~1s cadence needs no stdin, unlike + // `timeout /t`, so it reliably stays alive on a headless CI runner). + var (fileName, args) = OperatingSystem.IsWindows() + ? ("cmd.exe", (IReadOnlyList) ["/c", "ping", "-n", "120", "127.0.0.1"]) + : ("sleep", (IReadOnlyList) ["120"]); + + int? pid = null; + ClaudeMemoryIndexLiveCertTests.OnProcessStarted = p => pid = p; + try { + await Assert.ThrowsAsync(() => + ClaudeMemoryIndexLiveCertTests.RunProcessAsync( + fileName, args, workingDirectory: null, TimeSpan.FromMilliseconds(200))); + } finally { + ClaudeMemoryIndexLiveCertTests.OnProcessStarted = null; + } + + await Assert.That(pid).IsNotNull(); + + // Confirm the child is actually gone (not merely that our await unblocked) — a brief + // grace window covers the OS finishing the teardown after the kill signal. + var deadline = DateTime.UtcNow.AddSeconds(5); + var stillAlive = true; + while (DateTime.UtcNow < deadline) { + try { + using var proc = System.Diagnostics.Process.GetProcessById(pid!.Value); + if (proc.HasExited) { stillAlive = false; break; } + } catch (ArgumentException) { + stillAlive = false; // no such process — already reaped + break; + } + await Task.Delay(50); + } + + await Assert.That(stillAlive).IsFalse(); + } +}