diff --git a/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs index 7db7e84e2b..b7584add6d 100644 --- a/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs @@ -342,6 +342,19 @@ sealed class ResolvedEventKindSignal { // Skipped for a linked subagent child — its top-level sessionStart is never // posted (see below), so there is no session to attach a repository to. if (eventName == "sessionStart" && !isSubagentChild) { + // Stamp the active profile's default visibility BEFORE the enrichment round-trip + // below re-serializes `node`. Server-side VisibilityService treats a null + // default_visibility as "org-repo fallback", so without this a private-default + // user's Cursor sessions silently go org-visible. Budget-bounded + fail-open: the + // hook must never block the agent loop (the import path stamps this separately). + try { + if (!BudgetExpired() + && (await AppConfig.GetActiveProfileAsync(ct))?.DefaultVisibility is { } defaultVisibility) + node["default_visibility"] = defaultVisibility; + } catch { + // fail-open — visibility is best-effort, never fatal to the hook. + } + // Safe extract: workspace_roots[0] may be absent or a non-string; GetValue // would throw and (via the outer catch) drop the whole sessionStart hook. if (node["workspace_roots"] is JsonArray roots && roots.Count > 0 diff --git a/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs new file mode 100644 index 0000000000..9cdaa56854 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Nodes; +using Capacitor.Cli.Commands.Harness; +using Capacitor.Cli.Core.Config; +using Capacitor.Cli.Core; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Capacitor.Cli.Tests.Integration; + +/// +/// A live Cursor sessionStart hook stamps the active profile's default_visibility +/// onto the payload (mirrors CodexSessionStartVisibilityTests). workspace_roots is +/// set so the enrichment reserialization actually runs — the round-trip the stamp must survive. +/// See #579. +/// +public class CursorSessionStartVisibilityTests : IDisposable { + readonly WireMockServer _server = WireMockServer.Start(); + readonly string _configPath = PathHelpers.ConfigPath("config.json"); + readonly string? _previousConfig; + + public CursorSessionStartVisibilityTests() { + _previousConfig = File.Exists(_configPath) ? File.ReadAllText(_configPath) : null; + } + + public void Dispose() { + _server.Stop(); + + if (_previousConfig is null) { + if (File.Exists(_configPath)) File.Delete(_configPath); + } else { + File.WriteAllText(_configPath, _previousConfig); + } + } + + async Task RunSessionStartAndCaptureBodyAsync(string defaultVisibility) { + var config = new ProfileConfig { + ActiveProfile = "work", + Profiles = new() { + ["work"] = new Profile { + ServerUrl = _server.Url, + DefaultVisibility = defaultVisibility + } + } + }; + await ConfigMutator.MutateAsync(_ => config); + + _server.Given(Request.Create().WithPath("/hooks/session-start/cursor").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); + // Best-effort side calls (memory index, auth) must never fail the test. + _server.Given(Request.Create().WithPath("/api/*").UsingAnyMethod()) + .RespondWith(Response.Create().WithStatusCode(404)); + _server.Given(Request.Create().WithPath("/auth/config").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("""{"provider":"None"}""")); + + // workspace_roots present → the enrichment reparse runs (the round-trip the stamp must + // survive); transcript_path omitted → no watcher spawn. + using var tmp = new TempDir(); + var body = + $$""" + { + "hook_event_name": "sessionStart", + "session_id": "cursorvistestsession", + "model": "claude-3.5-sonnet", + "workspace_roots": ["{{tmp.Path.Replace("\\", "\\\\")}}"] + } + """; + + using var client = new HttpClient(); + var spool = new HookSpool(tmp.CreateDir("spool").Path); + + var exit = await CursorHookCommand.HandleCore(client, _server.Url!, new StringReader(body), spool, TimeSpan.FromSeconds(2)); + await Assert.That(exit).IsEqualTo(0); + + var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/cursor").UsingPost()); + await Assert.That(requests.Count).IsEqualTo(1); + + return JsonNode.Parse(requests[0].RequestMessage.Body!)!; + } + + [Test, NotInParallel("AppConfig_FileState")] + public async Task SessionStart_stamps_default_visibility_from_active_profile() { + var body = await RunSessionStartAndCaptureBodyAsync("private"); + await Assert.That(body["default_visibility"]?.GetValue()).IsEqualTo("private"); + } + + [Test, NotInParallel("AppConfig_FileState")] + public async Task SessionStart_stamps_the_profiles_configured_visibility_value() { + // Fidelity: the stamped value is the profile's configured visibility, not a hardcoded + // constant — a different profile value must round-trip verbatim. + var body = await RunSessionStartAndCaptureBodyAsync("public"); + await Assert.That(body["default_visibility"]?.GetValue()).IsEqualTo("public"); + } +}