diff --git a/backend/src/CodeSpace.Core/Services/Agents/PackCloneFetcher.cs b/backend/src/CodeSpace.Core/Services/Agents/PackCloneFetcher.cs index b555d56e6..00bcf86a6 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/PackCloneFetcher.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/PackCloneFetcher.cs @@ -50,14 +50,11 @@ public async Task 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 { @@ -96,6 +93,10 @@ internal static IReadOnlyList BuildCloneArgs(string url, string? referen return args; } + /// The clone as the runner gets it: in , with the network. A pasted URL carrying a token clones as a , so no credential helper stores it and no trace2 target records it. + 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 SweepStaleAsync(CancellationToken cancellationToken) => diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Integrators/LocalGitBranchIntegrator.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Integrators/LocalGitBranchIntegrator.cs index 3ba0af213..f6ca64c16 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Integrators/LocalGitBranchIntegrator.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Integrators/LocalGitBranchIntegrator.cs @@ -25,7 +25,10 @@ namespace CodeSpace.Core.Services.Agents.Workspace.Integrators; /// /// Secret hygiene 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 (), and the -/// transient clone is always removed in a finally. +/// transient clone is always removed in a finally. The clone keeps its tokened origin, so the commands whose git +/// transport reaches it — the clone and the push — run as 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. /// public sealed class LocalGitBranchIntegrator : IBranchIntegrator, IScopedDependency { @@ -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)}"); @@ -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; @@ -466,6 +469,9 @@ private async Task ResetToBaseAsync(string directory, string baseSha, Cancellati // ── Small git helpers ──────────────────────────────────────────────────────────── + /// The remote the integration clone reaches: the authed URL the clone names, which origin keeps to the end. + private static string RemoteUrl(IntegrationRequest request) => LocalGitWorkspaceProvider.BuildAuthenticatedUrl(request.RepositoryUrl, request.TokenUsername, request.Token); + private async Task HasStagedChangesAsync(string directory, CancellationToken cancellationToken) { var result = await RunGitAsync(new[] { "-C", directory, "diff", "--cached", "--quiet" }, directory, cancellationToken).ConfigureAwait(false); @@ -484,12 +490,21 @@ private async Task RevParseTreeAsync(string directory, string rev, Cance return result.Stdout.Trim(); } - private async Task RunGitAsync(IReadOnlyList args, string? workingDirectory, CancellationToken cancellationToken) + private Task RunGitAsync(IReadOnlyList args, string? workingDirectory, CancellationToken cancellationToken) => + RunSpecAsync(GitSpec(args, workingDirectory), cancellationToken); + + /// Run a command whose git transport reaches the integration clone's tokened origin — the clone, the push — as a . + private Task RunTokenedGitAsync(IntegrationRequest request, IReadOnlyList args, string directory, CancellationToken cancellationToken) => + RunSpecAsync(TokenedGitCommand.Spec(RemoteUrl(request), GitSpec(args, directory)), cancellationToken); + + private static SandboxSpec GitSpec(IReadOnlyList args, string? workingDirectory) => + new() { Command = "git", Args = args, WorkingDirectory = workingDirectory, TimeoutSeconds = GitTimeoutSeconds, AllowNetwork = true }; + + private async Task 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) { diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs index c515068cf..582c17ad1 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs @@ -16,7 +16,9 @@ namespace CodeSpace.Core.Services.Agents.Workspace.Providers; /// /// Secret hygiene: 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 .git/config -/// 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 , so an operator's store or +/// cache 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.) /// @@ -154,7 +156,7 @@ internal static bool IsLfsObjectPath(string relative) => /// Clone one repo, strip its token from the persisted remote, and read its base revision — the per-repo unit of the workspace. private async Task 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); @@ -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); @@ -575,11 +577,14 @@ Task RunGitAsync(IReadOnlyList 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 is in + /// reach, so a tokened clone runs each of them as a . /// private Task RunGitAsync(IReadOnlyList 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 ReadOnlyPaths); + /// 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. + private sealed record RepositoryCommandContext(string Directory, IReadOnlyList ReadOnlyPaths, string RemoteUrl); private static IReadOnlyList ResolveLocalSourcePaths(WorkspaceRequest request) { @@ -784,9 +789,9 @@ private async Task CaptureRepoChangesAsync(MaterializedRepo re // is off: against a remote without the locks API git-lfs would otherwise record lfs..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); @@ -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) { @@ -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 RunPublishGitOrThrowAsync(MaterializedRepo repo, string publishDir, IReadOnlyList args, CancellationToken cancellationToken, bool network, int timeoutSeconds = CaptureTimeoutSeconds) => EnsureSuccessAsync(repo, args, RunPublishGitAsync(repo, publishDir, args, cancellationToken, network, timeoutSeconds)); /// 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. private Task RunPublishGitAsync(MaterializedRepo repo, string publishDir, IReadOnlyList 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 RunTokenedPublishGitOrThrowAsync(MaterializedRepo repo, string publishDir, string authedUrl, IReadOnlyList args, CancellationToken cancellationToken) => + EnsureSuccessAsync(repo, args, RunTokenedPublishGitAsync(repo, publishDir, authedUrl, args, cancellationToken)); + + /// Run a publish-repo command that names — the LFS upload, the push, the readback — as a , with the network and the push budget. + private Task RunTokenedPublishGitAsync(MaterializedRepo repo, string publishDir, string authedUrl, IReadOnlyList args, CancellationToken cancellationToken) => + ExecuteGitAsync(repo, args, TokenedGitCommand.Spec(authedUrl, PublishGitSpec(publishDir, args, network: true, PushTimeoutSeconds)), cancellationToken); + + private static SandboxSpec PublishGitSpec(string publishDir, IReadOnlyList args, bool network, int timeoutSeconds) => + new() { Command = "git", Args = args, WorkingDirectory = publishDir, TimeoutSeconds = timeoutSeconds, AllowNetwork = network }; /// /// Host-side IO the publish does itself rather than through the runner (staging its directory, copying the clone's diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs index 39cc79467..85e52a152 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs @@ -9,7 +9,8 @@ namespace CodeSpace.Core.Services.Agents.Workspace; /// /// over git ls-remote, run through the local /// exactly like '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 +/// ). Branch first, tag second (preferring the /// peeled ^{} 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. @@ -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) { diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/TokenedGitCommand.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/TokenedGitCommand.cs new file mode 100644 index 000000000..23f945683 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/TokenedGitCommand.cs @@ -0,0 +1,63 @@ +using CodeSpace.Messages.Agents; + +namespace CodeSpace.Core.Services.Agents.Workspace; + +/// +/// Marks a git command as TOKENED — one whose git transport can reach a remote whose URL carries a clone token, +/// either named in its own argv (the clone, a probe or readback ls-remote, the publish push, and +/// lfs push, which runs git's transport against that URL itself) or as the origin of the clone it runs in +/// (a fetch or push before the token is stripped) — and keeps that token out of the operator's credential helpers and +/// trace2 targets. +/// +/// The token is already in the URL, so a tokened command has nothing to ask a helper for. But git still honours +/// the helpers in system and global config, and on success its transport hands the URL's username and password to each +/// of them to store: an operator's credential.helper=store (or cache, or a keychain) would keep +/// every run's token at rest. empties the helper list for the tokened remote's scheme +/// and authority: an empty value clears the helpers collected so far, URL-scoped, path-scoped, user-scoped and included +/// ones too, and a command-line value is read after system, global and repository config. It reaches child processes +/// (git-lfs, the git transport lfs push runs) through GIT_CONFIG_PARAMETERS. It is scoped rather than +/// global because git asks the same helpers for other hosts' credentials — an authenticating proxy named with only a +/// user, a separate LFS host — and those must still answer. +/// +/// git also writes every command's argv, and each child's (git remote-http <url>), to the trace2 +/// targets in system and global config; git 2.33 writes the URL's password verbatim. Those targets are read before any +/// -c, so a tokened command runs with in its environment, which wins over them. +/// +/// git-lfs's own object transfers through a tokened origin — the downloads a checkout, a hard reset or an apply +/// makes — authenticate from the URL's userinfo and neither ask nor tell a helper (verified with git-lfs 3.7.1), so a +/// command that only reaches the origin that way is not tokened. An untokened command is left as written: the +/// operator's helpers may be how it authenticates to a private mirror. +/// +internal static class TokenedGitCommand +{ + /// The environment a tokened command runs with: git's three trace2 targets switched off. + internal static readonly IReadOnlyDictionary TraceOff = new Dictionary(StringComparer.Ordinal) + { + ["GIT_TRACE2"] = "0", + ["GIT_TRACE2_EVENT"] = "0", + ["GIT_TRACE2_PERF"] = "0", + }; + + /// True when embeds a password: the token LocalGitWorkspaceProvider.BuildAuthenticatedUrl put there, or one a stored or pasted URL already carries. A bare username is not a secret, and a path or an scp-style address carries none. + internal static bool IsTokened(string remoteUrl) => + Uri.TryCreate(remoteUrl, UriKind.Absolute, out var uri) && uri.UserInfo.Split(':', 2) is [_, { Length: > 0 }]; + + /// The config a tokened command gets ahead of its own arguments: an empty helper list for the remote's scheme and authority — never its userinfo, so the key carries no token. Empty, not false: only the empty value resets the list; any other value adds one more helper. + internal static IReadOnlyList CredentialHelperReset(string remoteUrl) + { + var uri = new Uri(remoteUrl); + + return new[] { "-c", $"credential.{uri.Scheme}://{uri.Authority}.helper=" }; + } + + /// as a tokened command when the remote it can reach is tokened — ahead of its arguments, over its environment — otherwise unchanged. + internal static SandboxSpec Spec(string remoteUrl, SandboxSpec spec) + { + if (!IsTokened(remoteUrl)) return spec; + + var environment = new Dictionary(spec.Environment); + foreach (var (name, value) in TraceOff) environment[name] = value; + + return spec with { Args = [.. CredentialHelperReset(remoteUrl), .. spec.Args], Environment = environment }; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGrader.cs b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGrader.cs index 953487cf6..6b14c962b 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGrader.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGrader.cs @@ -332,7 +332,7 @@ private async Task CloneAtBaseAsync(WorkspaceRequest clone, string baseSha, stri var url = LocalGitWorkspaceProvider.BuildAuthenticatedUrl(clone.RepositoryUrl, clone.TokenUsername, clone.Token); var cloneResult = await _runners.Resolve(GradingRunnerKind).RunAsync( - new SandboxSpec { Command = "git", Args = new[] { "clone", url, directory }, WorkingDirectory = directory, TimeoutSeconds = CloneTimeoutSeconds, AllowNetwork = true }, cancellationToken).ConfigureAwait(false); + TokenedGitCommand.Spec(url, new SandboxSpec { Command = "git", Args = new[] { "clone", url, directory }, WorkingDirectory = directory, TimeoutSeconds = CloneTimeoutSeconds, AllowNetwork = true }), cancellationToken).ConfigureAwait(false); if (cloneResult.Status != SandboxStatus.Success) throw new WorkspaceException($"git clone failed (exit {cloneResult.ExitCode}): {LocalGitWorkspaceProvider.Redact(Summarize(cloneResult.Stderr), clone.Token)}"); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/GitPublishRemoteFixture.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/GitPublishRemoteFixture.cs index 713cb888b..ac53256e8 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/GitPublishRemoteFixture.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/GitPublishRemoteFixture.cs @@ -10,8 +10,9 @@ namespace CodeSpace.IntegrationTests.Workflows; /// /// A loopback smart-HTTP git remote (the real git http-backend) that ALSO speaks the Git-LFS batch API, with a /// FAKE token required for every write — the legitimate destination an agent's produced branch must reach. Fetch/clone -/// is anonymous (GIT_HTTP_EXPORT_ALL); git-receive-pack and every LFS endpoint demand -/// x-access-token:<FakeToken> basic auth, so a push that arrives proves the real credential was presented. +/// is anonymous (GIT_HTTP_EXPORT_ALL) unless is set; git-receive-pack and +/// every LFS endpoint demand x-access-token:<FakeToken> basic auth, so a push that arrives proves the real +/// credential was presented. /// It records every request so a test can assert what the remote saw — the authenticated push, the LFS objects uploaded, /// and that no agent-injected header () was ever sent to it. Fixture setup runs real git out /// of band; only the production provider's commands run through the sandbox runner under test. @@ -24,7 +25,7 @@ internal sealed class GitPublishRemoteFixture : IAsyncDisposable /// A request header an agent's http.extraHeader would inject; the clean publish repo must never send it to the remote. public const string HostileHeader = "X-Codespace-Exfil"; - private readonly HttpListener _listener = new(); + private HttpListener _listener = new(); private readonly CancellationTokenSource _stopping = new(); private readonly List _requests = new(); private readonly object _gate = new(); @@ -37,12 +38,18 @@ internal sealed class GitPublishRemoteFixture : IAsyncDisposable public string Url { get; private set; } = ""; public string BaseSha { get; private set; } = ""; + /// When set, every git request — a clone, a fetch, an ls-remote — demands the token too, so a read that succeeds proves it authenticated. Off by default: reads are anonymous. + public bool AuthenticateReads { get; init; } + /// Count of authenticated git-receive-pack requests — a push that validated the real credential. public int AuthenticatedPushRequests { get; private set; } /// OIDs the remote received over the LFS upload endpoint. public List UploadedLfsOids { get; } = new(); + /// OIDs the remote served over the LFS download endpoint, to an authenticated request. + public List DownloadedLfsOids { get; } = new(); + /// True if any request to the remote carried the agent-injected . public bool SawHostileHeader { get; private set; } @@ -76,7 +83,10 @@ public async Task StartAsync() var port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); Url = $"http://127.0.0.1:{port}/remote.git"; - _listener.Prefixes.Clear(); + + // A failed Start closes the listener for good (Prefixes then throws ObjectDisposedException), so each attempt + // at a fresh port needs a fresh listener. + if (attempt > 0) _listener = new HttpListener(); _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); try { _listener.Start(); break; } catch (HttpListenerException) when (attempt < 4) { } @@ -106,6 +116,42 @@ public async Task AddLegacySubtreeCommitAsync() await PublishSeedAsync(); } + /// + /// Give main LFS history: big.bin at a new commit (), a different version of it + /// at main's tip after that, and one more object in the LFS store that no commit names yet, for a patch to point at. + /// The seed has no LFS filters, so the pointers are committed as plain text; every object lives only in the remote's + /// LFS store. Call before the clone. + /// + public async Task AddLfsHistoryAsync() + { + await File.WriteAllTextAsync(Path.Combine(Seed, ".gitattributes"), "*.bin filter=lfs diff=lfs merge=lfs -text\n"); + var atBase = await CommitLfsFileAsync("lfs payload v1\n"); + var baseSha = (await GitAsync(Seed, new[] { "rev-parse", "HEAD" })).Trim(); + + await CommitLfsFileAsync("lfs payload v2\n"); + var unreferenced = await StoreLfsObjectAsync("lfs payload v3\n"); + + await PublishSeedAsync(); + return new LfsHistory(baseSha, atBase, unreferenced); + } + + private async Task CommitLfsFileAsync(string payload) + { + var lfs = await StoreLfsObjectAsync(payload); + await File.WriteAllTextAsync(Path.Combine(Seed, "big.bin"), lfs.Pointer); + await GitAsync(Seed, new[] { "add", "." }); + await GitAsync(Seed, new[] { "commit", "-m", payload.Trim() }); + return lfs; + } + + private async Task StoreLfsObjectAsync(string payload) + { + var bytes = Encoding.UTF8.GetBytes(payload); + var oid = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)).ToLowerInvariant(); + await File.WriteAllBytesAsync(Path.Combine(LfsStore, oid), bytes); + return new LfsObject(oid, bytes.Length); + } + /// One raw tree entry: <mode> <name>\0<20-byte sha>, written exactly as given (no mode normalisation). public static byte[] TreeEntry(string mode, string name, string sha) => Encoding.ASCII.GetBytes($"{mode} {name}\0").Concat(Convert.FromHexString(sha)).ToArray(); @@ -211,6 +257,7 @@ private async Task ServeLfsObjectAsync(HttpListenerContext context, string path) var stored = Path.Combine(LfsStore, oid); if (!File.Exists(stored)) { context.Response.StatusCode = 404; return; } + lock (_gate) DownloadedLfsOids.Add(oid); var bytes = await File.ReadAllBytesAsync(stored); context.Response.ContentLength64 = bytes.Length; await context.Response.OutputStream.WriteAsync(bytes); @@ -220,10 +267,10 @@ private async Task ServeGitAsync(HttpListenerContext context, string path) { var isPush = path.EndsWith("/git-receive-pack", StringComparison.Ordinal) || context.Request.QueryString["service"] == "git-receive-pack"; - if (isPush) + if (isPush || AuthenticateReads) { if (!AuthOk(context)) { Unauthorized(context); return; } - lock (_gate) AuthenticatedPushRequests++; + if (isPush) lock (_gate) AuthenticatedPushRequests++; } using var input = new MemoryStream(); @@ -310,6 +357,15 @@ public async ValueTask DisposeAsync() } } +/// An LFS object the fixture's remote stores, and the pointer a commit names it by. +internal sealed record LfsObject(string Oid, long Size) +{ + public string Pointer => $"version https://git-lfs.github.com/spec/v1\noid sha256:{Oid}\nsize {Size}\n"; +} + +/// What made: the commit holding , and an object no commit names. +internal sealed record LfsHistory(string BaseSha, LfsObject AtBase, LfsObject Unreferenced); + /// A loopback endpoint that accepts and records every connection, answering nothing useful — the attacker a redirect (insteadOf / proxy / a hostile .lfsconfig) would reach. The publish must send it NOTHING. internal sealed class LoopbackSink : IDisposable { @@ -340,3 +396,141 @@ private async Task AcceptAsync() public void Dispose() => _listener.Stop(); } + +/// +/// A loopback forward proxy that answers 407 to every request without 's Basic proxy credential and +/// relays the rest to the origin, one request per connection — an operator's proxy whose password lives in their +/// credential helper. Counts the requests it relayed, so a test can tell git went through it rather than around it. +/// +internal sealed class AuthenticatingProxy : IAsyncDisposable +{ + public const string User = "proxyuser"; + public const string Password = "fake-proxy-password"; + + private static readonly string Credential = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{User}:{Password}")); + private static readonly byte[] Challenge = Encoding.ASCII.GetBytes("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"fixture-proxy\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stopping = new(); + private readonly List _connections = new(); + private readonly Task _accept; + private int _relayed; + + public AuthenticatingProxy() + { + _listener.Start(); + _accept = AcceptAsync(); + } + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + public int RelayedRequests => Volatile.Read(ref _relayed); + + private async Task AcceptAsync() + { + try + { + while (true) + { + var client = await _listener.AcceptTcpClientAsync(_stopping.Token); + lock (_connections) _connections.Add(ServeAsync(client)); + } + } + catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException) { } + } + + private async Task ServeAsync(TcpClient client) + { + using var _ = client; + + try + { + var stream = client.GetStream(); + var (head, body) = await ReadHeadAsync(stream); + var lines = head.Split("\r\n"); + var request = lines[0].Split(' '); + var headers = lines.Skip(1).Select(l => l.Split(':', 2)).Where(h => h.Length == 2).ToList(); + + if (Header(headers, "Proxy-Authorization") != Credential) + { + await DrainBodyAsync(stream, body.Length, headers); + await stream.WriteAsync(Challenge); + return; + } + + Interlocked.Increment(ref _relayed); + await RelayAsync(stream, request, headers, body); + } + catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException or OperationCanceledException) { } + } + + /// Forward one request in origin form, without the proxy headers and asking the origin to close after it, then copy the origin's response back until it does. + private static async Task RelayAsync(NetworkStream client, string[] request, List headers, byte[] body) + { + var target = new Uri(request[1]); + using var upstream = new TcpClient(); + await upstream.ConnectAsync(target.Host, target.Port); + var origin = upstream.GetStream(); + + var forwarded = new StringBuilder($"{request[0]} {target.PathAndQuery} {request[2]}\r\n"); + foreach (var h in headers.Where(h => !h[0].Trim().StartsWith("Proxy-", StringComparison.OrdinalIgnoreCase) && !h[0].Trim().Equals("Connection", StringComparison.OrdinalIgnoreCase))) forwarded.Append($"{h[0]}:{h[1]}\r\n"); + forwarded.Append("Connection: close\r\n\r\n"); + + await origin.WriteAsync(Encoding.ASCII.GetBytes(forwarded.ToString())); + await origin.WriteAsync(body); + + var restOfRequest = client.CopyToAsync(origin); + await origin.CopyToAsync(client); + + upstream.Close(); + try { await restOfRequest; } catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException) { } + } + + /// Read past the request's head; returns it and whatever body bytes arrived with it. + private static async Task<(string Head, byte[] Body)> ReadHeadAsync(NetworkStream stream) + { + var received = new MemoryStream(); + var chunk = new byte[4096]; + + while (true) + { + var read = await stream.ReadAsync(chunk); + if (read == 0) throw new IOException("the client closed before its request head ended"); + received.Write(chunk, 0, read); + + var bytes = received.ToArray(); + var end = bytes.AsSpan().IndexOf("\r\n\r\n"u8); + if (end >= 0) return (Encoding.ASCII.GetString(bytes, 0, end), bytes[(end + 4)..]); + } + } + + /// Consume a refused request's body so the challenge is read, not reset — unless the client is waiting for a 100 before it sends one. + private static async Task DrainBodyAsync(NetworkStream stream, int alreadyRead, List headers) + { + if (Header(headers, "Expect") is not null) return; + + var remaining = long.TryParse(Header(headers, "Content-Length"), out var length) ? length - alreadyRead : 0; + var buffer = new byte[8192]; + + while (remaining > 0) + { + var read = await stream.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining))); + if (read == 0) return; + remaining -= read; + } + } + + private static string? Header(IEnumerable headers, string name) => headers.FirstOrDefault(h => h[0].Trim().Equals(name, StringComparison.OrdinalIgnoreCase))?[1].Trim(); + + public async ValueTask DisposeAsync() + { + _stopping.Cancel(); + _listener.Stop(); + await _accept; + + Task[] open; + lock (_connections) open = _connections.ToArray(); + try { await Task.WhenAll(open).WaitAsync(TimeSpan.FromSeconds(10)); } catch (TimeoutException) { /* best-effort: a client still holding a connection */ } + + _stopping.Dispose(); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/TokenedGitCredentialHelperFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/TokenedGitCredentialHelperFlowTests.cs new file mode 100644 index 000000000..54d77fff6 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/TokenedGitCredentialHelperFlowTests.cs @@ -0,0 +1,446 @@ +using CodeSpace.Core.Services.Agents.Sandbox; +using CodeSpace.Core.Services.Agents.Sandbox.Runners; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Core.Services.Agents.Workspace.Integrators; +using CodeSpace.Core.Services.Agents.Workspace.Providers; +using CodeSpace.Core.Services.Workflows.Artifacts; +using CodeSpace.Messages.Agents; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +/// +/// HIGH fidelity (Rule 12): the REAL , and +/// on the real , with real git and +/// git-lfs, against a loopback smart-HTTP remote () that demands a FAKE token +/// for every request, read or write. The operator's host is simulated by a scratch HOME whose global config +/// (GIT_CONFIG_GLOBAL, with system config off) sets credential.helper=store --file=<scratch> plus a +/// second store scoped to the remote's URL — the setup under which every tokened run used to leave its token on disk. +/// +/// Each tokened operation must authenticate with the token in its URL and leave both store files without it. The +/// positive control runs the same production code with ONE named git subcommand as it ran before tokened commands were +/// marked — no credential-helper reset, trace2 on: that store then holds the token, so its absence in the real run is the +/// reset's doing, not a helper that never ran. An untokened clone must still authenticate through the operator's helper, +/// and a tokened one must still reach the remote through a proxy whose password that helper holds: the reset covers the +/// tokened remote only. The operator's trace2 targets must record no tokened command, and the LFS downloads an +/// integration makes through its tokened origin — a checkout and an apply that run as written — must store nothing. +/// +/// Each test owns its remote, proxy and temp tree and removes them on every path; it skips on Windows or without +/// git, and the LFS rows without git-lfs. Nothing reads or writes the real global config, system config or keychain. +/// +[Trait("Category", "Integration")] +public sealed class TokenedGitCredentialHelperFlowTests +{ + private const string ReadmePatch = "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-base, revised\n+integrated\n"; + + [Theory] + [InlineData(null)] // every tokened command carries the reset + [InlineData("ls-remote")] // positive controls: the soft-ref probe without it, + [InlineData("clone")] // the clone, + [InlineData("fetch")] // or the pin's fetch rungs through the still-tokened origin + public async Task Provisioning_a_tokened_workspace_leaves_no_token_in_the_operators_credential_store(string? unreset) + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + await using var ctx = await OperatorHostContext.StartAsync(); + ctx.Runner.Unreset = unreset; + + await using var handle = await ctx.ProvisionAsync(GitPublishRemoteFixture.FakeToken); + + handle.Repositories.Single().BaseSha.ShouldBe(ctx.PinnedSha, "the probe, the clone and the pin's fetch all authenticated with the URL's token"); + ctx.Runner.Ran(unreset ?? "fetch").ShouldBeTrue("fixture check: the command under test ran"); + ctx.ShouldHoldTheToken(stored: unreset is not null); + } + + [Theory] + [InlineData(null)] + [InlineData("lfs")] // positive controls: the LFS upload without the reset, + [InlineData("push")] // the push, + [InlineData("ls-remote")] // or the readback + public async Task Publishing_leaves_no_token_in_the_operators_credential_store(string? unreset) + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + var lfs = await GitLfsAvailableAsync(); + if (unreset == "lfs" && !lfs) return; + + await using var ctx = await OperatorHostContext.StartAsync(); + await using var handle = await ctx.ProvisionAsync(GitPublishRemoteFixture.FakeToken); + ctx.ShouldHoldTheToken(stored: false); + + var oid = lfs ? await ctx.AgentCommitsLfsFileAsync(handle.Directory) : null; + await ctx.AgentCommitsAsync(handle.Directory); + ctx.Runner.Unreset = unreset; + + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + + ctx.Remote.AuthenticatedPushRequests.ShouldBeGreaterThan(0, "the push authenticated with the URL's token"); + ((IWorkspacePushHandle)handle).LastPushedCommitSha().ShouldBe(await ctx.RemoteShaAsync(ctx.BranchName), "the readback authenticated and confirmed the pushed tip"); + if (oid is not null) ctx.Remote.HasLfsObject(oid).ShouldBeTrue("the LFS upload authenticated with the URL's token"); + ctx.Runner.Ran(unreset ?? "push").ShouldBeTrue("fixture check: the command under test ran"); + ctx.ShouldHoldTheToken(stored: unreset is not null); + } + + [Theory] + [InlineData(null)] + [InlineData("ls-remote")] + public async Task Resolving_the_launch_base_leaves_no_token_in_the_operators_credential_store(string? unreset) + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + await using var ctx = await OperatorHostContext.StartAsync(); + ctx.Runner.Unreset = unreset; + + var tip = await new RemoteTipResolver(ctx.Registry).ResolveTipShaAsync(new WorkspaceRequest + { + RepositoryUrl = ctx.Remote.Url, Token = GitPublishRemoteFixture.FakeToken, TokenUsername = "x-access-token", Ref = "main", + }, refRequired: true, CancellationToken.None); + + tip.ShouldBe(ctx.Remote.BaseSha, "the probe authenticated with the URL's token"); + ctx.ShouldHoldTheToken(stored: unreset is not null); + } + + [Theory] + [InlineData(null)] + [InlineData("clone")] // positive controls: the integration clone without the reset, + [InlineData("push")] // or the push through its tokened origin + public async Task Integrating_leaves_no_token_in_the_operators_credential_store(string? unreset) + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + await using var ctx = await OperatorHostContext.StartAsync(); + ctx.Runner.Unreset = unreset; + + var result = await ctx.IntegrateAsync(ctx.BranchName, ctx.Remote.BaseSha, ReadmePatch); + + result.Status.ShouldBe(IntegrationStatus.Clean, result.Reason); + (await ctx.RemoteFileAsync(ctx.BranchName, "README.md")).ShouldBe("integrated\n", "the clone and the push authenticated with the URL's token"); + ctx.ShouldHoldTheToken(stored: unreset is not null); + } + + [Theory] + [InlineData(null)] + [InlineData("clone")] // positive control: the clone without the reset — the operator's store is live here + public async Task Integrating_over_lfs_history_downloads_through_the_tokened_origin_and_stores_nothing(string? unreset) + { + // The integration clone keeps its tokened origin, and the base checkout and the apply download LFS objects through + // it. git-lfs authenticates those downloads from the origin URL and neither asks nor tells a credential helper, so + // they run as written; only git's own transport — the clone and the push — carries the reset. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync() || !await GitLfsAvailableAsync()) return; + + await using var ctx = await OperatorHostContext.StartAsync(); + var lfs = await ctx.Remote.AddLfsHistoryAsync(); + ctx.InstallLfsFilters(); + ctx.Runner.Unreset = unreset; + + var result = await ctx.IntegrateAsync(ctx.BranchName, lfs.BaseSha, LfsPointerPatch(lfs.AtBase, lfs.Unreferenced)); + + result.Status.ShouldBe(IntegrationStatus.Clean, result.Reason); + (await ctx.RemoteFileAsync(ctx.BranchName, "big.bin")).ShouldBe(lfs.Unreferenced.Pointer, "the clone and the push authenticated with the URL's token"); + ctx.Remote.DownloadedLfsOids.ShouldContain(lfs.AtBase.Oid, "fixture check: the base checkout downloaded its LFS object through the tokened origin"); + ctx.Remote.DownloadedLfsOids.ShouldContain(lfs.Unreferenced.Oid, "fixture check: the apply downloaded the patched LFS object through the tokened origin"); + ctx.ShouldHoldTheToken(stored: unreset is not null); + } + + [Fact] + public async Task Tokened_commands_reach_the_remote_through_a_proxy_whose_password_the_operators_helper_holds() + { + // The operator's http.proxy names only its user, so git asks the credential helpers for the proxy's password before + // every tokened command. The reset empties the helper list for the tokened remote only: that lookup still finds the + // operator's store, and the token still reaches no helper. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + await using var ctx = await OperatorHostContext.StartAsync(); + await using var proxy = new AuthenticatingProxy(); + ctx.RouteThroughProxy(proxy); + + var tip = await new RemoteTipResolver(ctx.Registry).ResolveTipShaAsync(new WorkspaceRequest + { + RepositoryUrl = ctx.Remote.Url, Token = GitPublishRemoteFixture.FakeToken, TokenUsername = "x-access-token", Ref = "main", + }, refRequired: true, CancellationToken.None); + tip.ShouldBe(ctx.Remote.BaseSha, "the launch-base probe passed the proxy and authenticated to the remote"); + + await using var handle = await ctx.ProvisionAsync(GitPublishRemoteFixture.FakeToken); + handle.Repositories.Single().BaseSha.ShouldBe(ctx.PinnedSha, "the soft-ref probe, the clone and the pin's fetch passed the proxy"); + + await ctx.AgentCommitsAsync(handle.Directory); + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + ((IWorkspacePushHandle)handle).LastPushedCommitSha().ShouldBe(await ctx.RemoteShaAsync(ctx.BranchName), "the push and its readback passed the proxy"); + + var integrated = await ctx.IntegrateAsync("codespace/integration/" + Guid.NewGuid().ToString("N"), ctx.Remote.BaseSha, ReadmePatch); + integrated.Status.ShouldBe(IntegrationStatus.Clean, integrated.Reason); + + proxy.RelayedRequests.ShouldBeGreaterThan(0, "fixture check: git reached the remote through the proxy, not around it"); + ctx.ShouldHoldTheToken(stored: false); + } + + [Theory] + [InlineData(null)] + [InlineData("clone")] // positive controls: the clone as it ran before tokened commands were marked, + [InlineData("push")] // or the publish push + public async Task The_operators_trace2_targets_record_no_tokened_command(string? unreset) + { + // git writes every command's argv, and each child's (git remote-http ), to the trace2 targets in system and + // global config — read before any -c can reach them — and git 2.33 writes the URL's password verbatim. A tokened + // command runs with trace2 off, so the operator's targets never see a tokened URL; untokened commands still trace. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + await using var ctx = await OperatorHostContext.StartAsync(); + ctx.TraceToOperatorTargets(); + ctx.Runner.Unreset = unreset; + + await using var handle = await ctx.ProvisionAsync(GitPublishRemoteFixture.FakeToken); + await ctx.AgentCommitsAsync(handle.Directory); + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + + var trace = ctx.OperatorTrace(); + trace.ShouldContain("rev-parse", Case.Sensitive, "fixture check: the operator's trace2 targets are live, and untokened commands still trace"); + trace.Contains("x-access-token:", StringComparison.Ordinal).ShouldBe(unreset is not null, "a tokened URL (redacted or not, it keeps its username) in the operator's trace2 targets"); + if (unreset is null) trace.ShouldNotContain(GitPublishRemoteFixture.FakeToken); + } + + [Fact] + public async Task An_untokened_clone_still_authenticates_through_the_operators_helper() + { + // The reset is for tokened commands only: an operator may reach a private mirror through a helper of their own. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + await using var ctx = await OperatorHostContext.StartAsync(); + var untokened = WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest { RepositoryUrl = ctx.Remote.Url }); + + await Should.ThrowAsync(() => ctx.Provider.PrepareAsync(untokened, CancellationToken.None), "fixture check: the remote refuses a clone that presents no credential"); + + ctx.SeedTheOperatorsStore(); + await using var handle = await ctx.Provider.PrepareAsync(untokened, CancellationToken.None); + + File.ReadAllText(Path.Combine(handle.Directory, "README.md")).ShouldBe("base, revised\n", "the clone authenticated through the operator's store helper"); + ctx.Runner.Specs.ShouldAllBe(s => !s.Args.Any(a => IsHelperReset(a)) && !s.Environment.Keys.Any(k => TraceOff.Contains(k)), "an untokened command keeps the operator's helpers and trace2"); + } + + private static Task GitAvailableAsync() => ToolAvailableAsync(new[] { "--version" }); + + private static Task GitLfsAvailableAsync() => ToolAvailableAsync(new[] { "lfs", "version" }); + + /// The environment a tokened command runs with, which turns off every trace2 target. + private static readonly string[] TraceOff = { "GIT_TRACE2", "GIT_TRACE2_EVENT", "GIT_TRACE2_PERF" }; + + /// A config that empties the credential helper list — credential.helper= or one scoped to a URL. + private static bool IsHelperReset(string arg) => arg.StartsWith("credential.", StringComparison.Ordinal) && arg.EndsWith(".helper=", StringComparison.Ordinal); + + /// An agent's patch repointing big.bin from one LFS object to another, as a capture records it: the pointer text. + private static string LfsPointerPatch(LfsObject from, LfsObject to) => + $"diff --git a/big.bin b/big.bin\n--- a/big.bin\n+++ b/big.bin\n@@ -1,3 +1,3 @@\n version https://git-lfs.github.com/spec/v1\n-oid sha256:{from.Oid}\n-size {from.Size}\n+oid sha256:{to.Oid}\n+size {to.Size}\n"; + + private static async Task ToolAvailableAsync(IReadOnlyList args) + { + try { return (await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = args, TimeoutSeconds = 15 }, CancellationToken.None)).Status == SandboxStatus.Success; } + catch { return false; } + } + + /// The git subcommand, past any leading -c key=value and -C dir. + private static string Subcommand(IReadOnlyList args) + { + var i = 0; + while (i + 1 < args.Count && args[i] is "-c" or "-C") i += 2; + return i < args.Count ? args[i] : ""; + } + + /// The remote, a scratch operator host (HOME, global config with the two store helpers, system config off) and the production classes running on it. + private sealed class OperatorHostContext : IAsyncDisposable + { + private readonly string _root = Path.Combine(Path.GetTempPath(), "cs-credstore-" + Guid.NewGuid().ToString("N")); + + private readonly Dictionary _environment; + + private OperatorHostContext() + { + Directory.CreateDirectory(Home); + _environment = new Dictionary { ["HOME"] = Home, ["GIT_CONFIG_GLOBAL"] = GlobalConfig, ["GIT_CONFIG_NOSYSTEM"] = "1" }; + Runner = new OperatorConfigRunner(_environment); + Registry = new SandboxRunnerRegistry(new ISandboxRunner[] { Runner }); + Provider = new LocalGitWorkspaceProvider(Registry, NullLogger.Instance, Path.Combine(_root, "workspaces")); + } + + public GitPublishRemoteFixture Remote { get; } = new() { AuthenticateReads = true }; + public OperatorConfigRunner Runner { get; } + public SandboxRunnerRegistry Registry { get; } + public LocalGitWorkspaceProvider Provider { get; } + public string BranchName { get; } = "codespace/agent/" + Guid.NewGuid().ToString("N"); + + /// The base's parent: absent from a depth-1 clone of the tip, so pinning it walks the fetch rungs through origin. + public string PinnedSha { get; private set; } = ""; + + private string Home => Path.Combine(_root, "home"); + private string GlobalConfig => Path.Combine(Home, ".gitconfig"); + private string GlobalStore => Path.Combine(_root, "store-global"); + private string ScopedStore => Path.Combine(_root, "store-scoped"); + + public static async Task StartAsync() + { + var ctx = new OperatorHostContext(); + + try + { + await ctx.Remote.StartAsync(); + ctx.PinnedSha = (await GitPublishRemoteFixture.GitAsync(ctx.Remote.Root, new[] { "--git-dir", ctx.Remote.Remote, "rev-parse", "main~1" })).Trim(); + ctx.WriteOperatorConfig(); + return ctx; + } + catch + { + await ctx.DisposeAsync(); + throw; + } + } + + /// A global store helper, and a second store scoped to the remote's URL — the reset must silence both. + private void WriteOperatorConfig() + { + var scope = new Uri(Remote.Url).GetLeftPart(UriPartial.Authority); + + File.WriteAllText(GlobalConfig, $"[credential]\n\thelper = store --file={GlobalStore}\n[credential \"{scope}\"]\n\thelper = store --file={ScopedStore}\n"); + } + + /// + /// Send every git request through , which global config names by its user alone and whose + /// password the operator's store holds — a proxy behind a credential helper. The child sees an empty NO_PROXY, so + /// even loopback goes through it. + /// + public void RouteThroughProxy(AuthenticatingProxy proxy) + { + File.AppendAllText(GlobalConfig, $"[http]\n\tproxy = http://{AuthenticatingProxy.User}@127.0.0.1:{proxy.Port}\n"); + File.WriteAllText(GlobalStore, $"http://{AuthenticatingProxy.User}:{AuthenticatingProxy.Password}@127.0.0.1:{proxy.Port}\n"); + _environment["NO_PROXY"] = ""; + _environment["no_proxy"] = ""; + } + + /// Point the operator's trace2 targets — normal, event and perf — at scratch files. + public void TraceToOperatorTargets() => + File.AppendAllText(GlobalConfig, $"[trace2]\n\tnormalTarget = {TraceFile("normal")}\n\teventTarget = {TraceFile("event")}\n\tperfTarget = {TraceFile("perf")}\n"); + + /// Everything the operator's trace2 targets recorded. + public string OperatorTrace() => string.Concat(new[] { "normal", "event", "perf" }.Select(TraceFile).Where(File.Exists).Select(File.ReadAllText)); + + private string TraceFile(string target) => Path.Combine(_root, "trace2-" + target); + + /// The LFS filters git lfs install puts in global config, so a checkout smudges LFS pointers into their objects. + public void InstallLfsFilters() => + File.AppendAllText(GlobalConfig, "[filter \"lfs\"]\n\tclean = git-lfs clean -- %f\n\tsmudge = git-lfs smudge -- %f\n\tprocess = git-lfs filter-process\n\trequired = true\n"); + + /// Integrate one contribution — over — into with the real integrator and the fake token. + public Task IntegrateAsync(string branch, string baseSha, string patch) => + new LocalGitBranchIntegrator(Registry, new InlineOffloader(), NullLogger.Instance).IntegrateAsync(new IntegrationRequest + { + TeamId = Guid.NewGuid(), RepositoryUrl = Remote.Url, BaseSha = baseSha, Token = GitPublishRemoteFixture.FakeToken, TokenUsername = "x-access-token", IntegrationBranch = branch, + Contributions = new[] { new BranchContribution { Label = "agent", BaseSha = baseSha, Patch = patch } }, + }, CancellationToken.None); + + /// The operator's own credential for the remote, as git credential-store keeps it. + public void SeedTheOperatorsStore() => + File.WriteAllText(GlobalStore, $"http://x-access-token:{GitPublishRemoteFixture.FakeToken}@{new Uri(Remote.Url).Authority}\n"); + + /// A soft ref with a fallback (so the probe runs), a depth-1 clone, and a pin to the base's parent (so the fetch rungs run). + public Task ProvisionAsync(string token) => + Provider.PrepareAsync(WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest + { + RepositoryUrl = Remote.Url, Token = token, TokenUsername = "x-access-token", Ref = "main", DefaultRef = "trunk", PinnedSha = PinnedSha, Depth = 1, + }), CancellationToken.None); + + public void ShouldHoldTheToken(bool stored) + { + foreach (var store in new[] { GlobalStore, ScopedStore }) + { + var holds = File.Exists(store) && File.ReadAllText(store).Contains(GitPublishRemoteFixture.FakeToken, StringComparison.Ordinal); + + holds.ShouldBe(stored, stored ? $"positive control: without the reset the operator's helper writes the token to {store}" : $"the token reached the operator's credential store {store}"); + } + } + + public async Task AgentCommitsAsync(string cloneDir) + { + await File.WriteAllTextAsync(Path.Combine(cloneDir, "agent.txt"), "the agent's work\n"); + await GitAsync(cloneDir, "add", "-A"); + await GitAsync(cloneDir, "-c", "user.name=Agent", "-c", "user.email=agent@example.test", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "agent work"); + } + + /// The agent tracks and commits a real LFS file (filters configured locally, no hooks); returns its oid. + public async Task AgentCommitsLfsFileAsync(string cloneDir) + { + await GitAsync(cloneDir, "config", "filter.lfs.clean", "git-lfs clean -- %f"); + await GitAsync(cloneDir, "config", "filter.lfs.smudge", "git-lfs smudge -- %f"); + await GitAsync(cloneDir, "config", "filter.lfs.process", "git-lfs filter-process"); + await GitAsync(cloneDir, "config", "filter.lfs.required", "true"); + await GitAsync(cloneDir, "lfs", "track", "*.bin"); + await File.WriteAllBytesAsync(Path.Combine(cloneDir, "big.bin"), System.Security.Cryptography.RandomNumberGenerator.GetBytes(4096)); + await GitAsync(cloneDir, "add", "-A"); + await GitAsync(cloneDir, "-c", "user.name=Agent", "-c", "user.email=agent@example.test", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "agent lfs file"); + + var pointer = await GitAsync(cloneDir, "show", "HEAD:big.bin"); + return pointer.Split('\n').Select(l => l.Trim()).First(l => l.StartsWith("oid sha256:", StringComparison.Ordinal))["oid sha256:".Length..]; + } + + public async Task RemoteShaAsync(string branch) => (await GitPublishRemoteFixture.GitAsync(Remote.Root, new[] { "--git-dir", Remote.Remote, "rev-parse", $"refs/heads/{branch}" })).Trim(); + + public Task RemoteFileAsync(string branch, string file) => GitPublishRemoteFixture.GitAsync(Remote.Root, new[] { "--git-dir", Remote.Remote, "show", $"refs/heads/{branch}:{file}" }); + + /// Test-side git (the agent's own commits) on the same scratch host, hooks off; never recorded, never a positive control. + private async Task GitAsync(string workdir, params string[] args) + { + var result = await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = new[] { "-c", "core.hooksPath=/dev/null" }.Concat(args).ToList(), WorkingDirectory = workdir, Environment = _environment, TimeoutSeconds = 120 }, CancellationToken.None); + + if (result.Status != SandboxStatus.Success) + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {result.Stderr}"); + + return result.Stdout; + } + + public async ValueTask DisposeAsync() + { + await Remote.DisposeAsync(); + try { Directory.Delete(_root, recursive: true); } catch { /* best-effort */ } + } + } + + /// + /// The real local runner on the scratch operator host, recording every spec the production code submits. With + /// set, that one subcommand runs as it did before tokened commands were marked — without the + /// credential-helper reset, with trace2 on — the positive control. + /// + private sealed class OperatorConfigRunner(IReadOnlyDictionary environment) : ISandboxRunner + { + private readonly LocalProcessRunner _inner = new(); + + public string Kind => "local"; + public string? Unreset { get; set; } + public List Specs { get; } = new(); + + public bool Ran(string subcommand) => Specs.Any(s => Subcommand(s.Args) == subcommand); + + public Task RunAsync(SandboxSpec spec, CancellationToken cancellationToken) + { + Specs.Add(spec); + + var unreset = Subcommand(spec.Args) == Unreset; + var env = new Dictionary(spec.Environment); + foreach (var (key, value) in environment) env[key] = value; + if (unreset) foreach (var key in TraceOff) env.Remove(key); + + return _inner.RunAsync(spec with { Args = unreset ? WithoutTheReset(spec.Args) : spec.Args, Environment = env }, cancellationToken); + } + + private static IReadOnlyList WithoutTheReset(IReadOnlyList args) + { + var at = Enumerable.Range(0, Math.Max(0, args.Count - 1)).FirstOrDefault(i => args[i] == "-c" && IsHelperReset(args[i + 1]), -1); + + return at < 0 ? args : args.Take(at).Concat(args.Skip(at + 2)).ToList(); + } + } + + private sealed class InlineOffloader : IArtifactOffloader + { + public Task ResolveAsync(Guid teamId, string? inline, Guid? artifactId, CancellationToken cancellationToken) => Task.FromResult(inline ?? ""); + + public Task OffloadIfLargeAsync(Guid teamId, string? text, string contentType, CancellationToken cancellationToken) => throw new NotSupportedException(); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Agents/LocalGitBranchIntegratorTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/LocalGitBranchIntegratorTests.cs index 858a1995d..7fe835fcd 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/LocalGitBranchIntegratorTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/LocalGitBranchIntegratorTests.cs @@ -1,4 +1,8 @@ +using CodeSpace.Core.Services.Agents.Sandbox; using CodeSpace.Core.Services.Agents.Workspace.Integrators; +using CodeSpace.Core.Services.Workflows.Artifacts; +using CodeSpace.Messages.Agents; +using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace CodeSpace.UnitTests.Agents; @@ -93,4 +97,70 @@ public void The_cap_never_splits_a_surrogate_pair() detail.ShouldBe(filler + "…", "the cap drops the WHOLE surrogate pair rather than emit its unpaired high half"); } + + // ── Tokened commands: the ones whose git transport reaches the tokened origin ────────── + + [Theory] + [InlineData(true, false)] // clean: the clone and the push + [InlineData(true, true)] // conflicted: the clone only — nothing is pushed + [InlineData(false, false)] // untokened: an anonymous clone keeps the operator's helpers and trace2 + [InlineData(false, true)] + public async Task Only_the_clone_and_the_push_run_as_tokened_commands(bool tokened, bool conflicted) + { + // The integration clone keeps its tokened origin to the end, but only the commands whose git transport talks to it + // hand the URL's password to credential helpers or write the URL to trace2: the clone names the authed URL, and the + // push goes through origin. The base checkout, the apply and the reset back to base download LFS objects through origin too, but + // git-lfs authenticates those from the URL and neither asks nor tells a helper (TokenedGitCredentialHelperFlowTests + // proves it), so they run as written, like the commit, the diffs and the rev-parses. + var runner = new IntegrationRunner(conflicted); + var integrator = new LocalGitBranchIntegrator(new SandboxRunnerRegistry(new ISandboxRunner[] { runner }), new InlineOffloader(), NullLogger.Instance); + + await integrator.IntegrateAsync(new IntegrationRequest + { + TeamId = Guid.NewGuid(), RepositoryUrl = "https://example.test/repo.git", BaseSha = "base", Token = tokened ? "test-token" : null, IntegrationBranch = "codespace/integration/run", + Contributions = new[] { new BranchContribution { Label = "agent", BaseSha = "base", Patch = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-a\n+b\n" } }, + }, CancellationToken.None); + + var transport = new[] { "clone", "push" }; + var subcommands = runner.Specs.Select(Subcommand).ToList(); + subcommands.ShouldContain(conflicted ? "reset" : "commit", "fixture check: the run took the intended path"); + subcommands.ShouldContain("checkout", "fixture check: the base was checked out"); + subcommands.ShouldContain("apply", "fixture check: the patch was applied"); + if (tokened && !conflicted) subcommands.ShouldContain("push", "fixture check: a clean tokened integration pushes"); + + foreach (var spec in runner.Specs) + TokenedGitSpecs.RunsTokened(spec, "https://example.test").ShouldBe(tokened && transport.Contains(Subcommand(spec)), string.Join(' ', spec.Args)); + } + + /// The git subcommand, past any leading -c key=value and -C dir. + private static string Subcommand(SandboxSpec spec) + { + var i = 0; + while (spec.Args[i] is "-c" or "-C") i += 2; + return spec.Args[i]; + } + + /// Answers an integration the way git would for one contribution: the apply succeeds or conflicts, the index then has staged changes, and the integration branch does not exist yet. + private sealed class IntegrationRunner(bool conflicted) : ISandboxRunner + { + public string Kind => "local"; + public List Specs { get; } = new(); + + public Task RunAsync(SandboxSpec spec, CancellationToken cancellationToken) + { + Specs.Add(spec); + + var fails = (spec.Args.Contains("apply") && conflicted) || spec.Args.Contains("--quiet") || spec.Args.Contains("--verify"); + + return Task.FromResult(fails + ? new SandboxResult { Status = SandboxStatus.Failed, ExitCode = 1, Stdout = "", Stderr = "" } + : new SandboxResult { Status = SandboxStatus.Success, ExitCode = 0, Stdout = "", Stderr = "" }); + } + } + + private sealed class InlineOffloader : IArtifactOffloader + { + public Task ResolveAsync(Guid teamId, string? inline, Guid? artifactId, CancellationToken cancellationToken) => Task.FromResult(inline ?? ""); + public Task OffloadIfLargeAsync(Guid teamId, string? text, string contentType, CancellationToken cancellationToken) => throw new NotSupportedException(); + } } diff --git a/backend/tests/CodeSpace.UnitTests/Agents/PackCloneFetcherArgsTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/PackCloneFetcherArgsTests.cs index 76626d8e0..35ad05483 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/PackCloneFetcherArgsTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/PackCloneFetcherArgsTests.cs @@ -47,6 +47,19 @@ public void Clone_argv_passes_a_branch_reference_when_set() branch.ShouldBeLessThan(args.IndexOf("--"), "--branch is an option, so it precedes the end-of-options marker"); } + [Theory] + [InlineData("https://someone:ghp_pasted_token@github.com/owner/repo", true)] // a pasted URL with a personal token in it + [InlineData("https://github.com/owner/repo", false)] + public void Only_a_clone_url_carrying_a_token_runs_as_a_tokened_command(string url, bool tokened) + { + var spec = PackCloneFetcher.BuildCloneSpec(url, reference: null, dir: "/tmp/dest"); + + TokenedGitSpecs.RunsTokened(spec, "https://github.com").ShouldBe(tokened, "a helper would store the pasted token on success, and trace2 would record it"); + spec.Args.Skip(tokened ? 2 : 0).ShouldBe(PackCloneFetcher.BuildCloneArgs(url, reference: null, dir: "/tmp/dest"), "the hardened clone argv, redirect guard included, either way"); + spec.WorkingDirectory.ShouldBe("/tmp/dest"); + spec.AllowNetwork.ShouldBeTrue(); + } + [Fact] public void Clone_argv_omits_branch_when_no_reference() { diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorAcceptanceGraderTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorAcceptanceGraderTests.cs index 810949931..5b4a2e231 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorAcceptanceGraderTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorAcceptanceGraderTests.cs @@ -192,6 +192,24 @@ public async Task GradeBaseAsync_fails_closed_with_a_prefixed_detail_when_the_ba grade.Detail.ShouldStartWith("clone-failed:", customMessage: "the prefix is the interim infra-vs-genuine discriminator a differential consumer keys on (until F0's typed dispositions)"); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Only_a_tokened_base_clone_runs_as_a_tokened_command(bool tokened) + { + // The clone is the one command that names the authed URL; the token strip follows it at once, so the detached + // checkout and everything after it reach no tokened remote. An untokened clone keeps the operator's helpers. + var runners = new ScriptedApplyRunnerRegistry(applySucceeds: true); + var grader = Build(new FakeResolver(new WorkspaceRequest { RepositoryUrl = "https://example.test/r.git", Token = tokened ? "test-token" : null }), new FakeGrader(Pass), runners: runners); + + await grader.GradeBaseAsync(Guid.NewGuid(), Guid.NewGuid(), "deadbeef", Spec(), 30, CancellationToken.None); + + var clone = runners.Invocations.Single(i => i.Args.Contains("clone")); + TokenedGitSpecs.RunsTokened(clone, "https://example.test").ShouldBe(tokened, string.Join(' ', clone.Args)); + runners.Invocations.ShouldContain(i => i.Args.Contains("checkout"), "fixture check: the base checkout ran after the clone"); + runners.Invocations.Where(i => !i.Args.Contains("clone")).ShouldAllBe(i => !TokenedGitSpecs.RunsTokened(i, "https://example.test")); + } + // ── S2: GradePatchAsync — the branch-less twin (a fresh clone at the BASE SHA + apply, no push) ──── [Fact] diff --git a/backend/tests/CodeSpace.UnitTests/TokenedGitSpecs.cs b/backend/tests/CodeSpace.UnitTests/TokenedGitSpecs.cs new file mode 100644 index 000000000..6f2ffc4b0 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/TokenedGitSpecs.cs @@ -0,0 +1,29 @@ +using CodeSpace.Messages.Agents; +using Shouldly; + +namespace CodeSpace.UnitTests; + +/// +/// The call-site pins' view of a tokened git command, spelled out literally rather than through +/// TokenedGitCommand itself: the reset scoped to the remote leads the argv exactly once, and every trace2 target +/// is off. A spec carrying only part of that fails the test outright — it is neither shape. +/// +internal static class TokenedGitSpecs +{ + private static readonly string[] TraceOff = { "GIT_TRACE2", "GIT_TRACE2_EVENT", "GIT_TRACE2_PERF" }; + + /// True when runs as a tokened command for the remote at (its scheme and authority, e.g. https://example.test); false when it carries no part of one. + public static bool RunsTokened(SandboxSpec spec, string scope) + { + var leads = spec.Args.Take(2).SequenceEqual(new[] { "-c", $"credential.{scope}.helper=" }); + var resets = spec.Args.Count(a => a.StartsWith("credential.", StringComparison.Ordinal) && a.EndsWith(".helper=", StringComparison.Ordinal)); + var traceOff = TraceOff.Count(k => spec.Environment.TryGetValue(k, out var v) && v == "0"); + var traceKeys = TraceOff.Count(spec.Environment.ContainsKey); + + var tokened = leads && resets == 1 && traceOff == TraceOff.Length; + var untokened = resets == 0 && traceKeys == 0; + (tokened || untokened).ShouldBeTrue($"half a tokened command: {string.Join(' ', spec.Args)} | env {string.Join(' ', spec.Environment.Keys)}"); + + return tokened; + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs index ce780dcfc..49e892e02 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs @@ -628,6 +628,87 @@ public Task RunAsync(SandboxSpec spec, CancellationToken cancella } } + // ─── Tokened commands: every command that runs while the token is in reach ────────── + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Every_command_before_the_token_strip_runs_as_a_tokened_command_when_the_clone_is_tokened(bool tokened) + { + // Until the strip, the token is in reach: the probe and the clone name the authed URL, and the pin's fetch rungs go + // through the still-tokened origin. Every command before the strip shares one runner path, so each runs as a + // tokened command — the local ones (the ancestry checks, the pin's checkout) at no cost. The strip and the base + // read after it reach no tokened remote, and an untokened clone keeps the operator's helpers and trace2 on every + // command — they may be how it authenticates. + var runner = new PinFetchRunner(); + var provider = new LocalGitWorkspaceProvider(new SandboxRunnerRegistry(new[] { runner }), NullLogger.Instance); + + await using var handle = await provider.PrepareAsync(WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest + { + RepositoryUrl = "https://example.test/repo.git", Token = tokened ? "test-token" : null, Ref = "session", DefaultRef = "main", PinnedSha = new string('b', 40), Depth = 1, + }), CancellationToken.None); + + var subcommands = runner.Specs.Select(Subcommand).ToList(); + subcommands.ShouldContain("ls-remote", "fixture check: the soft-ref probe ran"); + subcommands.Count(s => s == "fetch").ShouldBe(3, "fixture check: every fetch rung of the pin ran"); + subcommands.ShouldContain("checkout", "fixture check: the pin was checked out"); + + var strip = runner.Specs.FindIndex(s => s.Args.Contains("set-url")); + (strip > 0).ShouldBe(tokened, "fixture check: only a tokened clone strips its origin"); + + for (var i = 0; i < runner.Specs.Count; i++) + TokenedGitSpecs.RunsTokened(runner.Specs[i], "https://example.test").ShouldBe(tokened && i < strip, string.Join(' ', runner.Specs[i].Args)); + } + + [Fact] + public async Task Only_the_publish_commands_that_name_the_authed_url_run_as_tokened_commands() + { + // The LFS upload, the push and its readback carry the token in their argv. Every other post-turn command — over the + // agent clone or in the publish repo — reaches no tokened remote and is left as written. + var runner = new PostTurnRunner(agentCommittedItself: false); + var provider = new LocalGitWorkspaceProvider(new SandboxRunnerRegistry(new[] { runner }), NullLogger.Instance); + const string token = "fixture-token"; + + await using var handle = await provider.PrepareAsync(PostTurnProvision(token, multiRepo: false), CancellationToken.None); + var oid = "abcd" + new string('0', 60); + var lfsObject = Path.Combine(handle.Directory, ".git", "lfs", "objects", "ab", "cd", oid); + Directory.CreateDirectory(Path.GetDirectoryName(lfsObject)!); + await File.WriteAllTextAsync(lfsObject, "an lfs object the branch points at\n"); + var prepared = runner.Specs.Count; + + (await ((IWorkspacePushHandle)handle).PushChangesAsync("codespace/run", CancellationToken.None)).ShouldBe("codespace/run"); + + var postTurn = runner.Specs.Skip(prepared).ToList(); + var tokened = postTurn.Where(s => s.Args.Any(a => a.Contains(token))).ToList(); + + tokened.Select(Subcommand).ShouldBe(new[] { "lfs", "push", "ls-remote" }, "fixture check: the LFS upload, the push and the readback all ran"); + tokened.ShouldAllBe(s => TokenedGitSpecs.RunsTokened(s, "https://example.test")); + postTurn.Where(s => !tokened.Contains(s)).ShouldAllBe(s => !TokenedGitSpecs.RunsTokened(s, "https://example.test")); + } + + /// The git subcommand, past any leading -c key=value and -C dir. + private static string Subcommand(SandboxSpec spec) + { + var i = 0; + while (spec.Args[i] is "-c" or "-C") i += 2; + return spec.Args[i]; + } + + /// Records every spec and answers success with a fixed 40-char sha, except that no commit is ever local (rev-parse --verify fails), so a pin walks every fetch rung before its checkout. + private sealed class PinFetchRunner : ISandboxRunner + { + public string Kind => "local"; + public List Specs { get; } = new(); + public Task RunAsync(SandboxSpec spec, CancellationToken cancellationToken) + { + Specs.Add(spec); + + return Task.FromResult(spec.Args.Contains("--verify") + ? new SandboxResult { Status = SandboxStatus.Failed, ExitCode = 1, Stdout = "", Stderr = "" } + : new SandboxResult { Status = SandboxStatus.Success, ExitCode = 0, Stdout = new string('a', 40), Stderr = "" }); + } + } + // ─── Change capture ────────────────────────────────────────────────────── // ── S1: PinnedSha — the immutable-base substrate ───────────────────────────────── diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs index 7eb9a3af0..a54c591e0 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs @@ -176,8 +176,37 @@ public void SanitizeUrl_strips_userinfo_and_leaves_clean_urls_alone() RemoteTipResolver.SanitizeUrl("https://host/repo.git").ShouldBe("https://host/repo.git"); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task The_launch_probe_runs_as_a_tokened_command_only_when_it_carries_a_token(bool tokened) + { + // The probe names the authed URL, so a tokened probe must leave nothing for an operator's store helper or trace2 + // target to keep; an untokened one keeps both, and the helpers may be how it authenticates. + var tip = new string('c', 40); + var runner = new LsRemoteRunner($"{tip}\trefs/heads/main\n"); + var request = new WorkspaceRequest { RepositoryUrl = "https://example.test/repo.git", Token = tokened ? "test-token" : null, Ref = "main" }; + + var sha = await new RemoteTipResolver(new SandboxRunnerRegistry(new ISandboxRunner[] { runner })).ResolveTipShaAsync(request, refRequired: true, CancellationToken.None); + + sha.ShouldBe(tip); + var probe = runner.Specs.ShouldHaveSingleItem(); + TokenedGitSpecs.RunsTokened(probe, "https://example.test").ShouldBe(tokened, string.Join(' ', probe.Args)); + } + // ─── harness (the LocalGitWorkspaceProviderTests pattern) ─────────────────────── + private sealed class LsRemoteRunner(string stdout) : ISandboxRunner + { + public string Kind => "local"; + public List Specs { get; } = new(); + public Task RunAsync(SandboxSpec spec, CancellationToken cancellationToken) + { + Specs.Add(spec); + return Task.FromResult(new SandboxResult { Status = SandboxStatus.Success, ExitCode = 0, Stdout = stdout, Stderr = "" }); + } + } + private static RemoteTipResolver NewResolver() => new(new SandboxRunnerRegistry(new ISandboxRunner[] { new LocalProcessRunner() })); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/TokenedGitCommandTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/TokenedGitCommandTests.cs new file mode 100644 index 000000000..28101e5e2 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/TokenedGitCommandTests.cs @@ -0,0 +1,83 @@ +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Core.Services.Agents.Workspace.Providers; +using CodeSpace.Messages.Agents; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// — the one place a git command is marked as carrying a clone token. Pins the scoped +/// reset and the trace2 switch literally, and which remote URLs count as tokened; the call sites are pinned next to their +/// own classes, and TokenedGitCredentialHelperFlowTests proves both against real git, a real store helper, a +/// proxy whose password that helper holds, and real trace2 targets. +/// +[Trait("Category", "Unit")] +public sealed class TokenedGitCommandTests +{ + [Theory] + [InlineData("https://x-access-token:ghp_abc@github.com/org/repo.git", "credential.https://github.com.helper=")] + [InlineData("http://x-access-token:t@127.0.0.1:8080/remote.git", "credential.http://127.0.0.1:8080.helper=")] + [InlineData("https://oauth2:p%40ss%2Fword@GitLab.Example.com:8443/org/repo.git", "credential.https://gitlab.example.com:8443.helper=")] + public void The_reset_is_scoped_to_the_tokened_remote_and_pinned_literally(string url, string reset) + { + // EMPTY, not false: an empty helper value clears the list collected so far, any other value adds one more helper. + // Scoped to the remote's scheme and authority: it clears every helper git would ask about that remote, and leaves + // the ones a proxy or another host needs. Never the userinfo — the key must not carry the token itself. + TokenedGitCommand.CredentialHelperReset(url).ShouldBe(new[] { "-c", reset }); + } + + [Fact] + public void Trace2_off_is_pinned_literally() + { + // git's own names for its three trace2 targets. The environment wins over the trace2.*Target an operator set in + // system or global config, which git reads before any -c. + TokenedGitCommand.TraceOff.OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => $"{kv.Key}={kv.Value}").ShouldBe(new[] { "GIT_TRACE2=0", "GIT_TRACE2_EVENT=0", "GIT_TRACE2_PERF=0" }); + } + + [Theory] + [InlineData("https://x-access-token:ghp_abc@github.com/org/repo.git")] + [InlineData("https://oauth2:p%40ss%2Fword@gitlab.com/org/repo.git")] + [InlineData("http://x-access-token:t@127.0.0.1:8080/remote.git")] + public void A_url_carrying_a_password_is_tokened(string url) => TokenedGitCommand.IsTokened(url).ShouldBeTrue(); + + [Theory] + [InlineData("https://github.com/org/repo.git")] + [InlineData("https://user@github.com/org/repo.git")] // a username alone is not a secret + [InlineData("https://user:@github.com/org/repo.git")] // nor is an empty password + [InlineData("ssh://git@github.com/org/repo.git")] + [InlineData("git@github.com:org/repo.git")] + [InlineData("file:///srv/repo.git")] + [InlineData("/srv/repo.git")] + public void A_url_without_a_password_is_not_tokened(string url) => TokenedGitCommand.IsTokened(url).ShouldBeFalse(); + + [Fact] + public void Every_authenticated_url_the_platform_builds_is_tokened() + { + TokenedGitCommand.IsTokened(LocalGitWorkspaceProvider.BuildAuthenticatedUrl("https://github.com/org/repo.git", null, "p@ss/w+rd")).ShouldBeTrue(); + TokenedGitCommand.IsTokened(LocalGitWorkspaceProvider.BuildAuthenticatedUrl("https://gitlab.com/org/repo.git", "oauth2", "glpat")).ShouldBeTrue(); + TokenedGitCommand.IsTokened(LocalGitWorkspaceProvider.BuildAuthenticatedUrl("https://github.com/org/repo.git", null, null)).ShouldBeFalse("no token: the URL is left as the operator stored it"); + } + + [Fact] + public void A_tokened_command_gets_the_reset_ahead_of_its_arguments_and_trace2_off() + { + const string url = "https://x-access-token:t@host/r.git"; + var spec = new SandboxSpec { Command = "git", Args = new[] { "-c", "lfs.locksverify=false", "lfs", "push", url, "branch" }, Environment = new Dictionary { ["LANG"] = "C", ["GIT_TRACE2"] = "/tmp/trace" }, TimeoutSeconds = 30, AllowNetwork = true }; + + var tokened = TokenedGitCommand.Spec(url, spec); + + tokened.Args.ShouldBe(new[] { "-c", "credential.https://host.helper=" }.Concat(spec.Args)); + tokened.Environment["LANG"].ShouldBe("C", "the command's own environment is kept"); + foreach (var (name, value) in TokenedGitCommand.TraceOff) tokened.Environment[name].ShouldBe(value, "trace2 off wins over any value the command brought"); + (tokened with { Args = spec.Args, Environment = spec.Environment }).ShouldBe(spec, "nothing else about the command changes"); + TokenedGitSpecs.RunsTokened(tokened, "https://host").ShouldBeTrue(); + } + + [Fact] + public void An_untokened_command_is_left_as_written() + { + var spec = new SandboxSpec { Command = "git", Args = new[] { "clone", "https://host/r.git", "/tmp/x" } }; + + TokenedGitCommand.Spec("https://host/r.git", spec).ShouldBeSameAs(spec, "the operator's helpers may be how an untokened remote authenticates"); + } +}