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
20 changes: 15 additions & 5 deletions backend/Dockerfile.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
# EXCLUSIVELY in the worker image (backend/Dockerfile.worker — the local sandbox runner). Deploy the two images as
# SEPARATE deployments so each scales (replica) independently: the API on HTTP load, the worker on job throughput.
#
# Because this image is INTERNET-FACING it is kept minimal — NO git, NO bubblewrap/prlimit, NO harness CLIs (those
# are agent-execution + isolation deps that belong only on the worker). Verified 2026-06: every git / workspace-clone
# path lives under Services/Agents/* (the executor, workspace providers, integrators, acceptance grader), all of
# which run inside Hangfire jobs on the worker — no synchronous controller/handler clones a repo. If a synchronous
# API path ever needs git, reconsider this leanness rather than silently regressing the attack surface.
# Because this image is INTERNET-FACING it is kept minimal — NO bubblewrap/prlimit, NO harness CLIs (those are
# agent-execution + isolation deps that belong only on the worker). Every workspace CLONE path lives under
# Services/Agents/* and runs inside Hangfire jobs on the worker — no synchronous controller/handler clones a repo.
#
# git IS installed here (the one sanctioned exception, S1): TaskLaunchService resolves the launch's immutable base
# vector synchronously via `git ls-remote` (RemoteTipResolver) — a read-only, no-checkout ref listing over the same
# HTTPS transport + credential the API already uses for provider REST calls. No clone, no working tree, no
# bubblewrap. If any OTHER synchronous git use appears, reconsider rather than silently regressing the attack surface.
#
# docker build -f backend/Dockerfile.api -t codespace-api backend/ # build context = backend/
#
Expand All @@ -27,6 +30,13 @@ RUN dotnet restore CodeSpace.sln
RUN dotnet publish src/CodeSpace.Api/CodeSpace.Api.csproj -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime

# git → launch-time immutable-base resolution ONLY (`git ls-remote`, read-only — see the header note; no clones here)
# ca-certificates → outbound TLS for ls-remote against git remotes (same bundle discipline as the worker image)
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY --from=build /app ./

Expand Down
10 changes: 10 additions & 0 deletions backend/src/CodeSpace.Api/Filters/GlobalExceptionFilter.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using CodeSpace.Core.Authorization;
using CodeSpace.Core.Services.Agents;
using CodeSpace.Core.Services.Agents.Workspace;
using CodeSpace.Core.Services.OAuth;
using CodeSpace.Core.Services.Providers.Resilience;
using CodeSpace.Core.Services.Workflows;
Expand Down Expand Up @@ -136,6 +137,15 @@ public void OnException(ExceptionContext context)
context.Result = BuildProblemResult(StatusCodes.Status404NotFound, "not_found", "The requested resource was not found.");
break;

case WorkspaceException workspace:
// 422 — the workspace/launch cannot be provisioned as asked (S1: a launch-pinned base ref that
// doesn't exist, an unreachable remote at base resolution). The message is deliberately operator-
// actionable (it names the ref/remote); masking it as a 500 would defeat the whole point of
// failing at launch instead of inside the run.
_logger.LogWarning("Workspace resolution rejected at {Path}: {Message}", path, workspace.Message);
context.Result = BuildProblemResult(StatusCodes.Status422UnprocessableEntity, "workspace_unresolvable", workspace.Message);
break;

case WorkflowValidationException workflowValidation:
// 422 Unprocessable Entity is the right code for "JSON parsed, body shape
// accepted, domain rules say no". The workflow editor inspector renders one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ await _runner.RunAsync(new AgentReviewSpec
SubjectInstructions = BuildReviewInstructions(request.PlanArtifact, request.Goal),
RepositoryId = request.RepositoryId,
BaseRef = null, // the plan targets the repo's CURRENT state — clone the default branch
// S1: …and when the launch pinned an immutable base, materialize exactly THAT commit — the reviewer must
// judge the plan against the same tree the executing agents will see, not a tip that moved since launch.
PinnedSha = request.PinnedSha,
TeamId = request.TeamId,
WorkflowRunId = request.WorkflowRunId,
NodeId = request.NodeId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ internal static string PickReviewerHarness(string producerHarness, IReadOnlyList
Harness = reviewerHarness,
ModelCredentialModelId = spec.ReviewerModelId,
RepositoryId = spec.RepositoryId,
Workspace = AgentWorkspaceAuthoring.ResolveAuthoredWorkspace(spec.RepositoryId, Array.Empty<WorkspaceRepositorySpec>(), primaryRef: spec.BaseRef),
Workspace = AgentWorkspaceAuthoring.ResolveAuthoredWorkspace(spec.RepositoryId, Array.Empty<WorkspaceRepositorySpec>(), primaryRef: spec.BaseRef, primaryPinnedSha: spec.PinnedSha),
Autonomy = AgentAutonomyLevel.Confined,
Permissions = AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Confined),
TimeoutSeconds = ReviewerTimeoutSeconds,
Expand Down Expand Up @@ -172,6 +172,9 @@ public sealed record AgentReviewSpec
/// <summary>The ref to clone at — a produced branch for an output review; null (the default branch) for a plan review.</summary>
public string? BaseRef { get; init; }

/// <summary>S1 — the exact base commit to materialize (the launch's immutable base pin). Null ⇒ the tip of <see cref="BaseRef"/> / the default branch at review time (legacy).</summary>
public string? PinnedSha { get; init; }

public required Guid TeamId { get; init; }

/// <summary>Observability linkage: the run/node cell the reviewer AgentRun lands on. Null on run-less paths.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,7 @@ public sealed record PlanReviewRequest

/// <summary>The operator's reviewer model pin; null ⇒ auto.</summary>
public Guid? ReviewerModelId { get; init; }

/// <summary>S1 — the exact base commit to clone the repository at (the launch's immutable base pin), so the reviewer verifies the plan against the SAME tree the executing agents materialize. Null ⇒ the default branch's tip at review time (legacy).</summary>
public string? PinnedSha { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ public static WorkspaceRepositorySpec ToRelatedSpec(Guid repositoryId, string? a
/// entry, its <c>ref</c> is emitted so the agent clones it at the prior turn's produced branch. Null map / a repo
/// absent from it ⇒ NO <c>ref</c> key for that entry (byte-identical — the repo clones at its default branch). The
/// agent.code path passes the map; the supervisor passes null (it has no per-repo continuity — out of scope).</para>
/// <para><paramref name="pinnedShas"/> (S1 — the launch's immutable base vector) supplies a per-repo base pin:
/// when a repo has an entry, its <c>pinnedSha</c> is emitted so the agent materializes that exact commit. Wins
/// over a spec-carried pin (the launch vector is fresher than a spec authored earlier); null map / a repo absent
/// from it falls back to the spec's own <see cref="WorkspaceRepositorySpec.PinnedSha"/> round-trip (byte-identical).</para>
/// </summary>
public static IReadOnlyList<Dictionary<string, object?>>? SerializeRelatedRepositories(IReadOnlyList<WorkspaceRepositorySpec>? related, IReadOnlyDictionary<Guid, string>? baseRefs = null)
public static IReadOnlyList<Dictionary<string, object?>>? SerializeRelatedRepositories(IReadOnlyList<WorkspaceRepositorySpec>? related, IReadOnlyDictionary<Guid, string>? baseRefs = null, IReadOnlyDictionary<Guid, string>? pinnedShas = null)
{
if (related is not { Count: > 0 }) return null;

Expand All @@ -93,8 +97,10 @@ public static WorkspaceRepositorySpec ToRelatedSpec(Guid repositoryId, string? a
entry["refSoftFallback"] = true;
}

// S1: the pin survives the projection round-trip (omitted when null — byte-identical to before).
if (!string.IsNullOrWhiteSpace(r.PinnedSha)) entry["pinnedSha"] = r.PinnedSha;
// S1: the pin survives the projection round-trip — the launch vector's entry when present, else the
// spec's own carried pin (omitted when neither — byte-identical to before).
var pin = pinnedShas is not null && pinnedShas.TryGetValue(r.RepositoryId, out var vectorPin) && !string.IsNullOrWhiteSpace(vectorPin) ? vectorPin : r.PinnedSha;
if (!string.IsNullOrWhiteSpace(pin)) entry["pinnedSha"] = pin;

return entry;
}).ToList();
Expand All @@ -113,18 +119,19 @@ internal static WorkspaceAccess ParseAccess(string? access) =>
/// guards a null primary, the analysis-only case.) <paramref name="primaryRef"/> is the primary's authored ref
/// (null = the repo default).
/// <para>EXCEPTION: a single-repo run that PINS a primary ref (session branch continuity — start the next turn
/// from the prior turn's produced branch) needs an EXPLICIT one-repo spec so the resolver clones at that ref;
/// without a ref it stays null (byte-identical — the executor derives <c>FromRepository(id)</c> at the default
/// branch). So: related repos ⇒ the multi-repo spec; else a pinned ref ⇒ <c>FromRepository(id, ref)</c>; else null.</para>
/// from the prior turn's produced branch) OR a primary base commit (S1 — <paramref name="primaryPinnedSha"/>)
/// needs an EXPLICIT one-repo spec so the resolver clones at that ref / materializes that commit; without either
/// it stays null (byte-identical — the executor derives <c>FromRepository(id)</c> at the default branch). So:
/// related repos ⇒ the multi-repo spec; else a pinned ref or base commit ⇒ <c>FromRepository(id, ref, …, sha)</c>; else null.</para>
/// </summary>
public static WorkspaceSpec? ResolveAuthoredWorkspace(Guid? primaryRepositoryId, IReadOnlyList<WorkspaceRepositorySpec> relatedRepositories, string? primaryRef = null, bool primaryRefSoftFallback = false, WorkspaceCwdMode cwdMode = WorkspaceCwdMode.Auto)
public static WorkspaceSpec? ResolveAuthoredWorkspace(Guid? primaryRepositoryId, IReadOnlyList<WorkspaceRepositorySpec> relatedRepositories, string? primaryRef = null, bool primaryRefSoftFallback = false, WorkspaceCwdMode cwdMode = WorkspaceCwdMode.Auto, string? primaryPinnedSha = null)
{
if (primaryRepositoryId is not { } primaryId) return null;

// cwdMode only bites a MULTI-repo workspace; a single-repo run always runs at the repo root (the single-repo
// invariant), so the pinned-ref single-repo branch below ignores it (byte-identical).
if (relatedRepositories.Count > 0) return WorkspaceSpec.FromAuthoredRepos(primaryId, primaryRef, relatedRepositories, primaryRefSoftFallback, cwdMode);
// invariant), so the pinned single-repo branch below ignores it (byte-identical).
if (relatedRepositories.Count > 0) return WorkspaceSpec.FromAuthoredRepos(primaryId, primaryRef, relatedRepositories, primaryRefSoftFallback, cwdMode, primaryPinnedSha);

return string.IsNullOrWhiteSpace(primaryRef) ? null : WorkspaceSpec.FromRepository(primaryId, primaryRef, primaryRefSoftFallback);
return string.IsNullOrWhiteSpace(primaryRef) && string.IsNullOrWhiteSpace(primaryPinnedSha) ? null : WorkspaceSpec.FromRepository(primaryId, primaryRef, primaryRefSoftFallback, primaryPinnedSha);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using CodeSpace.Messages.Agents;

namespace CodeSpace.Core.Services.Agents.Workspace;

/// <summary>
/// Resolves the tip COMMIT of a clone request's effective ref over the GIT transport itself (<c>git ls-remote</c>) —
/// the same transport, URL, and credential the eventual clone uses, so the resolved sha can never skew from what the
/// clone would materialize (a provider-API lookup could disagree with the git remote in tests and on mirrors; the
/// transport cannot). S1's launch-time immutable-base vector is built from these.
/// </summary>
public interface IRemoteTipResolver
{
/// <summary>
/// The tip commit sha of <paramref name="request"/>'s effective ref: <see cref="WorkspaceRequest.Ref"/> when set
/// (falling back to <see cref="WorkspaceRequest.DefaultRef"/> under the request's own SOFT semantics when the ref
/// is gone), else the remote's HEAD. An unreachable remote always throws <see cref="WorkspaceException"/> LOUD —
/// the clone would fail the same way later, and the pin's contract is early, honest failure. A ref the remote
/// does not have: throws when <paramref name="refRequired"/> (an operator/authored pin — its absence is an
/// authoring error), returns null when not (an IMPLICIT recorded default branch — an empty just-created repo or
/// a stale default-branch record launches UNPINNED, the pre-S1 behaviour, instead of failing an opportunistic
/// pin). Null also for an empty remote's HEAD (nothing exists to pin).
/// </summary>
Task<string?> ResolveTipShaAsync(WorkspaceRequest request, bool refRequired, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,7 @@ private async Task CloneAsync(WorkspaceRequest request, string directory, Cancel

var args = new List<string> { "clone" };

// S1: a pinned base forces a FULL clone — a shallow tip may not contain the pin, and the pin's whole point
// is materializing an EXACT historical commit.
if (request.Depth > 0 && string.IsNullOrWhiteSpace(request.PinnedSha)) { args.Add("--depth"); args.Add(request.Depth.ToString()); }
if (request.Depth > 0) { args.Add("--depth"); args.Add(request.Depth.ToString()); }
if (!string.IsNullOrWhiteSpace(checkoutRef)) { args.Add("--branch"); args.Add(checkoutRef); }

args.Add(url);
Expand All @@ -310,18 +308,40 @@ private async Task CloneAsync(WorkspaceRequest request, string directory, Cancel
if (result.Status != SandboxStatus.Success)
throw new WorkspaceException($"git clone failed (exit {result.ExitCode}): {Redact(Summarize(result.Stderr), request.Token)}");

// S1: hard-checkout the pinned base. The clone above kept Ref's branch context (the push path re-branches
// via `checkout -B` anyway, so a detached start is fine); the TREE the agent sees is exactly the pin. A
// missing/unreachable pin fails LOUD — the pin is a freshness guarantee, never a suggestion.
if (!string.IsNullOrWhiteSpace(request.PinnedSha))
await MaterializePinAsync(request, directory, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// S1: materialize the pinned base EXACTLY, cheapest rung first. (1) The pin is usually the branch tip the
/// shallow clone just fetched — a local object check + detached checkout keeps the clone SHALLOW, so the common
/// launch pays nothing over the pre-S1 clone. (2) A tip that advanced since launch: fetch the pin BY SHA
/// (best-effort — servers without allow-*-sha1-in-want refuse it). (3) Unshallow the cloned branch's history —
/// the pin is an ancestor of the launch-time tip unless the branch was rewritten. Still absent after every rung
/// ⇒ the checkout fails LOUD: the pin is a freshness guarantee, never a suggestion (a force-push that orphaned
/// the pin must surface, never a silent tip fallback).
/// </summary>
private async Task MaterializePinAsync(WorkspaceRequest request, string directory, CancellationToken cancellationToken)
{
var pin = request.PinnedSha!;

if (!await CommitExistsLocallyAsync(directory, pin, cancellationToken).ConfigureAwait(false))
{
var checkout = await RunGitAsync(new[] { "-C", directory, "checkout", "--detach", request.PinnedSha! }, cancellationToken).ConfigureAwait(false);
await RunGitAsync(new[] { "-C", directory, "fetch", "origin", pin }, cancellationToken).ConfigureAwait(false); // best-effort; the checkout below is the arbiter

if (checkout.Status != SandboxStatus.Success)
throw new WorkspaceException($"the pinned base commit '{request.PinnedSha}' could not be checked out (exit {checkout.ExitCode}): {Redact(Summarize(checkout.Stderr), request.Token)} — the pin guarantees every participant sees the SAME immutable base; a stale or unpushed pin must fail the provision, never silently fall back to the tip");
if (!await CommitExistsLocallyAsync(directory, pin, cancellationToken).ConfigureAwait(false) && request.Depth > 0)
await RunGitAsync(new[] { "-C", directory, "fetch", "--unshallow", "origin" }, cancellationToken).ConfigureAwait(false);
}

var checkout = await RunGitAsync(new[] { "-C", directory, "checkout", "--detach", pin }, cancellationToken).ConfigureAwait(false);

if (checkout.Status != SandboxStatus.Success)
throw new WorkspaceException($"the pinned base commit '{pin}' could not be checked out (exit {checkout.ExitCode}): {Redact(Summarize(checkout.Stderr), request.Token)} — the pin guarantees every participant sees the SAME immutable base; a stale or unpushed pin must fail the provision, never silently fall back to the tip");
}

private async Task<bool> CommitExistsLocallyAsync(string directory, string sha, CancellationToken cancellationToken) =>
(await RunGitAsync(new[] { "-C", directory, "rev-parse", "--verify", "--quiet", $"{sha}^{{commit}}" }, cancellationToken).ConfigureAwait(false)).Status == SandboxStatus.Success;

/// <summary>
/// The ref to actually check out. A SOFT ref (a session-inherited prior branch — <see cref="WorkspaceRequest.DefaultRef"/>
/// carries the fallback) is pre-flighted against the remote: if it was pruned (a merged PR auto-deletes it) we clone
Expand Down
Loading
Loading