From c69acf95bfe146add355a3c520f34fcb2a867afb Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Wed, 7 Oct 2026 01:18:48 +0800 Subject: [PATCH] Keep clone tokens out of operator credential helpers and traces Every platform git command that carries a clone token in its URL - the workspace clone with its soft-ref probe and pin fetches, the publish push, LFS upload and readback, the launch-base ls-remote, the integration clone and push, the grader's base clone, and a pack clone from a pasted URL - still honoured system and global git config. On success git's transport hands the URL's username and password to every configured credential helper to store (git lfs push too: it runs that transport against the URL itself), so an operator's store or cache helper, or a system keychain, kept each run's token at rest. git also writes every command's argv, and each child's, to the trace2 targets that config names, and git 2.33 writes the URL's password verbatim. A tokened command now runs with `-c credential.://.helper=`. An empty value scoped to the tokened remote clears the helper list git builds for that remote - global helpers and ones scoped to its URL, path, user or an included file alike - and a command-line value is read last, so nothing is stored and nothing is asked: the token is already in the URL. The reset is scoped rather than global because git asks the same helpers for other hosts' credentials; an authenticating proxy named with only a user, or an LFS endpoint on another host, still gets the operator's password. A tokened command also runs with GIT_TRACE2, GIT_TRACE2_EVENT and GIT_TRACE2_PERF set to 0, which wins over the trace2 targets in config; a -c cannot, because git reads those first. TokenedGitCommand is the one place that decides a command is tokened - its git transport can reach a remote whose URL embeds a password - and applies both. Untokened commands keep the operator's helpers and tracing, which may be how they reach a private mirror. A checkout, reset or apply in a clone whose origin is tokened stays untokened: it reaches origin only for LFS objects, and git-lfs authenticates those from the URL without asking or telling a helper. Verified against git 2.33.0, 2.50.1 and 2.56.0 with git-lfs 3.7.1. The remote fixture's port retry reused an HttpListener that a failed Start had already closed; each attempt now gets a fresh listener. --- .../Services/Agents/PackCloneFetcher.cs | 9 +- .../Integrators/LocalGitBranchIntegrator.cs | 29 +- .../Providers/LocalGitWorkspaceProvider.cs | 36 +- .../Agents/Workspace/RemoteTipResolver.cs | 5 +- .../Agents/Workspace/TokenedGitCommand.cs | 63 +++ .../Supervisor/SupervisorAcceptanceGrader.cs | 2 +- .../Workflows/GitPublishRemoteFixture.cs | 206 +++++++- .../TokenedGitCredentialHelperFlowTests.cs | 446 ++++++++++++++++++ .../Agents/LocalGitBranchIntegratorTests.cs | 70 +++ .../Agents/PackCloneFetcherArgsTests.cs | 13 + .../Agents/SupervisorAcceptanceGraderTests.cs | 18 + .../CodeSpace.UnitTests/TokenedGitSpecs.cs | 29 ++ .../LocalGitWorkspaceProviderTests.cs | 81 ++++ .../Workflows/RemoteTipResolverTests.cs | 29 ++ .../Workflows/TokenedGitCommandTests.cs | 83 ++++ 15 files changed, 1089 insertions(+), 30 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Services/Agents/Workspace/TokenedGitCommand.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/TokenedGitCredentialHelperFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/TokenedGitSpecs.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/TokenedGitCommandTests.cs 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"); + } +}