Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
} 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<string>
// would throw and (via the outer catch) drop the whole sessionStart hook.
if (node["workspace_roots"] is JsonArray roots && roots.Count > 0
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A live Cursor <c>sessionStart</c> hook stamps the active profile's <c>default_visibility</c>
/// onto the payload (mirrors <c>CodexSessionStartVisibilityTests</c>). <c>workspace_roots</c> is
/// set so the enrichment reserialization actually runs — the round-trip the stamp must survive.
/// See #579.
/// </summary>
public class CursorSessionStartVisibilityTests : IDisposable {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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<JsonNode> 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<string>()).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<string>()).IsEqualTo("public");
}
}