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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ jobs:
strategy:
fail-fast: false
matrix:
# linux-x64 + windows-x64. macOS is NOT in this matrix: the unit suite has
# a known cluster of macOS-local temp-path/parallel-load flakes that would
# make a macOS leg permanently red. macOS arm64 is covered by maintainer
# runs; macOS x64 is intentionally untested. Platform-sensitive daemon
# behavior that must hold everywhere (e.g. the borrowed-review capability
# advertisement, which no longer carries any OS/arch precondition) is
# additionally pinned by host-independent source guards in the unit suite —
# see DaemonRunnerCursorAvailabilityTests' platform coverage matrix.
os: [ubuntu-latest, windows-latest]

steps:
Expand Down
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@ registers `kcap-flows` without auto-approval and tracks only newly-created globa
the string unattended-vendor list for compatibility and additionally advertise structured
per-vendor CLI/launcher-policy capabilities. Cursor serves borrowed review context from a
daemon-owned snapshot (dirty tracked and non-ignored untracked files, refreshed between rounds),
because its zero-interaction modes may write; Claude borrowed-worktree review remains uncertified
because its zero-interaction modes may write; Claude has no borrowed-review containment strategy
and therefore fails closed to an owned worktree.

Borrowed-review capability is **trust-by-default**: a vendor advertises it whenever its runtime
factory declares a containment strategy, for whatever build of the vendor CLI is installed and on
every platform. It is deliberately not gated on the installed binary matching a validated-build
record — a vendor auto-update would then silently withdraw the capability and reviewers would fall
back to a stale committed base. The daemon logs the CLI version it probed at startup (a startup
observation, not a launch-time fact) and does no automated drift detection; a defective vendor
release is handled by a human report and a corrected record. See
`docs/superpowers/specs/2026-07-27-ai1528-trust-by-default-borrowed-review-design.md` in kcap-server.

## Tech stack

- .NET 10, NativeAOT compiled
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ Stdio MCP server that lets coding agents start and interact with AI-powered agen

It provides four generic tools:

- **`start_flow`** — start a new flow from either `definition_id` (the server's flow-definition catalog, e.g. `spec-review`, `code-review`, or a custom definition) **or** `definition_yaml` (an inline dynamic flow definition — the full YAML, same schema as catalog definitions); provide exactly one of the two. Catalog starts use the guarded v2 protocol. A `definition_yaml` flow has extra constraints: every participant must declare `workspace: none` and a concrete, priced model (no `default`), the server clamps `limits`/`mcp` to its own caps rather than trusting the definition, may reject the whole thing with a coded error, and requires a server with dynamic flows enabled. Also provide `target_kind`, `target_ref`, `target_title`, and `context`. Requester context (session ID, cwd, repo root, owner, name) is resolved automatically from the environment. Returns a `flow_run_id`. A single-participant definition starts its first round eagerly; a multi-participant definition starts **round-less** — the response carries no round, and each declared role's agent launches lazily on its first `send_to_participant`. Optional `mode`: by default, when the daemon runs on the same machine and the selected vendor advertises a certified borrowed-context strategy, the participant sees current tracked, dirty, and non-ignored untracked checkout content; the safety boundary is vendor-specific. Cursor runs in a daemon-owned snapshot refreshed before follow-up rounds, while direct borrowing is reserved for runtimes with a certified native read-only clamp. Pass `mode="context-only"` to opt out. Claude review flows currently use owned worktrees because borrowed-checkout review is not certified. Optional `vendor`: for reserved `spec-review`/`code-review`, explicitly selects the reviewer independently of the driver; when omitted the server uses `Flows:Review:DefaultVendor`. Custom single-participant catalog definitions retain their authored vendor unless explicitly overridden. The server records requested/applied vendor plus selection source, and rejects an unavailable or uncertified vendor without silent fallback. Dynamic (`definition_yaml`) flows reject a top-level vendor override because each participant declares its own vendor. Optional `model`: a per-run reviewer **model** override for a single-participant catalog review — REQUIRES `vendor` (the model is interpreted against that vendor; there is no vendor→model table anywhere in the CLI, so `model` without `vendor` is rejected locally before any request is sent) and is rejected on a `definition_yaml` (dynamic) or multi-participant start (each participant already pins its own model). Pass the vendor's own model id or alias verbatim, case-sensitive — the CLI never translates, canonicalizes, or guesses it.
- **`start_flow`** — start a new flow from either `definition_id` (the server's flow-definition catalog, e.g. `spec-review`, `code-review`, or a custom definition) **or** `definition_yaml` (an inline dynamic flow definition — the full YAML, same schema as catalog definitions); provide exactly one of the two. Catalog starts use the guarded v2 protocol. A `definition_yaml` flow has extra constraints: every participant must declare `workspace: none` and a concrete, priced model (no `default`), the server clamps `limits`/`mcp` to its own caps rather than trusting the definition, may reject the whole thing with a coded error, and requires a server with dynamic flows enabled. Also provide `target_kind`, `target_ref`, `target_title`, and `context`. Requester context (session ID, cwd, repo root, owner, name) is resolved automatically from the environment. Returns a `flow_run_id`. A single-participant definition starts its first round eagerly; a multi-participant definition starts **round-less** — the response carries no round, and each declared role's agent launches lazily on its first `send_to_participant`. Optional `mode`: by default, when the daemon runs on the same machine and the selected vendor declares a borrowed-context containment strategy, the participant sees current tracked, dirty, and non-ignored untracked checkout content; the safety boundary is vendor-specific. Cursor runs in a daemon-owned snapshot refreshed before follow-up rounds, while direct borrowing is reserved for runtimes with a native read-only tool clamp. The capability is advertised for whatever build of the vendor CLI is installed, on every platform where it is installed — it is not gated on a per-version validation record, since a vendor auto-update would then silently drop the participant back onto a stale committed base. Pass `mode="context-only"` to opt out. Claude review flows currently use owned worktrees because it declares no borrowed-review containment strategy. Optional `vendor`: for reserved `spec-review`/`code-review`, explicitly selects the reviewer independently of the driver; when omitted the server uses `Flows:Review:DefaultVendor`. Custom single-participant catalog definitions retain their authored vendor unless explicitly overridden. The server records requested/applied vendor plus selection source, and rejects an unavailable or uncertified vendor without silent fallback. Dynamic (`definition_yaml`) flows reject a top-level vendor override because each participant declares its own vendor. Optional `model`: a per-run reviewer **model** override for a single-participant catalog review — REQUIRES `vendor` (the model is interpreted against that vendor; there is no vendor→model table anywhere in the CLI, so `model` without `vendor` is rejected locally before any request is sent) and is rejected on a `definition_yaml` (dynamic) or multi-participant start (each participant already pins its own model). Pass the vendor's own model id or alias verbatim, case-sensitive — the CLI never translates, canonicalizes, or guesses it.
- **`send_to_participant`** — send a follow-up message to a participant role declared by the flow definition (single-participant definitions use `"reviewer"`; the server rejects an unknown role, naming the valid ones). One round runs at a time per role — a second send to a busy role is rejected naming the busy round, while other roles remain addressable. Returns the new round's findings.
- **`get_flow_status`** — get the current status (running, waiting, completed, failed) and last result of a flow run.
- **`close_flow`** — mark a completed flow run as closed.
Expand Down
44 changes: 38 additions & 6 deletions src/Capacitor.Cli.Daemon/DaemonRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@ await Console.Error.WriteLineAsync(
config.UnattendedVendors = ComputeUnattendedVendors(runtimeFactories);
config.UnattendedVendorCapabilities = ComputeUnattendedVendorCapabilities(runtimeFactories, config);

// Which build of each unattended vendor was installed when this daemon started. Recorded at
// startup (like the Cursor-unavailable warning below) rather than per launch, and reported
// without any comparison against a validated-build record.
LogUnattendedVendorIdentities(logger, config.UnattendedVendorCapabilities);

// IsAvailable()==false silently omits cursor from SupportedVendors above — correct
// behavior (the launch dialog just won't offer Cursor), but gave operators no clue WHY. One
// Warning at startup (not per-launch) so a missing/misconfigured cursor-agent install is
Expand Down Expand Up @@ -604,12 +609,12 @@ internal static IReadOnlyList<UnattendedVendorCapability> ComputeUnattendedVendo
"copilot" => (config.CopilotPath, CopilotLauncherPolicyVersion),
_ => ("", $"{vendor}-unattended-v1")
};
var cursorArtifact = vendor == "cursor"
? CursorBorrowedReviewCertification.TryCertify(cliPath)
: null;
var borrowedSupported = vendor == "cursor"
? factory.SupportsBorrowedReviewFlow && cursorArtifact is not null
: factory.SupportsBorrowedReviewFlow;
// Trust-by-default: a vendor's borrowed-review capability is a property of its FACTORY,
// never of the installed build's identity. Gating this on an exact-build match made an
// ordinary vendor auto-update silently withdraw the capability (and the server then
// resolved workspace_mode=fallback, reviewing a stale committed base with nobody told).
// See docs/superpowers/specs/2026-07-27-ai1528-trust-by-default-borrowed-review-design.md.
var borrowedSupported = factory.SupportsBorrowedReviewFlow;
// Reviewer MODEL override support: advertised true ONLY when this vendor — already known
// installed + unattended-certified by ComputeUnattendedVendors above — also carries a
// runtime-owned resolver. Vendor-neutral: we read the factory's resolver + its policy
Expand All @@ -628,6 +633,30 @@ internal static IReadOnlyList<UnattendedVendorCapability> ComputeUnattendedVendo
return capabilities;
}

/// <summary>Rendered in place of a <c>CliVersion</c> the probe could not determine (spawn
/// failure, timeout, empty output, unparseable output). It is a reporting placeholder ONLY — an
/// unidentifiable build is still a trusted build and its capabilities are unaffected.</summary>
internal const string UnknownCliVersion = "unknown";

/// <summary>
/// One Information line per unattended vendor recording the CLI version probed at daemon
/// startup, so an operator reading the daemon's own log can tell which build was installed when
/// this daemon came up. Deliberately reports the version and NOTHING else: it does not compare
/// the installed build against any validated-build record (that would be exactly the automated
/// version-drift detection this design rejects), and no equivalent line is emitted per launch.
/// Pure over the computed capabilities — same reasoning as
/// <see cref="ShouldWarnCursorUnavailable"/> — so it is testable without booting the DI host.
/// See docs/superpowers/specs/2026-07-27-ai1528-trust-by-default-borrowed-review-design.md.
/// </summary>
internal static void LogUnattendedVendorIdentities(
ILogger logger, IEnumerable<UnattendedVendorCapability> capabilities) {
foreach (var capability in capabilities)
LogUnattendedVendorIdentity(
logger,
capability.Vendor,
string.IsNullOrWhiteSpace(capability.CliVersion) ? UnknownCliVersion : capability.CliVersion);
}

internal static string? ProbeCliVersion(string cliPath) {
try {
using var process = Process.Start(new ProcessStartInfo {
Expand Down Expand Up @@ -677,6 +706,9 @@ static bool MatchVersionTerm(Version version, string term) {
[LoggerMessage(Level = LogLevel.Information, Message = "kcap daemon '{Name}' starting, connecting to {ServerUrl}")]
static partial void LogDaemonStarting(ILogger logger, string name, string serverUrl);

[LoggerMessage(Level = LogLevel.Information, Message = "Unattended vendor '{Vendor}': CLI version {CliVersion}, as observed by probing the configured binary at daemon startup. That is a startup observation, not the build a later reviewer runs — if the vendor updates while this daemon keeps running, launches pick up the new build and this line stays stale until the daemon restarts.")]
static partial void LogUnattendedVendorIdentity(ILogger logger, string vendor, string cliVersion);

[LoggerMessage(Level = LogLevel.Warning, Message = "Cursor ACP runtime unavailable: cursor-agent CLI not found (looked for '{CursorPath}'). Cursor will not be offered as a hosted-agent vendor until this is fixed. Set KCAP_CURSOR_PATH to the cursor-agent executable, or install the Cursor CLI, then restart the daemon.")]
static partial void LogCursorUnavailable(ILogger logger, string cursorPath);

Expand Down
25 changes: 14 additions & 11 deletions src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntimeFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ internal sealed partial class AcpHostedAgentRuntimeFactory(
descriptor.BorrowedReviewContainment == AcpBorrowedReviewContainment.IndependentSnapshot;
public string? BorrowedReviewContainment => descriptor.BorrowedReviewContainment switch {
AcpBorrowedReviewContainment.NativeToolClamp => "native-tool-clamp",
AcpBorrowedReviewContainment.IndependentSnapshot => CursorBorrowedReviewCertification.Containment,
AcpBorrowedReviewContainment.IndependentSnapshot => CursorBorrowedReviewValidation.Containment,
_ => null
};

Expand All @@ -65,7 +65,7 @@ public async Task<HostedRuntimeStart> StartAsync(RuntimeStartContext ctx, Cancel
LogLaunching(ctx.AgentId, Vendor, ctx.Worktree.Path);
AcpMetrics.Launches.Add(1);

ValidateBorrowedArtifact(ctx, descriptor, config);
ValidateBorrowedArtifact(ctx, descriptor);

// Fail closed BEFORE _connectionSource spawns a child (a later gate would leak one). Null for
// a non-review launch; the built MCP list for a valid review flow.
Expand Down Expand Up @@ -228,10 +228,11 @@ internal static ProcessStartInfo BuildProcessStartInfo(
}
}

var binaryPath = descriptor.Vendor == "cursor" && ctx.IsBorrowedSnapshot
? CursorBorrowedReviewCertification.TryCertify(descriptor.ResolveBinaryPath(config))?.LauncherPath
?? throw new InvalidOperationException("cursor_borrowed_artifact_not_certified")
: descriptor.ResolveBinaryPath(config);
// Every vendor — borrowed-snapshot review included — spawns the ordinary configured binary.
// Resolving a borrowed reviewer through an exact-build record instead made a vendor
// auto-update hard-fail the launch. See
// docs/superpowers/specs/2026-07-27-ai1528-trust-by-default-borrowed-review-design.md.
var binaryPath = descriptor.ResolveBinaryPath(config);
var psi = new ProcessStartInfo(binaryPath, argv) {
WorkingDirectory = ctx.Worktree.Path,
RedirectStandardInput = true,
Expand All @@ -247,14 +248,16 @@ internal static ProcessStartInfo BuildProcessStartInfo(
return psi;
}

static void ValidateBorrowedArtifact(
RuntimeStartContext ctx, AcpVendorDescriptor descriptor, DaemonConfig config) {
/// <summary>Pre-spawn check that the descriptor's DECLARED containment agrees with the launch
/// context it was handed — a code-level invariant (a snapshot-materialized launch reaching a
/// vendor that does not declare independent-snapshot containment is a wiring bug). It is
/// deliberately NOT a build-identity check: capability is advertised for whatever build is
/// installed, so nothing here may consult a version record. See
/// docs/superpowers/specs/2026-07-27-ai1528-trust-by-default-borrowed-review-design.md.</summary>
static void ValidateBorrowedArtifact(RuntimeStartContext ctx, AcpVendorDescriptor descriptor) {
if (!ctx.IsReviewFlow || !ctx.IsBorrowedSnapshot) return;
if (descriptor.BorrowedReviewContainment != AcpBorrowedReviewContainment.IndependentSnapshot)
throw new InvalidOperationException("borrowed_snapshot_containment_mismatch");
if (descriptor.Vendor == "cursor" &&
CursorBorrowedReviewCertification.TryCertify(descriptor.ResolveBinaryPath(config)) is null)
throw new InvalidOperationException("cursor_borrowed_artifact_not_certified");
}

/// <summary>Builds Copilot CLI's process-level stdio MCP config. Copilot's ACP capability
Expand Down
Loading
Loading