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

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,9 @@

// Phase B (D4): durable PID records + this daemon's logical identity/epoch for
// crash-survivor reaping. Initialized in the ctor from config.
AgentPidRecordStore? _pidRecords;

Check warning on line 223 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 223 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 223 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / AOT publish check (src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)
AgentKillQuarantine? _quarantine;

Check warning on line 224 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 224 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 224 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / AOT publish check (src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)
OrphanReaper? _orphanReaper;

Check warning on line 225 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 225 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 225 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / AOT publish check (src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

// Tail-of-PTY capture for a FAILED launch, under the same per-daemon record root as the PID
// records ({state}/{name}/agents/failed/) — survives worktree teardown for post-mortem.
Expand Down Expand Up @@ -2359,7 +2359,13 @@
throw new InvalidOperationException($"borrow_auth_failed: {auth.Reason ?? "source_identity_changed"}");
var generation = await _worktreeManager.SyncBorrowedSnapshotFromSourceAsync(
agent.Worktree.SourceRepo, agent.Worktree.SnapshotRoot ?? agent.Worktree.Path,
agent.Worktree.Path, [], agent.Worktree.ReviewContextRoot
// The prefix computed at creation, carried — never re-derived. The only path available
// here is the TARGET-side execution path, and deriving from that is what lets the launch
// cwd and the exclusion classifier end up on two different spellings.
agent.Worktree.GitRelativeCwd
?? throw new InvalidOperationException(
"borrowed_snapshot_git_relative_cwd_missing"),
[], agent.Worktree.ReviewContextRoot
?? throw new InvalidOperationException(
"borrowed_snapshot_review_context_missing"), timeout.Token);
var reviewerToken = agent.ReviewerBridgeToken
Expand Down
331 changes: 331 additions & 0 deletions src/Capacitor.Cli.Daemon/Services/WorktreeManager.ExclusionPlan.cs

Large diffs are not rendered by default.

71 changes: 61 additions & 10 deletions src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,36 @@
public const string ReviewContextSuffix = ".review-context";
const string ReviewContextManifestName = "manifest.json";
const long MaxReviewContextBytes = 256L * 1024;

/// <summary>Ceiling on the SERIALIZED manifest, distinct from <see cref="MaxReviewContextBytes"/>,
/// which charges only blob content. Path strings, base64's 4/3 expansion and JSON framing are not free,
/// so the content cap is not by itself a bound on the file this writes and later re-reads.
///
/// <para><b>Derived from the content cap, not chosen.</b> Each admitted byte can appear twice — once
/// base64-encoded (4/3) and once in <c>Text</c>, where JSON escaping of a control character costs six
/// bytes (<c>backslash-u-0000</c>). Worst case is therefore about <c>256 KiB × (4/3 + 6) ≈ 1.9 MiB</c> before
/// paths, hashes and framing. A first attempt at 1 MiB was below that and rejected a manifest the
/// content cap had already accepted — a fail-closed refusal of a legitimate snapshot. 4 MiB clears the
/// worst case with headroom while still bounding the read.</para></summary>
const long MaxReviewContextManifestBytes = 4L * 1024 * 1024;

static readonly UTF8Encoding StrictUtf8 = new(false, true);

/// <summary>Reads a manifest, refusing anything past <see cref="MaxReviewContextManifestBytes"/>
/// BEFORE allocating it — checking after the read would already have paid the cost.</summary>
static async Task<byte[]> ReadManifestBytesAsync(string path, CancellationToken ct) {
await using var stream = new FileStream(path, new FileStreamOptions {
Mode = FileMode.Open, Access = FileAccess.Read, Share = FileShare.Read,
Options = FileOptions.Asynchronous | FileOptions.SequentialScan
});
if (stream.Length > MaxReviewContextManifestBytes)
throw new InvalidOperationException(
"borrowed_snapshot_review_context_manifest_too_large");
var buffer = new byte[stream.Length];
await stream.ReadExactlyAsync(buffer, ct);
return buffer;
}

public static string ReviewContextRootFor(string snapshotRoot) =>
snapshotRoot.TrimEnd(Path.DirectorySeparatorChar) + ReviewContextSuffix;

Expand All @@ -52,17 +80,17 @@
return generation with { StoragePath = published };
}

async Task<BorrowedReviewContextGeneration> CreateReviewContextGenerationAsync(

Check warning on line 83 in src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

Member 'CreateReviewContextGenerationAsync' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 83 in src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

Member 'CreateReviewContextGenerationAsync' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 83 in src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs

View workflow job for this annotation

GitHub Actions / AOT publish check (src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj)

Member 'CreateReviewContextGenerationAsync' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
string source, string reviewContextRoot, string sourceHead,
byte[] listing, bool caseSensitive, CancellationToken ct) {
byte[] listing, bool caseSensitive, SnapshotExclusionPlan plan, CancellationToken ct) {
CreateOwnerOnlyDirectory(reviewContextRoot);
var generationId = Guid.NewGuid().ToString("N");
var preparing = Path.Combine(reviewContextRoot, ".preparing-" + generationId);

try {
CreateOwnerOnlyDirectory(preparing);
var entries = await ExtractReviewContextEntriesAsync(
source, listing, caseSensitive, ct);
source, listing, caseSensitive, plan, ct);

var manifest = new BorrowedReviewContextManifest(
1,
Expand All @@ -75,14 +103,28 @@
[.. entries.OrderBy(entry => entry.Path, StringComparer.Ordinal)]);
var json = JsonSerializer.SerializeToUtf8Bytes(
manifest, BorrowedReviewContextJsonContext.Default.BorrowedReviewContextManifest);
// MaxReviewContextBytes charges only blob CONTENT. The serialized form also carries path
// strings, base64's 4/3 expansion and JSON framing, so it needs its own ceiling — enforced
// here on write and again before parsing on read, so an oversized manifest is refused before
// it is allocated rather than after.
if (json.LongLength > MaxReviewContextManifestBytes)
throw new InvalidOperationException(
"borrowed_snapshot_review_context_manifest_too_large");
var manifestPath = Path.Combine(preparing, ReviewContextManifestName);
await WriteOwnerOnlyFileAsync(manifestPath, json, ct);

var verifiedJson = await File.ReadAllBytesAsync(manifestPath, ct);
var verifiedJson = await ReadManifestBytesAsync(manifestPath, ct);
var verifiedManifest = JsonSerializer.Deserialize(
verifiedJson, BorrowedReviewContextJsonContext.Default.BorrowedReviewContextManifest)
?? throw new InvalidOperationException("borrowed_snapshot_review_context_invalid_manifest");
ValidateReviewContextManifest(verifiedManifest, generationId, sourceHead);
// The reserved set the extractor actually matched — the ACTUAL git paths, not the plan's
// canonical spellings. On a case-insensitive destination a tracked `SRC/.MCP.JSON` legitimately
// classifies against canonical `src/.mcp.json`, so validating exact membership against the
// canonical set would reject a valid entry — and relaxing it to OrdinalIgnoreCase would put a
// second matcher back in, which is the defect this design removes. The case decision is made
// once, by the classifier, at extraction.
var matchedPaths = entries.Select(entry => entry.Path).ToHashSet(StringComparer.Ordinal);
ValidateReviewContextManifest(verifiedManifest, generationId, sourceHead, matchedPaths);

return new BorrowedReviewContextGeneration(generationId, preparing, verifiedJson);
} catch {
Expand All @@ -92,10 +134,13 @@
}

static async Task<List<BorrowedReviewContextEntry>> ExtractReviewContextEntriesAsync(
string source, byte[] listing, bool caseSensitive, CancellationToken ct) {
var reserved = WorkspaceMcpConfigPaths
.Select(path => (Canonical: path, Bytes: Encoding.UTF8.GetBytes(path)))
.ToArray();
string source, byte[] listing, bool caseSensitive, SnapshotExclusionPlan plan,
CancellationToken ct) {
// The plan's set, not WorkspaceMcpConfigPaths: containment and reviewability have to range over
// the same paths, or a config one directory down becomes excluded from the snapshot (good) while
// staying invisible to the reviewer (bad) — contained but unreviewable, which is precisely the
// state this whole surface exists to avoid.
var reserved = plan.Reserved;
var matchedCanonicalPaths = new HashSet<string>(StringComparer.Ordinal);
var entries = new List<BorrowedReviewContextEntry>();
long totalBytes = 0;
Expand Down Expand Up @@ -192,18 +237,24 @@
static void ValidateReviewContextManifest(
BorrowedReviewContextManifest manifest,
string expectedGenerationId,
string expectedSourceHead) {
string expectedSourceHead,
IReadOnlySet<string> matchedPaths) {
if (manifest.SchemaVersion != 1 ||
manifest.GenerationId != expectedGenerationId ||
manifest.SourceHead != expectedSourceHead ||
manifest.Provenance != "git-index-stage-0" ||
manifest.WorkingTreeBytes ||
!manifest.UnstagedAndUntrackedOmitted ||
manifest.Entries.Length > WorkspaceMcpConfigPaths.Length)
manifest.Entries.Length > matchedPaths.Count)
throw new InvalidOperationException(
"borrowed_snapshot_review_context_invalid_manifest");
long total = 0;
foreach (var entry in manifest.Entries) {
// Exact membership in the set the classifier actually matched. Strictly stronger than the
// count cap this replaces, which bounded how many entries there were but not which.
if (!matchedPaths.Contains(entry.Path))
throw new InvalidOperationException(
"borrowed_snapshot_review_context_invalid_manifest");
byte[] content;
try { content = Convert.FromBase64String(entry.Base64); }
catch (FormatException ex) {
Expand Down
16 changes: 14 additions & 2 deletions src/Capacitor.Cli.Daemon/Services/WorktreeManager.WorkspaceMcp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,27 @@ public partial class WorktreeManager {
/// protected by their own argv, a property of each launcher rather than of the worktree. Kiro arrived
/// with no gate at all and nobody noticed, so the list covers every hosted vendor's file plus the
/// editor-generic ones — the point is that the next vendor is safe before anyone thinks about it.</para>
///
/// <para><b>These are the names; the SCOPE is separate.</b> Every entry is relative to a directory,
/// not to the repository root. For a borrowed snapshot the set of directories is the ancestor chain of
/// the execution cwd — see <see cref="PlanSnapshotExclusions"/>. Reading this list as root-relative is
/// what left <c>src/.codex/config.toml</c> live in a snapshot launched from <c>src</c>.</para>
/// </summary>
internal static readonly ImmutableArray<string> WorkspaceMcpConfigPaths = [
".mcp.json", // Claude Code / generic
".mcp.json", // Claude Code / generic; Copilot CLI also reads it
".cursor/mcp.json",
".gemini/settings.json",
".kiro/settings/mcp.json",
".vscode/mcp.json", // editor-generic; several CLIs read it
".vscode/mcp.json", // editor-generic; GitHub documents Copilot CLI does NOT read it,
// but VS Code and other CLIs do
".github/mcp.json", // Copilot CLI, alongside .mcp.json in the same walk. The list
// long carried .github/copilot/mcp.json, a DIFFERENT path, so this
// one was unprotected at every snapshot root.
".github/copilot/mcp.json",
".copilot/mcp.json",
".copilot/mcp-config.json", // GitHub documents ~/.copilot/mcp-config.json as USER scope; the
// workspace form is not documented and is carried under the
// "wider than known readers" rationale above
".codex/config.toml"
];

Expand Down
Loading
Loading