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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using CodeSpace.Core.Settings;
using CodeSpace.Messages.Agents;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace CodeSpace.Core.Services.Agents.Commands;

Expand All @@ -20,15 +21,17 @@ public sealed class RunCommandService : IRunCommandService, IScopedDependency
private readonly IWorkspaceProviderRegistry _workspaces;
private readonly AgentDefaultRunnerSetting _defaultRunner;
private readonly CallerCommandLanes _lanes;
private readonly ILogger<RunCommandService> _logger;

public RunCommandService(CodeSpaceDbContext db, IProviderAuthResolver auth, ISandboxRunnerRegistry runners, IWorkspaceProviderRegistry workspaces, AgentDefaultRunnerSetting defaultRunner, CallerCommandLanes lanes)
public RunCommandService(CodeSpaceDbContext db, IProviderAuthResolver auth, ISandboxRunnerRegistry runners, IWorkspaceProviderRegistry workspaces, AgentDefaultRunnerSetting defaultRunner, CallerCommandLanes lanes, ILogger<RunCommandService> logger)
{
_db = db;
_auth = auth;
_runners = runners;
_workspaces = workspaces;
_defaultRunner = defaultRunner;
_lanes = lanes;
_logger = logger;
}

public async Task<SandboxResult> RunAsync(RunCommandRequest request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -184,6 +187,8 @@ private async Task<WorkspaceRequest> BuildWorkspaceRequestAsync(Guid repositoryI

var token = await ResolveTokenAsync(repo, cancellationToken).ConfigureAwait(false);

CloneUrlAuthority.WarnWhenForeign(_logger, repo, credentialed: token is not null);

return new WorkspaceRequest
{
RepositoryUrl = repo.CloneUrlHttps,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Redaction;
using CodeSpace.Messages.Enums;
using Microsoft.Extensions.Logging;

namespace CodeSpace.Core.Services.Agents.Workspace;

/// <summary>
/// Whether a GitLab repository is cloned from the origin its provider connection was set up for. A clone's credential is
/// answered for the clone URL's scheme and authority alone (<see cref="TokenedGitCommand"/>), so a URL the host redirects to
/// another origin — http to https, a host alias, another port — finds no credential and fails with a prompt for one, which
/// <see cref="GitRemoteRedirect"/> names as the redirect it is once the command has failed (<c>provider_base_url_redirects</c>,
/// as the provider's REST calls name it). This check is the other half: it says so BEFORE any clone, from the two stored
/// URLs, when they disagree — but not when they agree and a proxy in front redirects anyway, which only the failed command
/// can show. The two origins differ when GitLab's
/// own <c>external_url</c> disagrees with the base URL the owner typed, which is exactly the case that redirects. Nothing is
/// rewritten — the clone URL is what GitLab reported, the base URL what the owner stored, and each is theirs to change — but
/// the first thing anyone debugging that run needs is the pair, so the resolve says so, with both origins, and only when a
/// credential is about to be presented (an anonymous clone follows a redirect without harm).
/// </summary>
internal static class CloneUrlAuthority
{
/// <summary>
/// True when <paramref name="cloneUrl"/> and the provider's <paramref name="baseUrl"/> are both http(s) URLs of a GitLab
/// connection and name different origins — scheme, host or port; path and userinfo are not part of one.
/// </summary>
internal static bool IsForeign(ProviderKind provider, string? baseUrl, string? cloneUrl) => provider == ProviderKind.GitLab && IsHttp(baseUrl) && IsHttp(cloneUrl) && !CredentialUrl.SameEndpoint(baseUrl, cloneUrl);

/// <summary>Logs a Warning naming both origins when <paramref name="repository"/> is about to be cloned with a credential from an origin other than its provider's base URL. Never throws, never changes the request.</summary>
internal static void WarnWhenForeign(ILogger logger, Repository repository, bool credentialed)
{
if (!credentialed || !IsForeign(repository.ProviderInstance.Provider, repository.ProviderInstance.BaseUrl, repository.CloneUrlHttps)) return;

logger.LogWarning("Repository {RepositoryId} is cloned from {CloneOrigin}, which is not the origin of its {Provider} provider's base URL {BaseOrigin}. git presents the credential to the clone URL's origin only, so if either one redirects to the other the clone fails asking for credentials. Edit the provider's base URL to the origin GitLab serves from, or correct GitLab's external_url", repository.Id, CredentialUrl.Endpoint(repository.CloneUrlHttps), repository.ProviderInstance.Provider, CredentialUrl.Endpoint(repository.ProviderInstance.BaseUrl));
}

private static bool IsHttp(string? url) => Uri.TryCreate(url?.Trim(), UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Text.RegularExpressions;
using CodeSpace.Core.Services.OutboundHttp;
using CodeSpace.Messages.Exceptions;

namespace CodeSpace.Core.Services.Agents.Workspace;

/// <summary>
/// Names the redirect a tokened git command met. git follows a redirect of the ref advertisement itself and makes the
/// origin it was sent to the remote's new base; the credential helper (<see cref="TokenedGitCommand"/>) answers for the
/// remote's own scheme and authority only, so at the new origin no credential is found and git fails with
/// <c>could not read Username for '&lt;that origin&gt;': terminal prompts disabled</c> — its own text, which reads like a
/// credential that was never configured. When that origin is not the remote's, the failure says what happened instead: the
/// remote redirected to <c>&lt;origin&gt;</c>, as a provider's REST call to the same address says
/// (<see cref="ProviderBaseUrlRedirectsException"/>, code <c>provider_base_url_redirects</c>).
///
/// <para>Still a <see cref="WorkspaceException"/>: its catch sites — the push retry, a per-repo push that never fails a run,
/// the workflow nodes, the supervisor's grader — treat any git failure as one, and a failure that is a different type would
/// walk past all of them. A prompt at the remote's own origin is not a redirect (a remote that refused the credential once is
/// not asked again) and keeps its plain failure. Nothing is rewritten and nothing is sent anywhere new: this only reads what
/// git already reported.</para>
/// </summary>
internal static partial class GitRemoteRedirect
{
/// <summary>
/// <paramref name="message"/> as a <see cref="WorkspaceException"/>, naming the redirect when <paramref name="stderr"/> —
/// the failed command's output against <paramref name="remoteUrl"/> — shows git was sent to another origin.
/// </summary>
internal static WorkspaceException Failure(string remoteUrl, string stderr, string message) =>
Named(remoteUrl, stderr) is { } redirect ? new WorkspaceException(message, redirect) : new WorkspaceException(message);

/// <summary>The redirect <paramref name="stderr"/> shows, or null: git asked for a credential at an http(s) origin other than <paramref name="remoteUrl"/>'s own.</summary>
internal static ProviderBaseUrlRedirectsException? Named(string remoteUrl, string stderr)
{
if (HttpUri(remoteUrl) is not { } remote || CredentialPrompt().Match(stderr) is not { Success: true } prompt) return null;

if (HttpUri(prompt.Groups["origin"].Value) is not { } sentTo || OriginScopedRedirectHandler.IsSameOrigin(remote, sentTo)) return null;

return new ProviderBaseUrlRedirectsException(remote, sentTo);
}

private static Uri? HttpUri(string url) => Uri.TryCreate(url.Trim(), UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps) ? parsed : null;

[GeneratedRegex(@"could not read (?:Username|Password) for '(?<origin>[^']+)'", RegexOptions.CultureInvariant)]
private static partial Regex CredentialPrompt();
}
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ private async Task CloneAsync(IntegrationRequest request, string directory, Canc
var result = await RunTokenedGitAsync(request, new[] { "clone", url, directory }, directory, cancellationToken).ConfigureAwait(false);

if (result.Status != SandboxStatus.Success)
throw TokenedGitCommand.OldGitFailure(Remote(request).IsTokened, result.Stderr) ?? new WorkspaceException($"git clone failed (exit {result.ExitCode}): {LocalGitWorkspaceProvider.Redact(Summarize(result.Stderr), request.Token)}");
throw TokenedGitCommand.OldGitFailure(Remote(request).IsTokened, result.Stderr) ?? GitRemoteRedirect.Failure(url, result.Stderr, $"git clone failed (exit {result.ExitCode}): {LocalGitWorkspaceProvider.Redact(Summarize(result.Stderr), request.Token)}");
}

/// <summary>Check out the EXACT shared base (detached) so every <c>apply --3way</c> resolves the pre-image against the commit the agents saw. False when the SHA is not in history (a bad base).</summary>
Expand Down Expand Up @@ -457,7 +457,7 @@ private async Task CommitAsync(string directory, int count, CancellationToken ca
return "remote integration branch advanced — refusing to clobber it";

// A genuine infrastructure failure (auth / network / repo gone) — surface it (token redacted).
throw TokenedGitCommand.OldGitFailure(Remote(request).IsTokened, result.Stderr) ?? new WorkspaceException($"git push failed (exit {result.ExitCode}): {LocalGitWorkspaceProvider.Redact(Summarize(result.Stderr), request.Token)}");
throw TokenedGitCommand.OldGitFailure(Remote(request).IsTokened, result.Stderr) ?? GitRemoteRedirect.Failure(Remote(request).Url, result.Stderr, $"git push failed (exit {result.ExitCode}): {LocalGitWorkspaceProvider.Redact(Summarize(result.Stderr), request.Token)}");
}

private async Task ResetToBaseAsync(string directory, IntegrationRequest request, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ private async Task CloneAsync(WorkspaceRequest request, RepositoryCommandContext
var result = await RunGitAsync(args, context, cancellationToken).ConfigureAwait(false);

if (result.Status != SandboxStatus.Success)
throw TokenedGitCommand.OldGitFailure(context.Remote.IsTokened, result.Stderr) ?? new WorkspaceException($"git clone failed (exit {result.ExitCode}): {Redact(Summarize(result.Stderr), request.Token)}");
throw TokenedGitCommand.OldGitFailure(context.Remote.IsTokened, result.Stderr) ?? GitRemoteRedirect.Failure(url, result.Stderr, $"git clone failed (exit {result.ExitCode}): {Redact(Summarize(result.Stderr), request.Token)}");

if (!string.IsNullOrWhiteSpace(request.PinnedSha))
await MaterializePinAsync(request, context, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -1007,7 +1007,7 @@ private async Task PushToRemoteOrThrowAsync(MaterializedRepo repo, string publis

if (first.Status != SandboxStatus.Success && TokenedGitCommand.OldGitFailure(remote.IsTokened, first.Stderr) is { } oldGit) throw oldGit;

if (first.Status == SandboxStatus.Success || !ResolvesItsCredential(repo) || !IsCredentialRefusal(first.Stderr))
if (first.Status == SandboxStatus.Success || !ResolvesItsCredential(repo) || !IsCredentialRefusal(first.Stderr) || GitRemoteRedirect.Named(remote.Url, first.Stderr) is not null)
{
EnsureSuccess(repo, args(remote.Url), first, remote.IsTokened);
return;
Expand Down Expand Up @@ -1198,7 +1198,7 @@ private static async Task<string> EnsureSuccessAsync(MaterializedRepo repo, IRea
private static string EnsureSuccess(MaterializedRepo repo, IReadOnlyList<string> args, SandboxResult result, bool tokened = false)
{
if (result.Status != SandboxStatus.Success)
throw TokenedGitCommand.OldGitFailure(tokened, result.Stderr) ?? new WorkspaceException($"git {string.Join(' ', RedactArgs(args, repo.Token))} failed (exit {result.ExitCode}): {Redact(result.Stderr.Trim(), repo.Token)}");
throw TokenedGitCommand.OldGitFailure(tokened, result.Stderr) ?? GitRemoteRedirect.Failure(repo.RepositoryUrl, result.Stderr, $"git {string.Join(' ', RedactArgs(args, repo.Token))} failed (exit {result.ExitCode}): {Redact(result.Stderr.Trim(), repo.Token)}");

SandboxOutputCompleteness.RequireStdout(result);
return result.Stdout;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public sealed class RemoteTipResolver : IRemoteTipResolver, ISingletonDependency
}

if (result.Status != SandboxStatus.Success)
throw TokenedGitCommand.OldGitFailure(remote.IsTokened, result.Stderr) ?? new WorkspaceException($"git ls-remote failed (exit {result.ExitCode}) resolving the launch base for {SanitizeUrl(request.RepositoryUrl)}: {LocalGitWorkspaceProvider.Redact(Truncate(result.Stderr), request.Token)} — the launch base pin requires a reachable remote; the clone would fail the same way later");
throw TokenedGitCommand.OldGitFailure(remote.IsTokened, result.Stderr) ?? GitRemoteRedirect.Failure(remote.Url, result.Stderr, $"git ls-remote failed (exit {result.ExitCode}) resolving the launch base for {SanitizeUrl(request.RepositoryUrl)}: {LocalGitWorkspaceProvider.Redact(Truncate(result.Stderr), request.Token)} — the launch base pin requires a reachable remote; the clone would fail the same way later");

// sha1 (40) and sha256 (64) object formats both count — a sha256 remote must not read as "ref missing".
return result.Stdout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using CodeSpace.Messages.Agents;
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace CodeSpace.Core.Services.Agents.Workspace;

Expand All @@ -28,11 +29,13 @@ public sealed class RepositoryWorkspaceResolver : IAgentWorkspaceResolver, IScop
{
private readonly CodeSpaceDbContext _db;
private readonly IProviderAuthResolver _auth;
private readonly ILogger<RepositoryWorkspaceResolver> _logger;

public RepositoryWorkspaceResolver(CodeSpaceDbContext db, IProviderAuthResolver auth)
public RepositoryWorkspaceResolver(CodeSpaceDbContext db, IProviderAuthResolver auth, ILogger<RepositoryWorkspaceResolver> logger)
{
_db = db;
_auth = auth;
_logger = logger;
}

public async Task<WorkspaceProvisionRequest?> ResolveAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken)
Expand Down Expand Up @@ -77,6 +80,8 @@ public RepositoryWorkspaceResolver(CodeSpaceDbContext db, IProviderAuthResolver

var token = await ResolveTokenAsync(repo, cancellationToken).ConfigureAwait(false);

CloneUrlAuthority.WarnWhenForeign(_logger, repo, credentialed: token is not null);

var requestedRef = string.IsNullOrWhiteSpace(@ref) ? null : @ref;

return new WorkspaceRequest
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CodeSpace.Messages.Exceptions;
using CodeSpace.Messages.Failures;

namespace CodeSpace.Core.Services.Agents.Workspace;
Expand All @@ -13,10 +14,18 @@ public class WorkspaceException : Exception, IFailure
{
public FailureKind Kind => FailureKind.Unprocessable;

public string Code => FailureCodes.WorkspaceUnresolvable;
/// <summary><c>provider_base_url_redirects</c> when git was redirected to another origin (<see cref="GitRemoteRedirect"/>), else <c>workspace_unresolvable</c>.</summary>
public string Code => Redirect?.Code ?? FailureCodes.WorkspaceUnresolvable;

/// <summary>The redirect's own details (the origin to edit the base URL to) when git was redirected; none otherwise.</summary>
public IReadOnlyDictionary<string, object?>? Details => Redirect?.Details;

private ProviderBaseUrlRedirectsException? Redirect => InnerException as ProviderBaseUrlRedirectsException;

public WorkspaceException(string message) : base(message) { }

public WorkspaceException(string message, Exception inner) : base(message, inner) { }

/// <summary><paramref name="message"/>, followed by what the redirect <paramref name="redirect"/> says to do about it.</summary>
public WorkspaceException(string message, ProviderBaseUrlRedirectsException redirect) : base($"{message} — {redirect.Message}", redirect) { }
}
Loading
Loading