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 @@ -50,14 +50,11 @@ public async Task<PackCheckout> FetchAsync(string url, string? reference, Cancel
Directory.CreateDirectory(PackClonesRoot);
var dir = Path.Combine(PackClonesRoot, Guid.NewGuid().ToString("N"));

var args = BuildCloneArgs(url, reference, dir);

SandboxResult result;
try
{
Directory.CreateDirectory(dir);
result = await _runners.Resolve(SandboxKinds.Local)
.RunAsync(new SandboxSpec { Command = "git", Args = args, WorkingDirectory = dir, TimeoutSeconds = CloneTimeoutSeconds, AllowNetwork = true }, cancellationToken).ConfigureAwait(false);
result = await _runners.Resolve(SandboxKinds.Local).RunAsync(BuildCloneSpec(url, reference, dir), cancellationToken).ConfigureAwait(false);
}
catch
{
Expand Down Expand Up @@ -96,6 +93,10 @@ internal static IReadOnlyList<string> BuildCloneArgs(string url, string? referen
return args;
}

/// <summary>The clone as the runner gets it: <see cref="BuildCloneArgs"/> in <paramref name="dir"/>, with the network. A pasted URL carrying a token clones as a <see cref="TokenedGitCommand"/>, so no credential helper stores it and no trace2 target records it.</summary>
internal static SandboxSpec BuildCloneSpec(string url, string? reference, string dir) =>
TokenedGitCommand.Spec(url, new SandboxSpec { Command = "git", Args = BuildCloneArgs(url, reference, dir), WorkingDirectory = dir, TimeoutSeconds = CloneTimeoutSeconds, AllowNetwork = true });

// ── IWorkspaceJanitor: reclaim pack clones orphaned by a crashed worker ──────────────────────────

public Task<int> SweepStaleAsync(CancellationToken cancellationToken) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ namespace CodeSpace.Core.Services.Agents.Workspace.Integrators;
///
/// <para><b>Secret hygiene</b> is co-located with the provider: the clone embeds the token in the URL for the clone
/// command only and every surfaced git output is redacted (<see cref="LocalGitWorkspaceProvider.Redact"/>), and the
/// transient clone is always removed in a <c>finally</c>.</para>
/// transient clone is always removed in a <c>finally</c>. The clone keeps its tokened origin, so the commands whose git
/// transport reaches it — the clone and the push — run as <see cref="TokenedGitCommand"/>s. The base checkout, the
/// apply and the reset reach it only for LFS objects, which git-lfs authenticates from the URL without asking or telling
/// a credential helper; a full clone leaves them no git object to fetch through it.</para>
/// </summary>
public sealed class LocalGitBranchIntegrator : IBranchIntegrator, IScopedDependency
{
Expand Down Expand Up @@ -207,10 +210,10 @@ private async Task CloneAsync(IntegrationRequest request, string directory, Canc
// A FULL clone (no --depth): a 3-way apply needs the base history the agents' shallow clones lacked, and a
// full clone guarantees the recorded base SHA is present. (A --filter=blob:none partial clone is a deferred
// optimisation — it needs remote allow-filter support a bare file:// remote can't give a test.)
var url = LocalGitWorkspaceProvider.BuildAuthenticatedUrl(request.RepositoryUrl, request.TokenUsername, request.Token);
var url = RemoteUrl(request);

Directory.CreateDirectory(directory);
var result = await RunGitAsync(new[] { "clone", url, directory }, directory, cancellationToken).ConfigureAwait(false);
var result = await RunTokenedGitAsync(request, new[] { "clone", url, directory }, directory, cancellationToken).ConfigureAwait(false);

if (result.Status != SandboxStatus.Success)
throw new WorkspaceException($"git clone failed (exit {result.ExitCode}): {LocalGitWorkspaceProvider.Redact(Summarize(result.Stderr), request.Token)}");
Expand Down Expand Up @@ -444,7 +447,7 @@ private async Task CommitAsync(string directory, int count, CancellationToken ca
{
var refspec = $"HEAD:refs/heads/{request.IntegrationBranch}";

var result = await RunGitAsync(new[] { "-C", directory, "push", "origin", refspec }, directory, cancellationToken).ConfigureAwait(false);
var result = await RunTokenedGitAsync(request, new[] { "-C", directory, "push", "origin", refspec }, directory, cancellationToken).ConfigureAwait(false);

if (result.Status == SandboxStatus.Success) return null;

Expand All @@ -466,6 +469,9 @@ private async Task ResetToBaseAsync(string directory, string baseSha, Cancellati

// ── Small git helpers ────────────────────────────────────────────────────────────

/// <summary>The remote the integration clone reaches: the authed URL the clone names, which origin keeps to the end.</summary>
private static string RemoteUrl(IntegrationRequest request) => LocalGitWorkspaceProvider.BuildAuthenticatedUrl(request.RepositoryUrl, request.TokenUsername, request.Token);

private async Task<bool> HasStagedChangesAsync(string directory, CancellationToken cancellationToken)
{
var result = await RunGitAsync(new[] { "-C", directory, "diff", "--cached", "--quiet" }, directory, cancellationToken).ConfigureAwait(false);
Expand All @@ -484,12 +490,21 @@ private async Task<string> RevParseTreeAsync(string directory, string rev, Cance
return result.Stdout.Trim();
}

private async Task<SandboxResult> RunGitAsync(IReadOnlyList<string> args, string? workingDirectory, CancellationToken cancellationToken)
private Task<SandboxResult> RunGitAsync(IReadOnlyList<string> args, string? workingDirectory, CancellationToken cancellationToken) =>
RunSpecAsync(GitSpec(args, workingDirectory), cancellationToken);

/// <summary>Run a command whose git transport reaches the integration clone's tokened origin — the clone, the push — as a <see cref="TokenedGitCommand"/>.</summary>
private Task<SandboxResult> RunTokenedGitAsync(IntegrationRequest request, IReadOnlyList<string> args, string directory, CancellationToken cancellationToken) =>
RunSpecAsync(TokenedGitCommand.Spec(RemoteUrl(request), GitSpec(args, directory)), cancellationToken);

private static SandboxSpec GitSpec(IReadOnlyList<string> args, string? workingDirectory) =>
new() { Command = "git", Args = args, WorkingDirectory = workingDirectory, TimeoutSeconds = GitTimeoutSeconds, AllowNetwork = true };

private async Task<SandboxResult> RunSpecAsync(SandboxSpec spec, CancellationToken cancellationToken)
{
try
{
return await _runners.Resolve(Kind).RunAsync(
new SandboxSpec { Command = "git", Args = args, WorkingDirectory = workingDirectory, TimeoutSeconds = GitTimeoutSeconds, AllowNetwork = true }, cancellationToken).ConfigureAwait(false);
return await _runners.Resolve(Kind).RunAsync(spec, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ namespace CodeSpace.Core.Services.Agents.Workspace.Providers;
///
/// <para><b>Secret hygiene:</b> the access token is embedded in the clone URL for the clone command
/// only, then the origin remote is rewritten to the tokenless URL so the persisted <c>.git/config</c>
/// never retains it, and any token text is redacted from surfaced error output. (The transient argv
/// never retains it, and any token text is redacted from surfaced error output. Every command that can
/// reach the tokened remote runs as a <see cref="TokenedGitCommand"/>, so an operator's <c>store</c> or
/// <c>cache</c> helper never keeps the token and no trace2 target records it. (The transient argv
/// exposure is acceptable on a single-tenant local worker; the K8s runner injects via an in-pod
/// credential helper instead.)</para>
/// </summary>
Expand Down Expand Up @@ -154,7 +156,7 @@ internal static bool IsLfsObjectPath(string relative) =>
/// <summary>Clone one repo, strip its token from the persisted remote, and read its base revision — the per-repo unit of the workspace.</summary>
private async Task<MaterializedRepo> MaterializeAsync(WorkspaceRepositoryProvision repo, string directory, CancellationToken cancellationToken)
{
var context = new RepositoryCommandContext(directory, ResolveLocalSourcePaths(repo.CloneRequest));
var context = new RepositoryCommandContext(directory, ResolveLocalSourcePaths(repo.CloneRequest), BuildAuthenticatedUrl(repo.CloneRequest.RepositoryUrl, repo.CloneRequest.TokenUsername, repo.CloneRequest.Token));
Directory.CreateDirectory(directory);
await CloneAsync(repo.CloneRequest, context, cancellationToken).ConfigureAwait(false);

Expand Down Expand Up @@ -325,7 +327,7 @@ private static void TryDeleteDirectory(string directory)
private async Task CloneAsync(WorkspaceRequest request, RepositoryCommandContext context, CancellationToken cancellationToken)
{
var directory = context.Directory;
var url = BuildAuthenticatedUrl(request.RepositoryUrl, request.TokenUsername, request.Token);
var url = context.RemoteUrl;

var (checkoutRef, softRefFellBack, remoteTip) = await ResolveCheckoutRefAsync(request, url, context, cancellationToken).ConfigureAwait(false);

Expand Down Expand Up @@ -575,11 +577,14 @@ Task<SandboxResult> RunGitAsync(IReadOnlyList<string> args) =>
/// commands that DO reach the remote (clone, fetch, push) as well as the local ones, so a single severed helper
/// would break materialization on any runner that enforces it. The value is the egress they have always had —
/// each command still uses the runner's filesystem isolation with its explicit workspace and source mounts.
/// Every command here runs before the token strip, while <see cref="RepositoryCommandContext.RemoteUrl"/> is in
/// reach, so a tokened clone runs each of them as a <see cref="TokenedGitCommand"/>.
/// </summary>
private Task<SandboxResult> RunGitAsync(IReadOnlyList<string> args, RepositoryCommandContext context, CancellationToken cancellationToken) =>
_runners.Resolve(Kind).RunAsync(new SandboxSpec { Command = "git", Args = args, WorkingDirectory = context.Directory, ReadOnlyPaths = context.ReadOnlyPaths, TimeoutSeconds = CloneTimeoutSeconds, AllowNetwork = true }, cancellationToken);
_runners.Resolve(Kind).RunAsync(TokenedGitCommand.Spec(context.RemoteUrl, new SandboxSpec { Command = "git", Args = args, WorkingDirectory = context.Directory, ReadOnlyPaths = context.ReadOnlyPaths, TimeoutSeconds = CloneTimeoutSeconds, AllowNetwork = true }), cancellationToken);

private sealed record RepositoryCommandContext(string Directory, IReadOnlyList<string> ReadOnlyPaths);
/// <summary>One clone's commands: its directory, its read-only source mounts, and the remote they can reach — the authed URL the probe and clone name, and origin holds until the strip.</summary>
private sealed record RepositoryCommandContext(string Directory, IReadOnlyList<string> ReadOnlyPaths, string RemoteUrl);

private static IReadOnlyList<string> ResolveLocalSourcePaths(WorkspaceRequest request)
{
Expand Down Expand Up @@ -784,9 +789,9 @@ private async Task<WorkspaceChanges> CaptureRepoChangesAsync(MaterializedRepo re
// is off: against a remote without the locks API git-lfs would otherwise record lfs.<url>.locksverify in the
// publish repo's .git/config, keyed by the authed URL, which would put the token on disk.
if (hasLfs)
await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "-c", "lfs.locksverify=false", "lfs", "push", authedUrl, branchName }, cancellationToken, network: true, PushTimeoutSeconds).ConfigureAwait(false);
await RunTokenedPublishGitOrThrowAsync(repo, publishDir, authedUrl, new[] { "-c", "lfs.locksverify=false", "lfs", "push", authedUrl, branchName }, cancellationToken).ConfigureAwait(false);

await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "push", "--force", authedUrl, $"{branchName}:{branchName}" }, cancellationToken, network: true, PushTimeoutSeconds).ConfigureAwait(false);
await RunTokenedPublishGitOrThrowAsync(repo, publishDir, authedUrl, new[] { "push", "--force", authedUrl, $"{branchName}:{branchName}" }, cancellationToken).ConfigureAwait(false);

repo.PushedCommitSha = await ReadBackPushedShaAsync(repo, publishDir, authedUrl, branchName, cancellationToken).ConfigureAwait(false);

Expand Down Expand Up @@ -857,7 +862,7 @@ private async Task ImportBundlesAsync(MaterializedRepo repo, string publishDir,
{
var localTip = (await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "rev-parse", $"refs/heads/{branchName}" }, cancellationToken, network: false).ConfigureAwait(false)).Trim();

var readback = await RunPublishGitAsync(repo, publishDir, new[] { "ls-remote", authedUrl, $"refs/heads/{branchName}" }, cancellationToken, network: true, PushTimeoutSeconds).ConfigureAwait(false);
var readback = await RunTokenedPublishGitAsync(repo, publishDir, authedUrl, new[] { "ls-remote", authedUrl, $"refs/heads/{branchName}" }, cancellationToken).ConfigureAwait(false);

if (readback.Status != SandboxStatus.Success || readback.ExitCode != 0)
{
Expand Down Expand Up @@ -929,14 +934,25 @@ private Task RunAgentCloneBundleAsync(MaterializedRepo repo, string publishDir,

// ── Commands over the platform-owned publish repo (init, fetch, lfs push, push, rev-parse, ls-remote) ──
// A fresh repo outside the workspace, never touched by the agent. Only the commands that reach the remote carry the
// credential (in the argv) and the network; the credential never meets the agent-writable .git.
// credential (in the argv) and the network, and they run as tokened commands (TokenedGitCommand), so no helper keeps
// it and no trace2 target records it; the credential never meets the agent-writable .git.

private Task<string> RunPublishGitOrThrowAsync(MaterializedRepo repo, string publishDir, IReadOnlyList<string> args, CancellationToken cancellationToken, bool network, int timeoutSeconds = CaptureTimeoutSeconds) =>
EnsureSuccessAsync(repo, args, RunPublishGitAsync(repo, publishDir, args, cancellationToken, network, timeoutSeconds));

/// <summary>Run a git command in the publish repo (its directory as cwd). Returns the raw result so a caller can classify it (e.g. an unreadable remote on the readback) rather than always throw.</summary>
private Task<SandboxResult> RunPublishGitAsync(MaterializedRepo repo, string publishDir, IReadOnlyList<string> args, CancellationToken cancellationToken, bool network, int timeoutSeconds) =>
ExecuteGitAsync(repo, args, new SandboxSpec { Command = "git", Args = args, WorkingDirectory = publishDir, TimeoutSeconds = timeoutSeconds, AllowNetwork = network }, cancellationToken);
ExecuteGitAsync(repo, args, PublishGitSpec(publishDir, args, network, timeoutSeconds), cancellationToken);

private Task<string> RunTokenedPublishGitOrThrowAsync(MaterializedRepo repo, string publishDir, string authedUrl, IReadOnlyList<string> args, CancellationToken cancellationToken) =>
EnsureSuccessAsync(repo, args, RunTokenedPublishGitAsync(repo, publishDir, authedUrl, args, cancellationToken));

/// <summary>Run a publish-repo command that names <paramref name="authedUrl"/> — the LFS upload, the push, the readback — as a <see cref="TokenedGitCommand"/>, with the network and the push budget.</summary>
private Task<SandboxResult> RunTokenedPublishGitAsync(MaterializedRepo repo, string publishDir, string authedUrl, IReadOnlyList<string> args, CancellationToken cancellationToken) =>
ExecuteGitAsync(repo, args, TokenedGitCommand.Spec(authedUrl, PublishGitSpec(publishDir, args, network: true, PushTimeoutSeconds)), cancellationToken);

private static SandboxSpec PublishGitSpec(string publishDir, IReadOnlyList<string> args, bool network, int timeoutSeconds) =>
new() { Command = "git", Args = args, WorkingDirectory = publishDir, TimeoutSeconds = timeoutSeconds, AllowNetwork = network };

/// <summary>
/// Host-side IO the publish does itself rather than through the runner (staging its directory, copying the clone's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ namespace CodeSpace.Core.Services.Agents.Workspace;
/// <summary>
/// <see cref="IRemoteTipResolver"/> over <c>git ls-remote</c>, run through the local <see cref="ISandboxRunner"/>
/// exactly like <see cref="Providers.LocalGitWorkspaceProvider"/>'s own git calls (same auth-URL embedding, same
/// token redaction on surfaced errors, same process/timeout handling). Branch first, tag second (preferring the
/// token redaction on surfaced errors, same process/timeout handling, and a tokened probe runs as a
/// <see cref="TokenedGitCommand"/>). Branch first, tag second (preferring the
/// peeled <c>^{}</c> commit over the annotated tag object — the pin is a COMMIT), HEAD when no ref is named.
/// Returned lines are matched by EXACT full ref name (ls-remote patterns are tail-matched globs — a pattern hit is
/// necessary but not sufficient), so a glob-shaped or shadowing ref can never pin the wrong commit.
Expand Down Expand Up @@ -82,7 +83,7 @@ public sealed class RemoteTipResolver : IRemoteTipResolver, ISingletonDependency
try
{
result = await _runners.Resolve(SandboxKinds.Local)
.RunAsync(new SandboxSpec { Command = "git", Args = args, TimeoutSeconds = LsRemoteTimeoutSeconds, AllowNetwork = true }, cancellationToken).ConfigureAwait(false);
.RunAsync(TokenedGitCommand.Spec(url, new SandboxSpec { Command = "git", Args = args, TimeoutSeconds = LsRemoteTimeoutSeconds, AllowNetwork = true }), cancellationToken).ConfigureAwait(false);
}
catch (Win32Exception ex)
{
Expand Down
Loading
Loading