Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/Agentweaver.AgentHost/AgentHostRuntimeState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ internal sealed class AgentHostRuntimeState
/// </summary>
public string? GitHubAccessToken { get; private set; }

/// <summary>
/// Short-lived installation credential for the configured run and repository. The shell tool
/// passes this value only to a simple <c>git</c> or <c>gh</c> child process.
/// </summary>
public string? RepositoryAccessToken { get; private set; }

/// <summary>
/// The authenticated platform caller token forwarded only for operator-assistant MCP requests.
/// This is distinct from <see cref="GitHubAccessToken"/>: in Entra deployments the former is the
Expand All @@ -107,6 +113,7 @@ public void InitializeFromOptions(AgentHostOptions options)
PreviewRunnerCredential = string.Empty; // not available on env-var launch path
KvUserSecretName = options.KvUserSecretName;
GitHubAccessToken = null; // not available on env-var launch path
RepositoryAccessToken = null;
CallerBearerToken = null; // operator-assistant-only warm-pod input
Purpose = AgentHostPurpose.Default;
WorkspaceMode = ExecutionWorkspaceMode.Shared;
Expand All @@ -127,15 +134,16 @@ public void InitializeFromOptions(AgentHostOptions options)
/// Atomically transitions the pod from standby to configured. Returns <see langword="false"/>
/// when the pod was already configured (one-time semantics → caller returns 409).
/// </summary>
public bool TryConfigure(string runId, string userId, string turnBearerToken, string? kvUserSecretName, string? gitHubAccessToken, string? previewRunnerCredential = null)
public bool TryConfigure(string runId, string userId, string turnBearerToken, string? kvUserSecretName, string? gitHubAccessToken, string? previewRunnerCredential = null, string? repositoryAccessToken = null)
=> TryConfigure(new AgentHostRunConfiguration(
runId,
userId,
turnBearerToken,
kvUserSecretName,
gitHubAccessToken,
previewRunnerCredential,
SharedWorkingDirectory: null));
SharedWorkingDirectory: null,
RepositoryAccessToken: repositoryAccessToken));

/// <summary>Atomically applies the complete run-scoped warm-pod configuration.</summary>
public bool TryConfigure(AgentHostRunConfiguration configuration)
Expand All @@ -153,6 +161,9 @@ public bool TryConfigure(AgentHostRunConfiguration configuration)
GitHubAccessToken = string.IsNullOrWhiteSpace(configuration.GitHubAccessToken)
? null
: configuration.GitHubAccessToken;
RepositoryAccessToken = string.IsNullOrWhiteSpace(configuration.RepositoryAccessToken)
? null
: configuration.RepositoryAccessToken;
CallerBearerToken = string.IsNullOrWhiteSpace(configuration.CallerBearerToken)
? null
: configuration.CallerBearerToken;
Expand Down Expand Up @@ -208,4 +219,5 @@ internal sealed record AgentHostRunConfiguration(
string? CommitAuthorEmail = null,
string? ProjectId = null,
string? AgentName = null,
string? CallerBearerToken = null);
string? CallerBearerToken = null,
string? RepositoryAccessToken = null);
11 changes: 10 additions & 1 deletion apps/Agentweaver.AgentHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Agentweaver.AgentHost;
using Agentweaver.AgentRuntime;
using Agentweaver.AgentRuntime.Providers;
using Agentweaver.AgentTools;
using Agentweaver.Domain;
using Agentweaver.SandboxExec;
using Agentweaver.SandboxExec.PodExec;
Expand Down Expand Up @@ -130,6 +131,7 @@

// ── Sandbox policy (no DB in pod) ─────────────────────────────────────────────
builder.Services.AddSingleton<ISandboxPolicyStore, PodSandboxPolicyStore>();
builder.Services.AddSingleton<ISandboxRepositoryCredentialProvider, RunScopedRepositoryCredentialProvider>();

// ── Agent runtime (in-memory approvals, local executor — Kata VM IS the sandbox) ─
builder.Services.AddSingleton<PreviewRunner>();
Expand Down Expand Up @@ -588,6 +590,12 @@ internal sealed record ConfigureRequest
/// </summary>
public string? GitHubAccessToken { get; init; }

/// <summary>
/// Short-lived credential for the configured run and repository. The runtime gives this value
/// only to a single <c>git</c> or <c>gh</c> shell command.
/// </summary>
public string? RepositoryAccessToken { get; init; }

/// <summary>
/// Authenticated platform caller token used by the operator assistant's MCP connection. Kept
/// separate from <see cref="GitHubAccessToken"/> because Entra deployments use different
Expand Down Expand Up @@ -679,7 +687,8 @@ internal sealed record ConfigureRequest
CommitAuthorEmail,
ProjectId,
AgentName,
CallerBearerToken);
CallerBearerToken,
RepositoryAccessToken);
}

internal sealed record PreviewProcessStartRequest
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Agentweaver.AgentTools;

namespace Agentweaver.AgentHost;

/// <summary>Provides the in-memory repository credential for the configured run.</summary>
internal sealed class RunScopedRepositoryCredentialProvider(
AgentHostRuntimeState runtimeState) : ISandboxRepositoryCredentialProvider
{
public string? GetAccessToken() => runtimeState.RepositoryAccessToken;
}
45 changes: 45 additions & 0 deletions apps/Agentweaver.Api/Auth/GitHubCapabilityBroker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,51 @@ internal sealed class GitHubCapabilityBroker(
: (GitHubCapabilityBrokerOutcome.Issued, new(fenced.Purpose, operation, expiresAt));
}

/// <summary>
/// Mints one short-lived installation credential after snapshot fencing. The caller receives
/// the value only in its callback. The API never uses this value for a GitHub command.
/// </summary>
internal async Task<GitHubCapabilityBrokerOutcome> TryUseRepositoryCredentialAsync(
SnapshotRef snapshotRef,
DateTimeOffset now,
Func<string, DateTimeOffset, Task> useCredential,
CancellationToken ct)
{
var fenced = await persistence.TryFenceLiveSnapshotAsync(
GitHubCapabilityPurpose.UnattendedRepository,
snapshotRef,
now,
ct).ConfigureAwait(false);
if (fenced is null)
return GitHubCapabilityBrokerOutcome.CapabilityUnavailable;

string? token = null;
DateTimeOffset? expiresAt = null;
var outcome = await installationTokens.MintForRepositoryAsync(
fenced.InstallationId!.Value,
fenced.RepositoryId!.Value,
(value, expires) =>
{
token = value;
expiresAt = expires;
return Task.CompletedTask;
},
ct).ConfigureAwait(false);
if (outcome != RepoAppInstallationOutcome.Success || string.IsNullOrWhiteSpace(token) ||
expiresAt is null || expiresAt <= now)
return GitHubCapabilityBrokerOutcome.CapabilityUnavailable;

if (await persistence.TryFenceLiveSnapshotAsync(
GitHubCapabilityPurpose.UnattendedRepository,
snapshotRef,
now,
ct).ConfigureAwait(false) is null)
return GitHubCapabilityBrokerOutcome.CapabilityUnavailable;

await useCredential(token, expiresAt.Value).ConfigureAwait(false);
return GitHubCapabilityBrokerOutcome.Issued;
}

internal static bool IsOperationAllowed(
GitHubCapabilityPurpose purpose,
GitHubCapabilityOperation operation) =>
Expand Down
1 change: 1 addition & 0 deletions apps/Agentweaver.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@
// AgentHost__UserId, scoping the in-pod GitHub Copilot auth to the user's Copilot-entitled token
// instead of the installation token (which fails the first model turn).
builder.Services.AddSingleton<IRunSubmittingUserResolver, RunStoreSubmittingUserResolver>();
builder.Services.AddSingleton<RunRepositoryCredentialRegistry>();
builder.Services.AddSingleton<IRunAgentHostContextResolver>(sp =>
new RunAgentHostContextResolver(
sp.GetRequiredService<Agentweaver.Api.Infrastructure.IRunStore>(),
Expand Down
21 changes: 20 additions & 1 deletion apps/Agentweaver.Api/Sandbox/AgentHostReaperService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,24 @@ public sealed class AgentHostReaperService : IAgentHostReaper
// First-class preview lifecycle reconciler. Before reaping an orphaned claim, it derives durable
// Previewable/PreviewActive state and atomically owns all retention or cleanup side effects.
private readonly Preview.ISandboxPreviewService? _previewService;
private readonly RunRepositoryCredentialRegistry? _repositoryCredentials;

public AgentHostReaperService(
IKubernetes client,
IRunStore runStore,
KubernetesSandboxOptions options,
ILogger<AgentHostReaperService> logger,
ISecretStore? secretStore = null,
Preview.ISandboxPreviewService? previewService = null)
Preview.ISandboxPreviewService? previewService = null,
RunRepositoryCredentialRegistry? repositoryCredentials = null)
{
_client = client;
_runStore = runStore;
_options = options;
_logger = logger;
_secretStore = secretStore;
_previewService = previewService;
_repositoryCredentials = repositoryCredentials;
}

/// <inheritdoc />
Expand Down Expand Up @@ -105,6 +108,7 @@ public async Task<int> SweepOrphanedPodsAsync(CancellationToken ct = default)
// from the claim annotation and delete it so the credential's durable lifetime stays
// bounded by the pod's (spec-006 decouple-preview; no-op when absent).
await TryDeleteOrphanCredentialAsync(claim.AnnotatedRunId, ct).ConfigureAwait(false);
await TryRevokeOrphanRepositoryCredentialAsync(claim.AnnotatedRunId, ct).ConfigureAwait(false);
}
}

Expand Down Expand Up @@ -251,6 +255,21 @@ await _secretStore.DeleteSecretAsync(Preview.PreviewRunnerCredential.SecretKey(r
}
}

private async Task TryRevokeOrphanRepositoryCredentialAsync(string? runId, CancellationToken ct)
{
if (_repositoryCredentials is null || string.IsNullOrWhiteSpace(runId))
return;

try
{
await _repositoryCredentials.RevokeAsync(runId, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "AgentHostReaper: failed to revoke repository credential for run {RunId}", runId);
}
}

/// <summary>
/// Reconciles every preview-retention side effect before deciding whether to reap. A missing
/// service/run id or reconciliation failure defaults to <c>Previewable</c> (leak-safe) rather than
Expand Down
29 changes: 29 additions & 0 deletions apps/Agentweaver.Api/Sandbox/KubernetesSandboxExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
// Source of the per-run AutoApproveTools flag propagated to the warm pod via /configure (bug
// #221). Null in unit tests → the flag defaults false (same null-skip convention as above).
private readonly IRunOptionsStore? _runOptions;
private readonly RunRepositoryCredentialRegistry? _repositoryCredentials;
// First-class preview lifecycle reconciler. ReleaseAgentHostPodAsync derives durable
// Previewable/PreviewActive state and applies all retention or cleanup effects before deciding
// whether to delete the claim.
Expand All @@ -241,6 +242,7 @@
ISecretStore? secretStore = null,
IRunEventStream? runEventStream = null,
IRunOptionsStore? runOptions = null,
RunRepositoryCredentialRegistry? repositoryCredentials = null,
IGitHubAccessTokenProvider? accessTokenProvider = null,
Agentweaver.Api.Sandbox.Preview.ISandboxPreviewService? previewService = null,
IGitHubTokenScopeProvider? tokenScopeProvider = null,
Expand All @@ -259,6 +261,7 @@
_secretStore = secretStore;
_runEventStream = runEventStream;
_runOptions = runOptions;
_repositoryCredentials = repositoryCredentials;
_accessTokenProvider = accessTokenProvider;
_previewService = previewService;
_authorshipCapabilityStore = authorshipCapabilityStore;
Expand Down Expand Up @@ -558,10 +561,14 @@
// immutable source refs; AgentHost creates their effective root inside execution-scratch.
if (claimCreated)
{
var repositoryAccessToken = _repositoryCredentials is null
? null
: await _repositoryCredentials.MintAsync(runId, ct).ConfigureAwait(false);
var effectiveWorkingDirectory = await CallAgentHostConfigureAsync(
podIp, _options.AgentHostPort, runId, submittingUser, turnToken, kvUserSecretName,
effectiveScope,
await ResolveGitHubAccessTokenAsync(effectiveScope, submittingUser, ct).ConfigureAwait(false),
repositoryAccessToken,
requestedWorkingDirectory ?? await ResolveWorkingDirectoryAsync(runId, ct).ConfigureAwait(false),
launchContext,
configProjectId,
Expand Down Expand Up @@ -600,6 +607,7 @@
// Crash/timeout during launch: delete any credential minted before the failure so it is
// never left behind (spec-006 decouple-preview, RESIDUAL rev3 gap).
await DeletePreviewRunnerCredentialAsync(runId, CancellationToken.None).ConfigureAwait(false);
await RevokeRepositoryCredentialAsync(runId, CancellationToken.None).ConfigureAwait(false);
throw;
}
}
Expand Down Expand Up @@ -634,6 +642,7 @@
if (_authorshipCapabilityStore is not null)
await _authorshipCapabilityStore.RemoveAsync(runId, ct).ConfigureAwait(false);
await DeletePreviewRunnerCredentialAsync(runId, ct).ConfigureAwait(false);
await RevokeRepositoryCredentialAsync(runId, ct).ConfigureAwait(false);

_logger.LogInformation(
"KubernetesSandboxExecutor: AgentHost pod released for run {RunId}", runId);
Expand Down Expand Up @@ -942,6 +951,7 @@
private async Task<string?> CallAgentHostConfigureAsync(
string podIp, int port, string runId, string userId, string turnBearerToken,
string kvUserSecretName, GitHubTokenScope tokenScope, string? gitHubAccessToken,
string? repositoryAccessToken,
string? sharedWorkingDirectory,
AgentHostLaunchContext launchContext,
string? projectId,
Expand Down Expand Up @@ -974,6 +984,7 @@
turnBearerToken,
kvUserSecretName,
gitHubAccessToken,
repositoryAccessToken,
callerBearerToken = launchContext.CallerBearerToken,
// Keep the legacy property during rolling upgrades; new AgentHosts prefer the explicit
// sharedWorkingDirectory descriptor and create any local workspace inside the pod.
Expand Down Expand Up @@ -1147,6 +1158,24 @@
}
}

private async Task RevokeRepositoryCredentialAsync(string runId, CancellationToken ct)
{
if (_repositoryCredentials is null)
return;

try
{
await _repositoryCredentials.RevokeAsync(runId, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(
ex,
"KubernetesSandboxExecutor: failed to revoke repository credential for run {RunId}",
runId);
Comment thread
sabbour marked this conversation as resolved.
Dismissed
}
}

/// <summary>
/// Waits for the AgentHost <c>SandboxClaim</c> to bind while emitting periodic
/// <see cref="EventTypes.SandboxProvisioningPending"/> heartbeats into the CHILD run's event
Expand Down
Loading
Loading