diff --git a/apps/Agentweaver.AgentHost/AgentHostOptions.cs b/apps/Agentweaver.AgentHost/AgentHostOptions.cs index 67c6f5aa9..c0b7f033d 100644 --- a/apps/Agentweaver.AgentHost/AgentHostOptions.cs +++ b/apps/Agentweaver.AgentHost/AgentHostOptions.cs @@ -129,42 +129,4 @@ public sealed class AgentHostOptions /// Optional system prompt context injected by the workflow graph. public string? SystemPromptContext { get; init; } - // ── Token store selection ───────────────────────────────────────────────── - - /// - /// When , the agent-host reads GitHub tokens from the shared RWX - /// filesystem store written by the API/worker tier (spec-018 P1.5). See - /// . Takes effect only when - /// is not set. - /// Config key: AgentHost:UseSharedTokenStore. - /// - public bool UseSharedTokenStore { get; init; } - - /// - /// Root path of the shared RWX auth directory, used with . - /// Passed to . - /// Config key: AgentHost:SharedTokenStorePath. - /// - public string? SharedTokenStorePath { get; init; } - - /// - /// When set, GitHub user tokens are read from CSI-mounted files at this path (Option B). - /// The CSI driver mounts per-user token files from Key Vault as - /// {KvTokenMountPath}/user_{userId}.json. - /// Config key: AgentHost:KvTokenMountPath. - /// When set, takes precedence over . - /// - public string? KvTokenMountPath { get; init; } - - /// Azure Key Vault URI for runtime token fetch (Option C warm-pool path). - /// When set, overrides KvTokenMountPath — token is fetched via workload identity at configure-time. - /// Config key: AgentHost:KeyVaultUri - /// - public string? KeyVaultUri { get; init; } - - /// Key Vault secret name for the run owner's GitHub token. - /// Passed in the /configure call. Format: ghtok-user--{base32(userId)}. - /// Config key: AgentHost:KvUserSecretName - /// - public string? KvUserSecretName { get; init; } } diff --git a/apps/Agentweaver.AgentHost/AgentHostRuntimeState.cs b/apps/Agentweaver.AgentHost/AgentHostRuntimeState.cs index 85703eedf..b7152bf08 100644 --- a/apps/Agentweaver.AgentHost/AgentHostRuntimeState.cs +++ b/apps/Agentweaver.AgentHost/AgentHostRuntimeState.cs @@ -12,8 +12,8 @@ namespace Agentweaver.AgentHost; /// /// Env-var launch (non-warm pod): seeds this from /// at startup via . -/// Warm pool: the pod starts in standby with no run context; the executor injects RunId / -/// UserId / TurnBearerToken / KvUserSecretName at run-launch time via . +/// Warm pool: the pod starts in standby with no run context; the executor injects run-bound +/// control data through . /// /// /// @@ -74,22 +74,15 @@ internal sealed class AgentHostRuntimeState public string PreviewRunnerCredential { get; private set; } = string.Empty; /// - /// Key Vault secret name for the run owner's GitHub token (Option C warm-pool path). - /// Supplied by the executor in the /configure call; consumed by - /// . Null on the file-mount/shared-store paths. - /// - public string? KvUserSecretName { get; private set; } - /// - /// Pre-resolved GitHub OAuth access token supplied by the API in the /configure body. - /// When set, uses this directly and skips the KV call, - /// allowing the pod to work without outbound access to Azure AD or Key Vault. + /// Bounded Copilot sign-in material delivered in memory to this trusted host only. It is never + /// inherited by the executor sidecar or preview children and is not a repository credential. /// - public string? GitHubAccessToken { get; private set; } + public string? CopilotAccessToken { get; private set; } /// /// The authenticated platform caller token forwarded only for operator-assistant MCP requests. - /// This is distinct from : in Entra deployments the former is the + /// This is distinct from : in Entra deployments the former is the /// Entra API access token while the latter is the linked GitHub token used by Copilot. /// public string? CallerBearerToken { get; private set; } @@ -105,8 +98,7 @@ public void InitializeFromOptions(AgentHostOptions options) UserId = options.UserId ?? string.Empty; TurnBearerToken = options.TurnBearerToken ?? string.Empty; PreviewRunnerCredential = string.Empty; // not available on env-var launch path - KvUserSecretName = options.KvUserSecretName; - GitHubAccessToken = null; // not available on env-var launch path + CopilotAccessToken = null; // credentials are never injected through the pod environment CallerBearerToken = null; // operator-assistant-only warm-pod input Purpose = AgentHostPurpose.Default; WorkspaceMode = ExecutionWorkspaceMode.Shared; @@ -127,13 +119,12 @@ public void InitializeFromOptions(AgentHostOptions options) /// Atomically transitions the pod from standby to configured. Returns /// when the pod was already configured (one-time semantics → caller returns 409). /// - 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? copilotAccessToken, string? previewRunnerCredential = null) => TryConfigure(new AgentHostRunConfiguration( runId, userId, turnBearerToken, - kvUserSecretName, - gitHubAccessToken, + copilotAccessToken, previewRunnerCredential, SharedWorkingDirectory: null)); @@ -147,12 +138,9 @@ public bool TryConfigure(AgentHostRunConfiguration configuration) UserId = configuration.UserId ?? string.Empty; TurnBearerToken = configuration.TurnBearerToken ?? string.Empty; PreviewRunnerCredential = configuration.PreviewRunnerCredential ?? string.Empty; - KvUserSecretName = string.IsNullOrWhiteSpace(configuration.KvUserSecretName) - ? null - : configuration.KvUserSecretName; - GitHubAccessToken = string.IsNullOrWhiteSpace(configuration.GitHubAccessToken) + CopilotAccessToken = string.IsNullOrWhiteSpace(configuration.CopilotAccessToken) ? null - : configuration.GitHubAccessToken; + : configuration.CopilotAccessToken; CallerBearerToken = string.IsNullOrWhiteSpace(configuration.CallerBearerToken) ? null : configuration.CallerBearerToken; @@ -193,8 +181,7 @@ internal sealed record AgentHostRunConfiguration( string RunId, string UserId, string TurnBearerToken, - string? KvUserSecretName, - string? GitHubAccessToken, + string? CopilotAccessToken, string? PreviewRunnerCredential, string? SharedWorkingDirectory, AgentHostPurpose Purpose = AgentHostPurpose.Default, diff --git a/apps/Agentweaver.AgentHost/AgentHostStartupService.cs b/apps/Agentweaver.AgentHost/AgentHostStartupService.cs index cbd490929..ec4853f1e 100644 --- a/apps/Agentweaver.AgentHost/AgentHostStartupService.cs +++ b/apps/Agentweaver.AgentHost/AgentHostStartupService.cs @@ -13,7 +13,7 @@ namespace Agentweaver.AgentHost; /// Env-var launch (non-warm pod): is set at /// startup, so runs SetupAsync immediately and the pod is ready /// when returns (legacy behaviour). -/// Warm pool (Option C): the pod starts with NO RunId and enters standby — +/// Warm pool: the pod starts with NO RunId and enters standby — /// SetupAsync is deferred until the executor calls from the /// POST /configure handler at run-launch time. The .NET process and Copilot SDK are /// already warm, so only the per-run setup runs on the request path. @@ -89,8 +89,7 @@ await RunSetupAsync( opts.RunId, opts.UserId ?? string.Empty, opts.TurnBearerToken ?? string.Empty, - opts.KvUserSecretName, - GitHubAccessToken: null, + CopilotAccessToken: null, PreviewRunnerCredential: null, SharedWorkingDirectory: null, ProjectId: opts.ProjectId, @@ -106,8 +105,7 @@ public async Task ConfigureAsync( string runId, string userId, string turnBearerToken, - string? kvUserSecretName, - string? gitHubAccessToken, + string? copilotAccessToken, string? workingDirectory, bool autoApproveTools, CancellationToken ct) @@ -116,8 +114,7 @@ public async Task ConfigureAsync( runId, userId, turnBearerToken, - kvUserSecretName, - gitHubAccessToken, + copilotAccessToken, PreviewRunnerCredential: null, SharedWorkingDirectory: workingDirectory), autoApproveTools, diff --git a/apps/Agentweaver.AgentHost/Agentweaver.AgentHost.csproj b/apps/Agentweaver.AgentHost/Agentweaver.AgentHost.csproj index 1c9cbe876..142f93472 100644 --- a/apps/Agentweaver.AgentHost/Agentweaver.AgentHost.csproj +++ b/apps/Agentweaver.AgentHost/Agentweaver.AgentHost.csproj @@ -23,14 +23,6 @@ - - - diff --git a/apps/Agentweaver.AgentHost/CsiMountedGitHubTokenStore.cs b/apps/Agentweaver.AgentHost/CsiMountedGitHubTokenStore.cs deleted file mode 100644 index 41bec3606..000000000 --- a/apps/Agentweaver.AgentHost/CsiMountedGitHubTokenStore.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Agentweaver.Domain; - -namespace Agentweaver.AgentHost; - -/// -/// that reads tokens from CSI-mounted Key Vault files (Option B). -/// The CSI secrets-store driver writes token files as {mountPath}/user_{userId}.json, -/// refreshing every 2 minutes from Key Vault. -/// -/// -/// Cold-start polling: on pod startup the CSI driver may not have written the file yet. -/// and retry up to -/// times with between attempts when the token is absent. -/// -/// -/// -/// Token refresh: each call re-reads from disk, so a CSI rotation is picked up automatically -/// on the next call without any in-memory staleness. -/// -/// -internal sealed class CsiMountedGitHubTokenStore : IGitHubTokenStore -{ - private readonly SharedHomeGitHubTokenStore _inner; - private readonly int _maxAttempts; - private readonly int _delayMs; - - internal const int DefaultMaxAttempts = 6; // 6 × 5s = 30s cold-start window - internal const int DefaultDelayMs = 5_000; - - public CsiMountedGitHubTokenStore(string mountPath, int maxAttempts = DefaultMaxAttempts, int delayMs = DefaultDelayMs) - { - _inner = new SharedHomeGitHubTokenStore(mountPath); - _maxAttempts = maxAttempts; - _delayMs = delayMs; - } - - public async Task GetAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - for (var attempt = 0; attempt < _maxAttempts; attempt++) - { - var entry = await _inner.GetAsync(scope, ct).ConfigureAwait(false); - if (entry.Status != GitHubTokenStatus.NeverSignedIn) - return entry; - if (attempt < _maxAttempts - 1) - await Task.Delay(_delayMs, ct).ConfigureAwait(false); - } - return new GitHubTokenEntry(GitHubTokenStatus.NeverSignedIn, null); - } - - public async Task GetTokenAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - for (var attempt = 0; attempt < _maxAttempts; attempt++) - { - var token = await _inner.GetTokenAsync(scope, ct).ConfigureAwait(false); - if (token is not null) - return token; - if (attempt < _maxAttempts - 1) - await Task.Delay(_delayMs, ct).ConfigureAwait(false); - } - return null; - } - - public Task GetIdentityAsync(GitHubTokenScope scope, CancellationToken ct = default) - => _inner.GetIdentityAsync(scope, ct); - - // Mutations are no-ops — pods are read-only consumers of the CSI-mounted token. - public Task SetAsync(GitHubTokenScope scope, GitHubToken token, CancellationToken ct = default) - => Task.CompletedTask; - - public Task SignOutAsync(GitHubTokenScope scope, CancellationToken ct = default) - => Task.CompletedTask; -} diff --git a/apps/Agentweaver.AgentHost/KeyVaultUserTokenProvider.cs b/apps/Agentweaver.AgentHost/KeyVaultUserTokenProvider.cs deleted file mode 100644 index 2785c6fa4..000000000 --- a/apps/Agentweaver.AgentHost/KeyVaultUserTokenProvider.cs +++ /dev/null @@ -1,195 +0,0 @@ -using System.Text.Json; -using Agentweaver.Domain; -using Azure; -using Azure.Security.KeyVault.Secrets; - -namespace Agentweaver.AgentHost; - -/// -/// Fetches the run owner's GitHub token from Azure Key Vault at configure-time using the pod's -/// workload identity (Option C, warm-pool path). The secret name is delivered via the -/// POST /configure call and stored on . -/// -/// -/// The secret value is the same JSON the API writes -/// (KeyVaultGitHubTokenStore / FileSystemGitHubTokenStore), so deserialization mirrors -/// . The token is cached in memory for the pod lifetime — a -/// run's token does not change mid-run, and the pod hosts exactly one run. -/// -/// -/// -/// Security: the pod fetches ONLY the single secret name it was configured with — it can never read -/// another user's token. The fetch fails closed (returns null) when no secret name is configured. -/// -/// -internal sealed class KeyVaultUserTokenProvider -{ - private readonly SecretClient _client; - private readonly AgentHostRuntimeState _runtimeState; - private readonly ILogger? _logger; - - private readonly SemaphoreSlim _gate = new(1, 1); - private StoredCredential? _cached; - private bool _fetched; - - public KeyVaultUserTokenProvider( - SecretClient client, - AgentHostRuntimeState runtimeState, - ILogger? logger = null) - { - _client = client; - _runtimeState = runtimeState; - _logger = logger; - } - - /// - /// Fetches (once, then cached) and returns the stored credential, or null when absent / not yet - /// configured / malformed. - /// - public async Task GetStoredCredentialAsync(CancellationToken ct = default) - { - if (_fetched) - return _cached; - - await _gate.WaitAsync(ct).ConfigureAwait(false); - try - { - if (_fetched) - return _cached; - - // Fast path: the API pre-resolved the token and passed it in /configure. - // Skip the KV call entirely — the pod has no outbound access to Azure AD or KV. - var preResolved = _runtimeState.GitHubAccessToken; - if (!string.IsNullOrWhiteSpace(preResolved)) - { - _logger?.LogInformation( - "KeyVaultUserTokenProvider: using pre-resolved GitHubAccessToken from /configure; skipping Key Vault fetch."); - _cached = new StoredCredential { Status = "signed-in", AccessToken = preResolved }; - _fetched = true; - return _cached; - } - - var secretName = _runtimeState.KvUserSecretName; - if (string.IsNullOrWhiteSpace(secretName)) - { - _logger?.LogWarning( - "KeyVaultUserTokenProvider: no KvUserSecretName configured — cannot fetch user token."); - _fetched = true; - _cached = null; - return null; - } - - try - { - var response = await _client.GetSecretAsync(secretName, cancellationToken: ct).ConfigureAwait(false); - _cached = JsonSerializer.Deserialize(response.Value.Value); - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - _logger?.LogWarning( - "KeyVaultUserTokenProvider: secret {Secret} not found in Key Vault.", secretName); - _cached = null; - } - catch (Exception ex) - { - _logger?.LogWarning(ex, - "KeyVaultUserTokenProvider: failed to fetch/parse secret {Secret}.", secretName); - _cached = null; - } - - _fetched = true; - return _cached; - } - finally - { - _gate.Release(); - } - } - - // Mirrors the on-disk / KV JSON shape written by the API token stores. - internal sealed record StoredCredential - { - public string? Status { get; init; } - public string? AccessToken { get; init; } - public string? RefreshToken { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } - public string? Login { get; init; } - public string? AvatarUrl { get; init; } - public string[]? Scopes { get; init; } - } -} - -/// -/// Read-only for the Option C warm-pool path: serves the single -/// run-owner token fetched from Key Vault by . The pod hosts -/// one run for one user, so all scopes resolve to that one token. Mutations are no-ops. -/// -internal sealed class KeyVaultGitHubTokenStore : IGitHubTokenStore -{ - private readonly KeyVaultUserTokenProvider _provider; - - public KeyVaultGitHubTokenStore(KeyVaultUserTokenProvider provider) => _provider = provider; - - public async Task GetAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - var stored = await _provider.GetStoredCredentialAsync(ct).ConfigureAwait(false); - if (stored is null) - return new GitHubTokenEntry(GitHubTokenStatus.NeverSignedIn, null); - if (stored.Status == "signed-out") - return new GitHubTokenEntry(GitHubTokenStatus.SignedOut, null); - if (!string.IsNullOrEmpty(stored.AccessToken)) - return new GitHubTokenEntry(GitHubTokenStatus.SignedIn, stored.AccessToken); - return new GitHubTokenEntry(GitHubTokenStatus.NeverSignedIn, null); - } - - public async Task GetTokenAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - var stored = await _provider.GetStoredCredentialAsync(ct).ConfigureAwait(false); - if (stored?.Status == "signed-in" && !string.IsNullOrEmpty(stored.AccessToken)) - return new GitHubToken( - stored.AccessToken, - stored.RefreshToken, - stored.ExpiresAt, - stored.Login ?? "unknown", - stored.AvatarUrl, - stored.Scopes ?? []); - return null; - } - - public async Task GetIdentityAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - var stored = await _provider.GetStoredCredentialAsync(ct).ConfigureAwait(false); - return stored?.Login is not null ? new GitHubIdentity(stored.Login, stored.AvatarUrl) : null; - } - - // Pod is a read-only consumer — never mutate the user's credentials. - public Task SetAsync(GitHubTokenScope scope, GitHubToken token, CancellationToken ct = default) - => Task.CompletedTask; - - public Task SignOutAsync(GitHubTokenScope scope, CancellationToken ct = default) - => Task.CompletedTask; -} - -/// -/// Token-scope provider for the warm-pool path: resolves the per-user scope from the runtime state -/// populated by POST /configure or the runtime-supplied user id. Fails closed when absent. -/// -internal sealed class RuntimeUserScopeProvider : IGitHubTokenScopeProvider -{ - private readonly AgentHostRuntimeState _runtimeState; - - public RuntimeUserScopeProvider(AgentHostRuntimeState runtimeState) => _runtimeState = runtimeState; - - public GitHubTokenScope Resolve(string? userId) - { - var effective = !string.IsNullOrWhiteSpace(_runtimeState.UserId) - ? _runtimeState.UserId - : (string.IsNullOrWhiteSpace(userId) ? null : userId); - if (effective is null) - throw new InvalidOperationException( - "AgentHost cannot resolve a Copilot token scope without the submitting user identity; " + - "installation-scope Copilot auth is not permitted."); - - return GitHubTokenScope.ForUser(effective); - } -} diff --git a/apps/Agentweaver.AgentHost/OperatorPodTurnRunner.cs b/apps/Agentweaver.AgentHost/OperatorPodTurnRunner.cs index 582dce19e..a106b59f5 100644 --- a/apps/Agentweaver.AgentHost/OperatorPodTurnRunner.cs +++ b/apps/Agentweaver.AgentHost/OperatorPodTurnRunner.cs @@ -84,11 +84,7 @@ public async Task RunTurnAsync(string task, bool isRevision, Cancellatio RunId: envelope.ContextRunId, ModelId: null, AgentDefinition: envelope.AgentDefinition, - // New API versions provide the platform caller credential separately from the linked - // GitHub token. Fall back during rolling upgrades from an older API. - CallerBearerToken: _runtimeState.CallerBearerToken - ?? _runtimeState.GitHubAccessToken - ?? string.Empty, + CallerBearerToken: _runtimeState.CallerBearerToken ?? string.Empty, History: envelope.History); // Fail closed rather than silently degrading to an ungated turn: OperatorAssistantAgent only diff --git a/apps/Agentweaver.AgentHost/PodGitHubTokenStore.cs b/apps/Agentweaver.AgentHost/PodGitHubTokenStore.cs deleted file mode 100644 index bac223d41..000000000 --- a/apps/Agentweaver.AgentHost/PodGitHubTokenStore.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Concurrent; -using Agentweaver.Domain; - -namespace Agentweaver.AgentHost; - -/// -/// In-pod GitHub token store that serves a single pre-configured installation token. -/// The token is sourced from Providers:GitHubCopilot:GitHubToken config or the -/// GITHUB_TOKEN environment variable and written into this store at startup. -/// -internal sealed class PodGitHubTokenStore : IGitHubTokenStore -{ - private readonly ConcurrentDictionary - _map = new(StringComparer.Ordinal); - - public void Seed(GitHubTokenScope scope, string accessToken) => - _map[scope.Key] = (GitHubTokenStatus.SignedIn, new GitHubToken(accessToken, null, null, "", null, Array.Empty())); - - public Task GetAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - if (_map.TryGetValue(scope.Key, out var e)) - return Task.FromResult(new GitHubTokenEntry(e.status, e.token?.AccessToken)); - return Task.FromResult(new GitHubTokenEntry(GitHubTokenStatus.NeverSignedIn, null)); - } - - public Task GetTokenAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - if (_map.TryGetValue(scope.Key, out var e) && e.status == GitHubTokenStatus.SignedIn) - return Task.FromResult(e.token); - return Task.FromResult(null); - } - - public Task SetAsync(GitHubTokenScope scope, GitHubToken token, CancellationToken ct = default) - { - _map[scope.Key] = (GitHubTokenStatus.SignedIn, token); - return Task.CompletedTask; - } - - public Task GetIdentityAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - if (_map.TryGetValue(scope.Key, out var e) && e.token is not null) - return Task.FromResult(new GitHubIdentity(e.token.Login, e.token.AvatarUrl)); - return Task.FromResult(null); - } - - public Task SignOutAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - _map[scope.Key] = (GitHubTokenStatus.SignedOut, null); - return Task.CompletedTask; - } -} diff --git a/apps/Agentweaver.AgentHost/PodInstallationScopeProvider.cs b/apps/Agentweaver.AgentHost/PodInstallationScopeProvider.cs deleted file mode 100644 index bb9a32a09..000000000 --- a/apps/Agentweaver.AgentHost/PodInstallationScopeProvider.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Agentweaver.Domain; - -namespace Agentweaver.AgentHost; - -/// -/// Legacy fallback scope provider. AgentHost executes Copilot model turns, so installation -/// tokens must never be used as model credentials. -/// -internal sealed class PodInstallationScopeProvider : IGitHubTokenScopeProvider -{ - public GitHubTokenScope Resolve(string? userId) => - throw new InvalidOperationException( - "AgentHost is configured with an installation-token scope provider, but Copilot model " + - "turns require a submitting user token. Configure Key Vault or shared user-token storage."); -} diff --git a/apps/Agentweaver.AgentHost/PreviewRunner.cs b/apps/Agentweaver.AgentHost/PreviewRunner.cs index 4a281f057..7f3b87cb0 100644 --- a/apps/Agentweaver.AgentHost/PreviewRunner.cs +++ b/apps/Agentweaver.AgentHost/PreviewRunner.cs @@ -737,7 +737,7 @@ private void ScrubChildEnvironment(ProcessStartInfo psi) { _runtimeState?.TurnBearerToken, _runtimeState?.PreviewRunnerCredential, - _runtimeState?.GitHubAccessToken, + _runtimeState?.CopilotAccessToken, _runtimeState?.CallerBearerToken, } .Where(s => !string.IsNullOrEmpty(s)) diff --git a/apps/Agentweaver.AgentHost/Program.cs b/apps/Agentweaver.AgentHost/Program.cs index b67c22a85..ba35cdd39 100644 --- a/apps/Agentweaver.AgentHost/Program.cs +++ b/apps/Agentweaver.AgentHost/Program.cs @@ -5,8 +5,6 @@ using Agentweaver.Domain; using Agentweaver.SandboxExec; using Agentweaver.SandboxExec.PodExec; -using Azure.Identity; -using Azure.Security.KeyVault.Secrets; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.A2A; using Microsoft.AspNetCore.Builder; @@ -37,7 +35,7 @@ // ── Bootstrap ────────────────────────────────────────────────────────────────── var builder = WebApplication.CreateBuilder(args); -// Load AgentHost options (per-run config injected as env vars / config at pod launch). +// Load static AgentHost options. Per-run data is accepted only through one-time /configure. builder.Services.Configure(builder.Configuration.GetSection("AgentHost")); builder.Services.AddSingleton(); @@ -55,78 +53,11 @@ builder.WebHost.ConfigureKestrel(kestrel => AgentHostKestrelConfigurator.Configure(kestrel, builder.Configuration)); -// ── GitHub credential chain ──────────────────────────────────────────────────── -// Three paths, selected in priority order: -// -// (A) CSI-mounted Key Vault token files (Option B, KvTokenMountPath set): -// A per-run SecretProviderClass mounts only the run owner's token file from Key Vault at -// /mnt/user-tokens/user_{userId}.json — same StoredCredential JSON as the shared store. -// CsiMountedGitHubTokenStore adds cold-start retry (6×5s) in case the CSI driver hasn't -// written the file yet at pod startup. Takes precedence over UseSharedTokenStore. -// -// (B) Shared file store (spec-018 P1.5 live PoC): the cluster mounts the agentweaver-workspace -// RWX volume at /workspace with HOME=/workspace/.home, and the API persists the user's GitHub -// token to {HOME}/.local/share/agentweaver/auth/user_.json. When UseSharedTokenStore=true -// the pod READS that same shared store directly — the token never moves, no secret is created. -// Pairs with a per-user scope provider so the correct user_.json is read. -// -// (C) Default: PodGitHubTokenStore (NeverSignedIn) + installation scope. The factory then falls -// back to Providers:GitHubCopilot:GitHubToken from config (e.g. an injected env/secret). -// -// No IGitHubAccessTokenProvider is wired (token is static at pod lifetime; the shared store already -// holds a freshly-issued user token). -var kvUri = builder.Configuration["AgentHost:KeyVaultUri"]; -var kvMountPath = builder.Configuration["AgentHost:KvTokenMountPath"]; -// Guard: reject empty, whitespace, or unsubstituted envsubst placeholders (e.g. "${AGENTHOST_KEYVAULT_URI}") -Uri? kvUriParsed = null; -var kvUriValid = !string.IsNullOrWhiteSpace(kvUri) - && Uri.TryCreate(kvUri, UriKind.Absolute, out kvUriParsed) - && (kvUriParsed.Scheme == "https" || kvUriParsed.Scheme == "http"); -if (kvUriValid) -{ - // Option C (warm pool): fetch the run owner's token from Key Vault at /configure-time via the - // pod's workload identity (DefaultAzureCredential). No CSI volume, no per-run SPC — the secret - // name (ghtok-user--{base32(userId)}) arrives in the /configure call and lands on - // AgentHostRuntimeState.KvUserSecretName. KeyVaultUserTokenProvider fetches ONLY that one secret - // and caches it for the pod lifetime. Takes precedence over the file-mount paths. - builder.Services.AddSingleton(new SecretClient(kvUriParsed!, new DefaultAzureCredential())); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => - new KeyVaultGitHubTokenStore(sp.GetRequiredService())); - builder.Services.AddSingleton(sp => - new RuntimeUserScopeProvider(sp.GetRequiredService())); -} -else if (!string.IsNullOrWhiteSpace(kvMountPath)) -{ - // Option A: CSI-mounted Key Vault token files. - // File per user: {kvMountPath}/user_{sanitizedUserId}.json — same StoredCredential JSON. - var configuredUserId = builder.Configuration["AgentHost:UserId"]; - builder.Services.AddSingleton( - new CsiMountedGitHubTokenStore(kvMountPath)); - builder.Services.AddSingleton(sp => - new SharedUserScopeProvider( - kvMountPath, - configuredUserId, - sp.GetRequiredService>())); -} -else if (builder.Configuration.GetValue("AgentHost:UseSharedTokenStore", false)) -{ - var authDir = SharedTokenStorePaths.ResolveAuthDir( - builder.Configuration["AgentHost:SharedTokenStorePath"]); - var configuredUserId = builder.Configuration["AgentHost:UserId"]; - builder.Services.AddSingleton(new SharedHomeGitHubTokenStore(authDir)); - builder.Services.AddSingleton(sp => - new SharedUserScopeProvider( - authDir, - configuredUserId, - sp.GetRequiredService>())); -} -else -{ - var podTokenStore = new PodGitHubTokenStore(); - builder.Services.AddSingleton(podTokenStore); - builder.Services.AddSingleton(); -} +// The host receives only an inference-only Copilot credential over its one-time trusted control +// channel. Repository credentials, token stores, token files, Key Vault locators, and ambient +// scope resolution are intentionally absent from the AgentHost and executor sidecar. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // ── Sandbox policy (no DB in pod) ───────────────────────────────────────────── builder.Services.AddSingleton(); @@ -262,8 +193,8 @@ await next(ctx).ConfigureAwait(false); }); -// ── Warm-pool one-time /configure endpoint (Option C) ─────────────────────────── -// Injects the per-run RunId/UserId/TurnBearerToken (and the KV secret name) into an already-warm +// ── Warm-pool one-time /configure endpoint ────────────────────────────────────── +// Injects the per-run RunId/UserId/TurnBearerToken (and bounded Copilot credential) into an already-warm // pod, then runs the deferred SetupAsync. Placed BEFORE the A2A bearer-auth middleware: it cannot be // protected by the TurnBearerToken (chicken-and-egg — the token is delivered HERE). NetworkPolicy // (ingress to AgentHost pods restricted to API/worker) is the guard. One-time: a second call (or a @@ -329,7 +260,7 @@ await startup.ConfigureAsync(configuration, body.AutoApproveTools, ctx.RequestAb var logger = ctx.RequestServices.GetRequiredService>(); logger.LogWarning( ex, - "AgentHost /configure: GitHub Copilot rejected the run credential for run {RunId}; the API may refresh and recreate this pod once.", + "AgentHost /configure: GitHub Copilot rejected the run-bound inference credential for run {RunId}.", configuration.RunId); return Results.Json( new @@ -573,7 +504,6 @@ internal sealed record ConfigureRequest public string? RunId { get; init; } public string? UserId { get; init; } public string? TurnBearerToken { get; init; } - public string? KvUserSecretName { get; init; } /// /// Per-run preview-runner credential (spec-006 decouple-preview, BLOCKER A). Delivered in-memory @@ -583,15 +513,15 @@ internal sealed record ConfigureRequest public string? PreviewRunnerCredential { get; init; } /// - /// GitHub OAuth access token pre-resolved by the API (which has KV access). - /// When present, the pod skips the Key Vault fetch entirely — no OIDC or KV egress needed. + /// Bounded Copilot inference credential selected by trusted API-side snapshot fencing. + /// It is held only in the trusted AgentHost process and is never copied into the sandbox. /// - public string? GitHubAccessToken { get; init; } + public string? CopilotAccessToken { get; init; } /// /// Authenticated platform caller token used by the operator assistant's MCP connection. Kept - /// separate from because Entra deployments use different - /// credentials for Agentweaver API authorization and the linked GitHub/Copilot account. + /// separate from the Copilot credential because Entra deployments use different + /// authorization channels for Agentweaver API and model inference. /// public string? CallerBearerToken { get; init; } @@ -664,8 +594,7 @@ internal sealed record ConfigureRequest RunId ?? string.Empty, UserId ?? string.Empty, TurnBearerToken ?? string.Empty, - KvUserSecretName, - GitHubAccessToken, + CopilotAccessToken, PreviewRunnerCredential, SharedWorkingDirectory ?? WorkingDirectory, Purpose, diff --git a/apps/Agentweaver.AgentHost/RunBoundCopilotCredentialProvider.cs b/apps/Agentweaver.AgentHost/RunBoundCopilotCredentialProvider.cs new file mode 100644 index 000000000..e6128c428 --- /dev/null +++ b/apps/Agentweaver.AgentHost/RunBoundCopilotCredentialProvider.cs @@ -0,0 +1,20 @@ +using Agentweaver.AgentRuntime.Providers; + +namespace Agentweaver.AgentHost; + +/// +/// Supplies only the run-bound Copilot inference credential held in the trusted AgentHost process. +/// It deliberately has no repository scope, locator, file, environment, or secret-store fallback. +/// +internal sealed class RunBoundCopilotCredentialProvider(AgentHostRuntimeState runtimeState) + : ICopilotCredentialProvider +{ + public Task GetAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult( + string.IsNullOrWhiteSpace(runtimeState.CopilotAccessToken) + ? null + : new CopilotCredential(runtimeState.CopilotAccessToken, ExpiresAt: null)); + } +} diff --git a/apps/Agentweaver.AgentHost/RunBoundCopilotScopeProvider.cs b/apps/Agentweaver.AgentHost/RunBoundCopilotScopeProvider.cs new file mode 100644 index 000000000..2d7947c32 --- /dev/null +++ b/apps/Agentweaver.AgentHost/RunBoundCopilotScopeProvider.cs @@ -0,0 +1,20 @@ +using Agentweaver.Domain; + +namespace Agentweaver.AgentHost; + +/// +/// Provides the host's already-bound user identity for legacy runtime call sites. The credential +/// factory ignores this scope when its run-bound Copilot provider is present, so model input cannot +/// select a GitHub credential scope. +/// +internal sealed class RunBoundCopilotScopeProvider(AgentHostRuntimeState runtimeState) + : IGitHubTokenScopeProvider +{ + public GitHubTokenScope Resolve(string? userId) + { + if (string.IsNullOrWhiteSpace(runtimeState.UserId)) + throw new InvalidOperationException("AgentHost has no configured run-bound Copilot identity."); + + return GitHubTokenScope.ForUser(runtimeState.UserId); + } +} diff --git a/apps/Agentweaver.AgentHost/SharedHomeGitHubTokenStore.cs b/apps/Agentweaver.AgentHost/SharedHomeGitHubTokenStore.cs deleted file mode 100644 index 4c8a0aa53..000000000 --- a/apps/Agentweaver.AgentHost/SharedHomeGitHubTokenStore.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Text.Json; -using Agentweaver.Domain; - -namespace Agentweaver.AgentHost; - -/// -/// Read-only that serves tokens from the shared, RWX file-based -/// store written by the API/worker tier (FileSystemGitHubTokenStore). See -/// for the path/format contract. -/// -/// -/// spec-018 P1.5: this is how the agent-host pod obtains the run's GitHub Copilot token without any -/// secret injection — it reads the same user_<id>.json the API persisted on the shared -/// agentweaver-workspace volume. Mutating operations (, -/// ) are intentionally no-ops: the pod must never clobber the user's -/// shared credentials. -/// -/// -internal sealed class SharedHomeGitHubTokenStore : IGitHubTokenStore -{ - private readonly string _authDir; - - public SharedHomeGitHubTokenStore(string authDir) => _authDir = authDir; - - public Task GetAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - var stored = Read(scope); - if (stored is null) - return Task.FromResult(new GitHubTokenEntry(GitHubTokenStatus.NeverSignedIn, null)); - if (stored.Status == "signed-out") - return Task.FromResult(new GitHubTokenEntry(GitHubTokenStatus.SignedOut, null)); - if (!string.IsNullOrEmpty(stored.AccessToken)) - return Task.FromResult(new GitHubTokenEntry(GitHubTokenStatus.SignedIn, stored.AccessToken)); - return Task.FromResult(new GitHubTokenEntry(GitHubTokenStatus.NeverSignedIn, null)); - } - - public Task GetTokenAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - var stored = Read(scope); - if (stored?.Status == "signed-in" && !string.IsNullOrEmpty(stored.AccessToken)) - return Task.FromResult(new GitHubToken( - stored.AccessToken, - stored.RefreshToken, - stored.ExpiresAt, - stored.Login ?? "unknown", - stored.AvatarUrl, - stored.Scopes ?? [])); - return Task.FromResult(null); - } - - public Task GetIdentityAsync(GitHubTokenScope scope, CancellationToken ct = default) - { - var stored = Read(scope); - if (stored?.Login is not null) - return Task.FromResult(new GitHubIdentity(stored.Login, stored.AvatarUrl)); - return Task.FromResult(null); - } - - // Pod is a read-only consumer of the shared store — never mutate the user's credentials. - public Task SetAsync(GitHubTokenScope scope, GitHubToken token, CancellationToken ct = default) - => Task.CompletedTask; - - public Task SignOutAsync(GitHubTokenScope scope, CancellationToken ct = default) - => Task.CompletedTask; - - private StoredCredential? Read(GitHubTokenScope scope) - { - var path = SharedTokenStorePaths.FilePath(_authDir, scope); - if (!File.Exists(path)) - return null; - try - { - return JsonSerializer.Deserialize(File.ReadAllText(path)); - } - catch (Exception) - { - return null; // malformed — treat as absent - } - } - - // Mirrors the on-disk shape written by FileSystemGitHubTokenStore (PascalCase, default policy). - internal sealed record StoredCredential - { - public string? Status { get; init; } - public string? AccessToken { get; init; } - public string? RefreshToken { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } - public string? Login { get; init; } - public string? AvatarUrl { get; init; } - public string[]? Scopes { get; init; } - } -} diff --git a/apps/Agentweaver.AgentHost/SharedTokenStorePaths.cs b/apps/Agentweaver.AgentHost/SharedTokenStorePaths.cs deleted file mode 100644 index 2a0e6bb3c..000000000 --- a/apps/Agentweaver.AgentHost/SharedTokenStorePaths.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Agentweaver.Domain; - -namespace Agentweaver.AgentHost; - -/// -/// Shared-path resolution for the file-based GitHub token store used by the API/worker tier. -/// -/// -/// spec-018 P1.5: the live cluster mounts the agentweaver-workspace RWX Azure Files share -/// at /workspace on api + worker + (now) the agent-host pod, with HOME=/workspace/.home. -/// The API persists GitHub tokens via FileSystemGitHubTokenStore to -/// {LocalApplicationData}/agentweaver/auth/{scope-key}.json — i.e. -/// /workspace/.home/.local/share/agentweaver/auth/user_<id>.json. Because the pod mounts -/// the SAME share with the SAME HOME, it reads the very same files — the token never moves and no -/// secret is created. -/// -/// -/// -/// Directory + filename derivation mirror Agentweaver.Api.Infrastructure.AppPaths and -/// FileSystemGitHubTokenStore.FilePath exactly so the pod reads what the API wrote. -/// -/// -internal static class SharedTokenStorePaths -{ - /// - /// Resolves the auth directory ({DataDirectory}/auth). Honors an explicit override - /// (config AgentHost:SharedTokenStorePath); otherwise mirrors AppPaths: - /// {LocalApplicationData}/agentweaver/auth, which is HOME-relative on Linux. - /// - public static string ResolveAuthDir(string? overridePath) - { - if (!string.IsNullOrWhiteSpace(overridePath)) - return overridePath!; - - var baseDir = Environment.GetFolderPath( - Environment.SpecialFolder.LocalApplicationData, - Environment.SpecialFolderOption.Create); - if (string.IsNullOrEmpty(baseDir)) - baseDir = AppContext.BaseDirectory; - - return Path.Combine(baseDir, "agentweaver", "auth"); - } - - /// - /// Sanitizes a scope key to a filename exactly as FileSystemGitHubTokenStore does - /// (letters/digits/'-'/'_' kept, everything else -> '_'); e.g. user:sabbour -> - /// user_sabbour. - /// - public static string SanitizeKey(GitHubTokenScope scope) => - string.Concat(scope.Key.Select(c => - char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '_')); - - public static string FilePath(string authDir, GitHubTokenScope scope) => - Path.Combine(authDir, $"{SanitizeKey(scope)}.json"); -} diff --git a/apps/Agentweaver.AgentHost/SharedUserScopeProvider.cs b/apps/Agentweaver.AgentHost/SharedUserScopeProvider.cs deleted file mode 100644 index deea029eb..000000000 --- a/apps/Agentweaver.AgentHost/SharedUserScopeProvider.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Agentweaver.Domain; -using Microsoft.Extensions.Logging; - -namespace Agentweaver.AgentHost; - -/// -/// Token-scope provider for the shared-store path (spec-018 P1.5). Resolves the -/// per-user scope that matches what the API persisted, so -/// reads the correct user_<id>.json. -/// -/// Resolution order: -/// -/// An explicitly configured user id (AgentHost:UserId / the run's submitting user), -/// if present, -> . -/// Fail closed if no user id is configured; installation tokens cannot authorize Copilot model turns. -/// -/// -internal sealed class SharedUserScopeProvider : IGitHubTokenScopeProvider -{ - private readonly string? _configuredUserId; - private readonly ILogger? _logger; - - public SharedUserScopeProvider( - string authDir, - string? configuredUserId, - ILogger? logger = null) - { - _configuredUserId = string.IsNullOrWhiteSpace(configuredUserId) ? null : configuredUserId; - _logger = logger; - } - - public GitHubTokenScope Resolve(string? userId) - { - var effective = _configuredUserId ?? (string.IsNullOrWhiteSpace(userId) ? null : userId); - if (effective is not null) - return GitHubTokenScope.ForUser(effective); - - _logger?.LogError("AgentHost userId not configured — refusing installation-scope Copilot auth"); - throw new InvalidOperationException( - "AgentHost cannot resolve a Copilot token scope without the submitting user identity; " + - "installation-scope Copilot auth is not permitted."); - } -} diff --git a/apps/Agentweaver.AgentHost/packages.lock.json b/apps/Agentweaver.AgentHost/packages.lock.json index 208f0cce9..419fdf2b7 100644 --- a/apps/Agentweaver.AgentHost/packages.lock.json +++ b/apps/Agentweaver.AgentHost/packages.lock.json @@ -2,15 +2,6 @@ "version": 1, "dependencies": { "net10.0": { - "Azure.Identity": { - "type": "Direct", - "requested": "[1.21.0, )", - "resolved": "1.21.0", - "contentHash": "GeFv8sGwRKvDKwI2WFy8r0mhmlxEVZg24Sit2NogTjiSO8RVjllWM65OT6e1sKjOvG8V74y7hAbaELUUPjZQSw==", - "dependencies": { - "Azure.Core": "1.53.0" - } - }, "Azure.Monitor.OpenTelemetry.Exporter": { "type": "Direct", "requested": "[1.8.3, )", @@ -22,15 +13,6 @@ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.3" } }, - "Azure.Security.KeyVault.Secrets": { - "type": "Direct", - "requested": "[4.11.0, )", - "resolved": "4.11.0", - "contentHash": "tjpVczILiXTR9bEynSL1JBU80169hGnTECtGsFjnRz9E1xoIhfUsaUTnB79k995zDkOG61hOI+YBtf5bNqgRIQ==", - "dependencies": { - "Azure.Core": "1.54.0" - } - }, "Microsoft.Agents.AI.Hosting.A2A": { "type": "Direct", "requested": "[1.11.1-preview.260625.1, )", diff --git a/apps/Agentweaver.Api/Auth/GitHubCapabilityBroker.cs b/apps/Agentweaver.Api/Auth/GitHubCapabilityBroker.cs index 822374a65..40d2cfbf4 100644 --- a/apps/Agentweaver.Api/Auth/GitHubCapabilityBroker.cs +++ b/apps/Agentweaver.Api/Auth/GitHubCapabilityBroker.cs @@ -13,6 +13,12 @@ internal sealed record GitHubCapabilityGrant( GitHubCapabilityOperation Operation, DateTimeOffset ExpiresAt); +/// +/// Ephemeral inference credential issued only to the trusted AgentHost launch control path. +/// It is deliberately internal, non-serializable, and never represents repository authority. +/// +public sealed record RunBoundCopilotCredential(string AccessToken, DateTimeOffset ExpiresAt); + /// /// Internal run-bound broker boundary. It accepts only a purpose and opaque snapshot reference, /// never a user, project, repository, grant, or ambient scope. @@ -85,6 +91,38 @@ internal sealed class GitHubCapabilityBroker( : (GitHubCapabilityBrokerOutcome.Issued, new(fenced.Purpose, operation, expiresAt)); } + /// + /// Acquires an inference credential only after the immutable Copilot snapshot has been fenced. + /// The caller is the internal AgentHost launch path; it receives no repository metadata, + /// locator, or selectable purpose. + /// + internal async Task TryAcquireCopilotCredentialAsync( + GitHubCapabilityPurpose purpose, + SnapshotRef snapshotRef, + DateTimeOffset now, + CancellationToken ct) + { + if (!IsOperationAllowed(purpose, GitHubCapabilityOperation.CopilotInference)) + return null; + + var fenced = await persistence.TryFenceLiveSnapshotAsync(purpose, snapshotRef, now, ct) + .ConfigureAwait(false); + if (fenced is null) + return null; + + var secret = await vault.ReadCurrentAsync(fenced.CredentialLocator!, ct).ConfigureAwait(false); + if (!secret.Found || !TryReadAccessToken(secret.Value, out var accessToken, out var providerExpiresAt)) + return null; + + if (await persistence.TryFenceLiveSnapshotAsync(purpose, snapshotRef, now, ct).ConfigureAwait(false) is null) + return null; + + var expiresAt = providerExpiresAt is not null && providerExpiresAt < now.Add(MaximumCapabilityLifetime) + ? providerExpiresAt.Value + : now.Add(MaximumCapabilityLifetime); + return expiresAt <= now ? null : new RunBoundCopilotCredential(accessToken!, expiresAt); + } + internal static bool IsOperationAllowed( GitHubCapabilityPurpose purpose, GitHubCapabilityOperation operation) => @@ -98,7 +136,14 @@ GitHubCapabilityPurpose.InteractiveCopilot or GitHubCapabilityPurpose.Unattended }; private static bool HasUsableAccessToken(string? value, out DateTimeOffset? expiresAt) + => TryReadAccessToken(value, out _, out expiresAt); + + private static bool TryReadAccessToken( + string? value, + out string? accessToken, + out DateTimeOffset? expiresAt) { + accessToken = null; expiresAt = null; if (string.IsNullOrWhiteSpace(value)) return false; @@ -110,6 +155,7 @@ private static bool HasUsableAccessToken(string? value, out DateTimeOffset? expi !document.RootElement.TryGetProperty("accessToken", out var token) || string.IsNullOrWhiteSpace(token.GetString())) return false; + accessToken = token.GetString(); if (document.RootElement.TryGetProperty("expiresAt", out var expiry) && expiry.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(expiry.GetString(), out var parsed)) diff --git a/apps/Agentweaver.Api/Program.cs b/apps/Agentweaver.Api/Program.cs index e855edb5d..f0b7c6fbc 100644 --- a/apps/Agentweaver.Api/Program.cs +++ b/apps/Agentweaver.Api/Program.cs @@ -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(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new RunAgentHostContextResolver( sp.GetRequiredService(), diff --git a/apps/Agentweaver.Api/Sandbox/KubernetesSandboxExecutor.cs b/apps/Agentweaver.Api/Sandbox/KubernetesSandboxExecutor.cs index 48e092c00..99e0cfe31 100644 --- a/apps/Agentweaver.Api/Sandbox/KubernetesSandboxExecutor.cs +++ b/apps/Agentweaver.Api/Sandbox/KubernetesSandboxExecutor.cs @@ -96,13 +96,6 @@ public sealed class KubernetesSandboxOptions /// public int AgentHostClaimCreationGraceSeconds { get; init; } = 300; - /// - /// Azure Key Vault URI injected into AgentHost pods as AgentHost__KeyVaultUri so the - /// warm pod can fetch the run owner's GitHub token via workload identity at /configure-time - /// (Option C). Sourced from the API's own KV config (Auth:TokenStore:KeyVaultUri). When - /// null/empty the env var is omitted and the pod falls back to the CSI file-mount path. - /// - public string? KvUri { get; init; } } /// @@ -188,22 +181,9 @@ internal sealed class KubernetesSandboxExecutor : ISandboxExecutor, IAgentHostPo // Used to POST /configure to the warm pod after bind (warm-pool deferred-config path). Null in // unit tests → the /configure call is skipped (same null-skip convention as the readiness probe). private readonly IHttpClientFactory? _httpClientFactory; - // Resolves the run owner's GitHub token so the API can pass it in /configure, avoiding the need - // for the kata VM pod to call Azure AD or Key Vault (blocked by Cilium FQDN policies). - private readonly IGitHubTokenStore? _tokenStore; - private readonly IGitHubTokenScopeProvider? _tokenScopeProvider; - // Refresh-aware token accessor (issue #523): a Build & Test gate can launch its AgentHost pod for - // the FIRST time (a fresh pod, not yet /configure'd for this run) many minutes after the run's - // earlier subtask stages — long enough for the submitting user's Copilot-entitled OAuth access - // token to cross its expiry skew window. Reading the raw entry via IGitHubTokenStore.GetAsync (as - // ResolveGitHubAccessTokenAsync previously did) can hand a stale/expired access token to the pod, - // which the pod then trusts unconditionally (its "fast path" skips its own Key Vault fetch - // whenever a pre-resolved token arrives) — producing GitHubCopilotUnauthorizedException at - // /configure. Routing through the same GetValidAccessTokenAsync used by GitHubCopilotClientFactory - // ensures a near-expiry token is transparently rotated before being handed to a newly-launched pod. - // Null in unit tests → falls back to the raw (non-refreshing) token store read. When present, - // it is authoritative: a null/failed refresh must never fall back to the rejected raw token. - private readonly IGitHubAccessTokenProvider? _accessTokenProvider; + // Trusted API-side broker adapter. It derives the snapshot and purpose from the persisted run; + // AgentHost receives only bounded Copilot inference material over its one-time control channel. + private readonly IRunBoundCopilotCredentialIssuer? _copilotCredentialIssuer; // Replica-safe run secret store used to persist the per-run preview-runner credential so a // reconcile/keepalive on either API replica can re-fetch it, and to durably DELETE it on pod // release (spec-006 decouple-preview, BLOCKER A / RESIDUAL). Null in unit tests → no minting. @@ -237,13 +217,11 @@ internal KubernetesSandboxExecutor( IAgentHostReadinessProbe? readinessProbe = null, IRunSubmittingUserResolver? submittingUserResolver = null, IHttpClientFactory? httpClientFactory = null, - IGitHubTokenStore? tokenStore = null, ISecretStore? secretStore = null, IRunEventStream? runEventStream = null, IRunOptionsStore? runOptions = null, - IGitHubAccessTokenProvider? accessTokenProvider = null, Agentweaver.Api.Sandbox.Preview.ISandboxPreviewService? previewService = null, - IGitHubTokenScopeProvider? tokenScopeProvider = null, + IRunBoundCopilotCredentialIssuer? copilotCredentialIssuer = null, Security.IRunAuthorshipCapabilityStore? authorshipCapabilityStore = null) { _client = client; @@ -254,12 +232,10 @@ internal KubernetesSandboxExecutor( _readinessProbe = readinessProbe; _submittingUserResolver = submittingUserResolver; _httpClientFactory = httpClientFactory; - _tokenStore = tokenStore; - _tokenScopeProvider = tokenScopeProvider; _secretStore = secretStore; _runEventStream = runEventStream; _runOptions = runOptions; - _accessTokenProvider = accessTokenProvider; + _copilotCredentialIssuer = copilotCredentialIssuer; _previewService = previewService; _authorshipCapabilityStore = authorshipCapabilityStore; } @@ -386,15 +362,13 @@ public async Task LaunchAgentHostPodAsync( "KubernetesSandboxExecutor: launching AgentHost pod for run {RunId} via claim {Claim}", runId, claimName); - // Resolve the run's submitting user so the pod can scope GitHub Copilot auth to that user's - // signed-in token. The user's Key Vault secret name (Option C warm-pool path) is derived here - // and delivered to the pod via /configure — never another user's secret. + // Resolve the server-owned run identity before minting the bounded Copilot credential. var submittingUser = await ResolveSubmittingUserAsync(runId, ct).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(submittingUser)) { throw new InvalidOperationException( $"Cannot launch AgentHost pod for run '{runId}' without a submitting user; " + - "the /configure call must scope the pod to the run owner's Key Vault token."); + "the trusted control plane cannot select a run-bound Copilot credential."); } _logger.LogInformation( @@ -404,19 +378,6 @@ public async Task LaunchAgentHostPodAsync( var (configProjectId, configAgentName) = _submittingUserResolver is not null ? await _submittingUserResolver.GetRunIdentityAsync(runId, ct).ConfigureAwait(false) : (null, null); - - // ghtok-user--{base32(userId)} — the SAME mapping the API uses when persisting the token to KV. - // With Entra sign-in the user's credentials live under the ACTIVE linked GitHub identity's - // scope (user-link:{oid}:{login}), so resolve the effective scope rather than assuming the - // legacy per-user scope, which is never written in that mode. - var effectiveScope = _tokenScopeProvider is not null - ? await _tokenScopeProvider - .ResolveAsync(submittingUser!, configProjectId, ct) - .ConfigureAwait(false) - : _tokenStore is IEffectiveGitHubTokenScopeResolver scopeResolver - ? await scopeResolver.ResolveEffectiveScopeAsync(submittingUser!, ct).ConfigureAwait(false) - : GitHubTokenScope.ForUser(submittingUser!); - var kvUserSecretName = KeyVaultSecretStore.SanitizeKey(effectiveScope.Key); var turnToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); var claimCreated = false; try @@ -552,16 +513,26 @@ await _authorshipCapabilityStore.RegisterAsync( } } - // Warm-pool deferred /configure: inject the per-run RunId/UserId/TurnBearerToken and the - // KV secret name into the already-warm pod, which then runs SetupAsync and becomes ready. + // Warm-pool deferred /configure injects only run context and bounded Copilot sign-in + // material. Repository tokens, locators, and scope metadata never cross this boundary. // Normal roles use the shared orchestration worktree. Local workspace modes carry // immutable source refs; AgentHost creates their effective root inside execution-scratch. if (claimCreated) { + var copilotCredential = _copilotCredentialIssuer is null + ? null + : await _copilotCredentialIssuer.TryIssueAsync(runId, ct).ConfigureAwait(false); + if (copilotCredential is null) + { + throw new AgentHostConfigureException( + "github_capability_unavailable", + $"No run-bound Copilot capability is available for run '{runId}'.", + 403); + } + var effectiveWorkingDirectory = await CallAgentHostConfigureAsync( - podIp, _options.AgentHostPort, runId, submittingUser, turnToken, kvUserSecretName, - effectiveScope, - await ResolveGitHubAccessTokenAsync(effectiveScope, submittingUser, ct).ConfigureAwait(false), + podIp, _options.AgentHostPort, runId, submittingUser, turnToken, + copilotCredential.AccessToken, requestedWorkingDirectory ?? await ResolveWorkingDirectoryAsync(runId, ct).ConfigureAwait(false), launchContext, configProjectId, @@ -866,82 +837,17 @@ private static bool IsTransientK8sFault(Exception ex, CancellationToken ct) return null; } - /// - /// Resolves the run owner's GitHub access token from the API-side token store so it can be - /// forwarded in the /configure body. The kata VM pod cannot reach Azure AD or Key Vault - /// (Cilium FQDN policies use eBPF interception that doesn't cross the guest kernel boundary). - /// Never throws — a lookup failure degrades gracefully: the pod will attempt the KV fetch itself - /// (which may fail) rather than causing a hard launch failure here. - /// - private async Task ResolveGitHubAccessTokenAsync( - GitHubTokenScope scope, - string userId, - CancellationToken ct) - { - // Prefer the refresh-aware provider (issue #523): a fresh AgentHost pod launched late in a - // long-running assembly (e.g. the Build & Test gate, well after the run's earlier subtask - // stages) can be handed a near-expiry or already-expired access token if we only ever read - // the raw stored entry — the pod's "fast path" trusts a pre-resolved token unconditionally - // and never re-validates it against Key Vault or GitHub. Routing through - // GetValidAccessTokenAsync mirrors GitHubCopilotClientFactory.CreateClientAsync and - // transparently rotates the token before it is handed to the pod. - if (_accessTokenProvider is not null) - { - try - { - var refreshed = await _accessTokenProvider.GetValidAccessTokenAsync(scope, ct) - .ConfigureAwait(false); - if (string.IsNullOrEmpty(refreshed)) - { - _logger.LogWarning( - "KubernetesSandboxExecutor: refresh-aware GitHub token provider returned no valid credential " + - "for {UserId} (scope {Scope}); refusing raw-token fallback.", - userId, - scope.Key); - } - return refreshed; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, - "KubernetesSandboxExecutor: failed to resolve/refresh GitHub token for {UserId} via " + - "IGitHubAccessTokenProvider (scope {Scope}); refusing raw-token fallback.", - userId, - scope.Key); - return null; - } - } - - if (_tokenStore is null) - return null; - - try - { - var entry = await _tokenStore.GetAsync(scope, ct).ConfigureAwait(false); - if (entry.Status == GitHubTokenStatus.SignedIn && !string.IsNullOrEmpty(entry.AccessToken)) - return entry.AccessToken; - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "KubernetesSandboxExecutor: failed to pre-resolve GitHub token for {UserId} — pod will fall back to KV.", - userId); - } - - return null; - } - /// /// Injects the per-run context into an already-warm AgentHost pod via its one-time - /// POST /configure endpoint. The pod then fetches ONLY - /// from Key Vault (its configured user's token) and runs SetupAsync. The endpoint is guarded by + /// POST /configure endpoint. The host receives bounded inference material only; it has no + /// repository credential locator or Key Vault access. The endpoint is guarded by /// NetworkPolicy (ingress to AgentHost pods restricted to API/worker), not the TurnBearerToken /// (which is itself delivered here). Idempotency: a second call returns 409 and is treated as a /// hard launch failure. /// private async Task CallAgentHostConfigureAsync( string podIp, int port, string runId, string userId, string turnBearerToken, - string kvUserSecretName, GitHubTokenScope tokenScope, string? gitHubAccessToken, + string? copilotAccessToken, string? sharedWorkingDirectory, AgentHostLaunchContext launchContext, string? projectId, @@ -972,8 +878,7 @@ private static bool IsTransientK8sFault(Exception ex, CancellationToken ct) runId, userId, turnBearerToken, - kvUserSecretName, - gitHubAccessToken, + copilotAccessToken, 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. @@ -1026,39 +931,6 @@ private static bool IsTransientK8sFault(Exception ex, CancellationToken ct) // Plain-text legacy errors keep the generic typed reason. } - if (string.Equals( - reason, - "agenthost_configure_copilot_unauthorized", - StringComparison.Ordinal) && - _accessTokenProvider is not null) - { - var refreshed = await _accessTokenProvider - .RefreshAfterUnauthorizedAsync(tokenScope, gitHubAccessToken, ct) - .ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(refreshed) && - !string.Equals(refreshed, gitHubAccessToken, StringComparison.Ordinal)) - { - _logger.LogWarning( - "KubernetesSandboxExecutor: AgentHost /configure rejected the Copilot credential for run {RunId}; " + - "scope {Scope} was refreshed and the pod must be recreated (recoveryAttempt=1, maxRecoveryAttempts=1).", - runId, - tokenScope.Key); - throw new AgentHostConfigureException( - "agenthost_configure_copilot_token_refreshed", - $"AgentHost /configure rejected the Copilot credential for run '{runId}'. " + - "The credential was refreshed; recreate the one-time-configured pod and retry once.", - (int)response.StatusCode, - retryable: true, - recoveryAction: "recreate_pod_with_refreshed_credential"); - } - - _logger.LogWarning( - "KubernetesSandboxExecutor: AgentHost /configure rejected the Copilot credential for run {RunId}; " + - "scope {Scope} could not produce a different refreshed credential, so the failure is not retryable.", - runId, - tokenScope.Key); - } - throw new AgentHostConfigureException( reason, $"AgentHost /configure for run '{runId}' failed: HTTP {(int)response.StatusCode} {detail}", diff --git a/apps/Agentweaver.Api/Sandbox/RunBoundCopilotCredentialIssuer.cs b/apps/Agentweaver.Api/Sandbox/RunBoundCopilotCredentialIssuer.cs new file mode 100644 index 000000000..257b51e03 --- /dev/null +++ b/apps/Agentweaver.Api/Sandbox/RunBoundCopilotCredentialIssuer.cs @@ -0,0 +1,50 @@ +using Agentweaver.Api.Auth; +using Agentweaver.Api.Infrastructure; +using Agentweaver.Api.Memory; +using Agentweaver.Domain; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentweaver.Api.Sandbox; + +/// +/// The sole AgentHost-launch adapter for Copilot credentials. It derives both run and purpose +/// server-side, selects an immutable snapshot, and asks the broker to re-fence it before delivery. +/// +public interface IRunBoundCopilotCredentialIssuer +{ + Task TryIssueAsync(string runId, CancellationToken ct = default); +} + +internal sealed class RunBoundCopilotCredentialIssuer( + IServiceScopeFactory scopeFactory, + IRunStore runStore) : IRunBoundCopilotCredentialIssuer +{ + public async Task TryIssueAsync(string runId, CancellationToken ct = default) + { + if (!RunId.TryParse(runId, out var id)) + return null; + + var run = await runStore.GetAsync(id, ct).ConfigureAwait(false); + if (run?.ProjectId is null) + return null; + + var purpose = string.IsNullOrWhiteSpace(run.SubmittingUser) + ? GitHubCapabilityPurpose.UnattendedCopilot + : GitHubCapabilityPurpose.InteractiveCopilot; + + using var scope = scopeFactory.CreateScope(); + var persistence = scope.ServiceProvider.GetRequiredService(); + var snapshot = (await persistence.GetCapabilitySnapshotsAsync(runId, ct).ConfigureAwait(false)) + .SingleOrDefault(x => x.Purpose == purpose); + if (snapshot is null) + return null; + + return await scope.ServiceProvider.GetRequiredService() + .TryAcquireCopilotCredentialAsync( + purpose, + new SnapshotRef(snapshot.SnapshotRef), + DateTimeOffset.UtcNow, + ct) + .ConfigureAwait(false); + } +} diff --git a/apps/Agentweaver.Api/Sandbox/SandboxExecutorRouter.cs b/apps/Agentweaver.Api/Sandbox/SandboxExecutorRouter.cs index cb17aa3ec..97c71e2e1 100644 --- a/apps/Agentweaver.Api/Sandbox/SandboxExecutorRouter.cs +++ b/apps/Agentweaver.Api/Sandbox/SandboxExecutorRouter.cs @@ -25,24 +25,20 @@ public sealed class SandboxExecutorRouter : ISandboxExecutorRouter private readonly Security.IRunAuthorshipCapabilityStore? _authorshipCapabilityStore; private readonly IHttpClientFactory? _httpClientFactory; private readonly IRunSubmittingUserResolver? _submittingUserResolver; - private readonly IGitHubTokenStore? _tokenStore; - private readonly IGitHubTokenScopeProvider? _tokenScopeProvider; private readonly Agentweaver.Api.Auth.ISecretStore? _secretStore; private readonly IRunEventStream? _runEventStream; private readonly IRunOptionsStore? _runOptions; - private readonly IGitHubAccessTokenProvider? _accessTokenProvider; + private readonly IRunBoundCopilotCredentialIssuer? _copilotCredentialIssuer; private readonly Preview.ISandboxPreviewService? _previewService; public SandboxExecutorRouter(IConfiguration config, ILoggerFactory loggerFactory, IPodNameRegistry? podRegistry = null, IHttpClientFactory? httpClientFactory = null, IRunSubmittingUserResolver? submittingUserResolver = null, IAgentHostTurnTokenRegistry? turnTokenRegistry = null, - IGitHubTokenStore? tokenStore = null, - IGitHubTokenScopeProvider? tokenScopeProvider = null, Agentweaver.Api.Auth.ISecretStore? secretStore = null, IRunEventStream? runEventStream = null, IRunOptionsStore? runOptions = null, - IGitHubAccessTokenProvider? accessTokenProvider = null, + IRunBoundCopilotCredentialIssuer? copilotCredentialIssuer = null, Preview.ISandboxPreviewService? previewService = null, Security.IRunAuthorshipCapabilityStore? authorshipCapabilityStore = null) { @@ -52,12 +48,10 @@ public SandboxExecutorRouter(IConfiguration config, ILoggerFactory loggerFactory _turnTokenRegistry = turnTokenRegistry; _httpClientFactory = httpClientFactory; _submittingUserResolver = submittingUserResolver; - _tokenStore = tokenStore; - _tokenScopeProvider = tokenScopeProvider; _secretStore = secretStore; _runEventStream = runEventStream; _runOptions = runOptions; - _accessTokenProvider = accessTokenProvider; + _copilotCredentialIssuer = copilotCredentialIssuer; _previewService = previewService; _authorshipCapabilityStore = authorshipCapabilityStore; } @@ -113,9 +107,6 @@ public ISandboxExecutor Resolve() _config["Sandbox:Kubernetes:AgentHostReadyTimeoutSeconds"], out int rt) ? rt : 90, AgentHostReadyPollIntervalMs = int.TryParse( _config["Sandbox:Kubernetes:AgentHostReadyPollIntervalMs"], out int ri) ? ri : 1000, - // Option C warm-pool token fetch: same KV the API persists user tokens to. - KvUri = _config["Sandbox:AgentHost:KeyVaultUri"] - ?? _config["Auth:TokenStore:KeyVaultUri"], }; var k8sLogger = _loggerFactory.CreateLogger(); WarnIfServiceCidrNotExcluded(sandboxOptions, logger); @@ -144,9 +135,8 @@ public ISandboxExecutor Resolve() sandboxOptions.Namespace, sandboxOptions.WorkspaceMountPath); return new KubernetesSandboxExecutor( k8sClient, sandboxOptions, k8sLogger, _podRegistry, _turnTokenRegistry, readinessProbe, - _submittingUserResolver, _httpClientFactory, _tokenStore, _secretStore, _runEventStream, - _runOptions, _accessTokenProvider, _previewService, - tokenScopeProvider: _tokenScopeProvider, + _submittingUserResolver, _httpClientFactory, _secretStore, _runEventStream, + _runOptions, _previewService, _copilotCredentialIssuer, authorshipCapabilityStore: _authorshipCapabilityStore); } catch (Exception ex) diff --git a/docs/guide/architecture-aks.md b/docs/guide/architecture-aks.md index e0a2b6106..3cd3a4e8e 100644 --- a/docs/guide/architecture-aks.md +++ b/docs/guide/architecture-aks.md @@ -144,7 +144,7 @@ See [Deploy to AKS](/guide/deployment-aks#sandbox-setup) for setup details. ### Secrets management -Secrets are delivered from **Azure Key Vault** with **Azure Workload Identity**. API app secrets still use the Secrets Store CSI driver; AgentHost user GitHub tokens are resolved on the API side and brokered to the sandbox pod in the one-time `/configure` call (`gitHubAccessToken`), because the sandbox identity has no Key Vault access (issue #471). There are no static credentials in any manifest. +Secrets are delivered from **Azure Key Vault** with **Azure Workload Identity** to trusted API services only. The AgentHost has no Key Vault identity, CSI mount, repository token, credential locator, or credential helper. At launch the API derives a Copilot-only snapshot from the persisted run, fences it before and after vault access, and supplies only short-lived inference material in its one-time `/configure` control message. There are no static credentials in any manifest. ![Secrets management: Managed Identity, ServiceAccount, ServiceAccount, AKS OIDC Issuer, AKS OIDC Issuer, Azure Key Vault, SecretProviderClass, Per-user GitHub token secret, API Pod, Warm AgentHost Pod, MCP Pod](../diagrams/guide-architecture-aks-fig5.png) @@ -153,7 +153,7 @@ Secrets are delivered from **Azure Key Vault** with **Azure Workload Identity**. Edit the JSON, then run `npm run docs:render-diagrams` and commit the regenerated PNG + .hash.txt. --> -The API's and worker's `ServiceAccount`s (`agentweaver-api`, `agentweaver-worker`) are federated to the shared, Key-Vault-privileged user-assigned `agentweaver-api-identity` through the cluster's OIDC issuer (`agentweaver-api-fedcred` and `agentweaver-worker-fedcred` respectively). The worker has its own Kubernetes RBAC identity and receives only sandbox lifecycle and legacy exec permissions; it does not inherit the API's preview-management permissions. The `agentweaver-agent-host` ServiceAccount is federated to a **separate, dedicated managed identity (`agentweaver-agenthost-identity`) that has no Key Vault role assignments** (issue #471) via its own federated credential (`agentweaver-agenthost-fedcred`). Because the sandbox runs untrusted shell/tool code, it must not be able to read Key Vault; the run owner's GitHub token is instead brokered per-run by the API in the `/configure` call. +The API's and worker's `ServiceAccount`s (`agentweaver-api`, `agentweaver-worker`) are federated to the shared, Key-Vault-privileged user-assigned `agentweaver-api-identity` through the cluster's OIDC issuer (`agentweaver-api-fedcred` and `agentweaver-worker-fedcred` respectively). The worker has its own Kubernetes RBAC identity and receives only sandbox lifecycle and legacy exec permissions; it does not inherit the API's preview-management permissions. `agentweaver-agent-host` has no workload identity or service-account token because sandbox shell/tool code must not reach Key Vault or repository credentials. One static `SecretProviderClass` object syncs app secrets from Key Vault into the API pod volume: @@ -169,7 +169,7 @@ The MCP pod mounts no secrets; MCP auth relies only on OAuth (Agentweaver-minted Secrets are read at pod startup via a shell wrapper in the container `command` — they are sourced from files, not injected as Kubernetes Secret refs. The CSI volume mount on `/mnt/secrets-store` is required to trigger synchronization; without it the files are never written. -Secret rotation polling is set to 2 minutes (`secrets-store.csi.k8s.io/rotation-poll-interval: "2m"`) for CSI-mounted API app secrets. Each authenticated user's GitHub OAuth token is stored in Key Vault under a per-user key (`ghtok-user--{base32(userId)}`). At run launch, `KubernetesSandboxExecutor` claims a pod from the shared `agentweaver-agent-host` pool, calls `POST /configure` with the run owner's secret name, and the pod's `KeyVaultUserTokenProvider` fetches only that secret through `SecretClient` + `DefaultAzureCredential`, caching it for the pod lifetime. +Secret rotation polling is set to 2 minutes (`secrets-store.csi.k8s.io/rotation-poll-interval: "2m"`) for CSI-mounted API app secrets. Authenticated GitHub credentials remain in the API-side credential vault. At run launch, `KubernetesSandboxExecutor` claims a pod from the shared `agentweaver-agent-host` pool and the trusted API-side broker uses the immutable run snapshot to issue bounded Copilot inference material. Repository reads, materialization, and writeback remain API-owned, snapshot-fenced operations; sandbox code receives neither repository authority nor a broker endpoint. NetworkPolicy limits are defense in depth, not the authority that protects credentials. --- diff --git a/k8s/base/sandbox-template-agenthost.yaml b/k8s/base/sandbox-template-agenthost.yaml index f9f229698..952a6632d 100644 --- a/k8s/base/sandbox-template-agenthost.yaml +++ b/k8s/base/sandbox-template-agenthost.yaml @@ -30,8 +30,7 @@ # # Apply (Kustomize): # kubectl apply -k k8s/overlays/production -# (image tag + AGENTHOST_KEYVAULT_URI are injected by the overlay's `images:` -# transformer and generated agentweaver-runtime-config ConfigMap, respectively.) +# (image tags are injected by the overlay's `images:` transformer.) --- apiVersion: extensions.agents.x-k8s.io/v1beta1 kind: SandboxTemplate @@ -54,19 +53,11 @@ spec: envVarsInjectionPolicy: Allowed podTemplate: metadata: - annotations: - # Keep the AAD workload-identity token (env + projected volume) out of the container that - # runs model-controlled code. Combined with the exec-no-serviceaccount mount below, the - # executor sidecar holds no cluster or cloud identity at all. - azure.workload.identity/skip-containers: "agentweaver-exec" labels: app: agentweaver-agent-host agentweaver.dev/sandbox: "true" app.kubernetes.io/part-of: agentweaver app.kubernetes.io/component: agent-host - # Workload identity webhook mutates the pod (projected SA token + AZURE_* env) - # so the CSI secrets-store driver can authenticate to Key Vault. - azure.workload.identity/use: "true" spec: dnsPolicy: ClusterFirst runtimeClassName: kata-vm-isolation @@ -93,7 +84,7 @@ spec: - "true" restartPolicy: Never serviceAccountName: agentweaver-agent-host - automountServiceAccountToken: true # needed for CSI workload identity + automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 1000 @@ -136,12 +127,8 @@ spec: # triggering an OOM kill that tears down the runner pod. - name: NODE_OPTIONS value: "--max-old-space-size=1024" - # issue #471: the run owner's GitHub token is delivered in-memory via the API's POST - # /configure call (AgentHostRuntimeState.GitHubAccessToken); the sandbox does NOT read Key - # Vault directly and its identity holds no KV roles. AgentHost__KeyVaultUri is retained - # only as a defense-in-depth fallback target — with the dedicated KV-less identity any - # such call fails closed — and is injected by the production overlay's configMapGenerator - # (agentweaver-runtime-config) at deploy time. + # The trusted API sends bounded Copilot inference material only through /configure. + # This pod has no repository-token, Key Vault, CSI, or shared-home credential path. # Static workspace paths — always /workspace for this deployment. # These live here (not in the SandboxClaim) so warm pool adoption is not bypassed. - name: AgentHost__WorkingDirectory @@ -168,11 +155,6 @@ spec: # hardcode here rather than route through the runtime-config ConfigMap. - name: AgentHost__McpEndpoint value: "http://agentweaver-mcp:8080/mcp" - - name: AgentHost__KeyVaultUri - valueFrom: - configMapKeyRef: - name: agentweaver-runtime-config - key: AGENTHOST_KEYVAULT_URI - name: APPLICATIONINSIGHTS_CONNECTION_STRING valueFrom: secretKeyRef: diff --git a/k8s/base/secret-provider-class.yaml b/k8s/base/secret-provider-class.yaml index 87b7be268..b304f218a 100644 --- a/k8s/base/secret-provider-class.yaml +++ b/k8s/base/secret-provider-class.yaml @@ -44,33 +44,3 @@ spec: key: mcp-api-key - objectName: appinsights-connection-string key: appinsights-connection-string ---- -# Base AgentHost token SPC. Keep this committed manifest installation-only: the API creates a -# per-run SecretProviderClass for each AgentHost pod containing only that run owner's -# ghtok-user--{base32(userId)} entry, then points a run-scoped SandboxTemplate at it. -apiVersion: secrets-store.csi.x-k8s.io/v1 -kind: SecretProviderClass -metadata: - name: agentweaver-user-tokens - namespace: agentweaver - annotations: - secrets-store.csi.k8s.io/rotation-poll-interval: "2m" -spec: - provider: azure - parameters: - usePodIdentity: "false" - useVMManagedIdentity: "false" - clientID: "changeme" - keyvaultName: "changeme" - tenantId: "changeme" - objects: | - array: - - | - objectName: ghtok-installation - objectType: secret - secretObjects: - - secretName: agentweaver-user-tokens - type: Opaque - data: - - objectName: ghtok-installation - key: installation.json diff --git a/k8s/overlays/production/kustomization.yaml b/k8s/overlays/production/kustomization.yaml index c12b84ac1..b4dd0e625 100644 --- a/k8s/overlays/production/kustomization.yaml +++ b/k8s/overlays/production/kustomization.yaml @@ -80,7 +80,6 @@ configMapGenerator: - "HOST=agentweaver.example.com" - "PREVIEW_HOSTNAME=*.example.com" - "IDENTITY_CLIENT_ID=changeme" - - "AGENTHOST_IDENTITY_CLIENT_ID=changeme" - "KEYVAULT_NAME=changeme" - "TENANT_ID=changeme" - "OAUTH_ISSUER=https://agentweaver.example.com" @@ -96,7 +95,6 @@ configMapGenerator: - "ENTRA_TENANT_ID=" - "ENTRA_REDIRECT_URI=https://agentweaver.example.com/auth/entra/callback" - "TOKEN_STORE_KEYVAULT_URI=https://changeme.vault.azure.net" - - "AGENTHOST_KEYVAULT_URI=https://changeme.vault.azure.net/" - "APPINSIGHTS_WORKSPACE_ID=" - "SANDBOX_PREVIEW_ZONE_SUFFIX=example.com" @@ -144,19 +142,6 @@ replacements: name: agentweaver-agent-host fieldPaths: - metadata.annotations.[azure.workload.identity/client-id] - - source: - kind: ConfigMap - name: agentweaver-runtime-config - fieldPath: data.AGENTHOST_IDENTITY_CLIENT_ID - targets: - # Dedicated least-privilege AgentHost identity (issue #471): NO Key Vault roles. The sandbox - # cannot exchange its workload-identity token for a vault token; the run owner's GitHub token is - # brokered per-run via the API /configure call instead. - - select: - kind: ServiceAccount - name: agentweaver-agent-host - fieldPaths: - - metadata.annotations.[azure.workload.identity/client-id] - source: kind: ConfigMap name: agentweaver-runtime-config diff --git a/packages/Agentweaver.AgentRuntime/Providers/GitHubCopilotClientFactory.cs b/packages/Agentweaver.AgentRuntime/Providers/GitHubCopilotClientFactory.cs index 186d5e0aa..5782152cc 100644 --- a/packages/Agentweaver.AgentRuntime/Providers/GitHubCopilotClientFactory.cs +++ b/packages/Agentweaver.AgentRuntime/Providers/GitHubCopilotClientFactory.cs @@ -15,9 +15,10 @@ public sealed class GitHubCopilotClientFactory : IAsyncDisposable private readonly string? _configFallbackToken; private readonly string? _configFallbackTokenFile; private readonly string? _runtimeCliPath; - private readonly IGitHubTokenStore _tokenStore; - private readonly IGitHubTokenScopeProvider _scopeProvider; + private readonly IGitHubTokenStore? _tokenStore; + private readonly IGitHubTokenScopeProvider? _scopeProvider; private readonly IGitHubAccessTokenProvider? _accessTokenProvider; + private readonly ICopilotCredentialProvider? _runBoundCredentialProvider; private readonly ILogger? _logger; private static readonly TimeSpan TokenExpirySkew = TimeSpan.FromMinutes(2); private static readonly TimeSpan[] RateLimitRetryDelays = @@ -29,14 +30,16 @@ public sealed class GitHubCopilotClientFactory : IAsyncDisposable public GitHubCopilotClientFactory( IConfiguration configuration, - IGitHubTokenStore tokenStore, - IGitHubTokenScopeProvider scopeProvider, + IGitHubTokenStore? tokenStore = null, + IGitHubTokenScopeProvider? scopeProvider = null, IGitHubAccessTokenProvider? accessTokenProvider = null, - ILogger? logger = null) + ILogger? logger = null, + ICopilotCredentialProvider? runBoundCredentialProvider = null) { ArgumentNullException.ThrowIfNull(configuration); - ArgumentNullException.ThrowIfNull(tokenStore); - ArgumentNullException.ThrowIfNull(scopeProvider); + if (tokenStore is null && runBoundCredentialProvider is null) + throw new ArgumentException( + "A token store or a run-bound Copilot credential provider is required."); var section = configuration.GetSection("Providers:GitHubCopilot"); _configFallbackToken = section.GetValue("GitHubToken") @@ -55,6 +58,7 @@ public GitHubCopilotClientFactory( _scopeProvider = scopeProvider; _accessTokenProvider = accessTokenProvider; _logger = logger; + _runBoundCredentialProvider = runBoundCredentialProvider; } /// @@ -82,7 +86,17 @@ public async Task CreateClientAsync( { var options = new CopilotClientOptions(); ApplyRuntimeConnection(options); - var entry = await _tokenStore.GetAsync(scope, ct).ConfigureAwait(false); + if (_runBoundCredentialProvider is not null) + { + var credential = await _runBoundCredentialProvider.GetAsync(ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(credential?.AccessToken)) + throw new GitHubCopilotUnauthorizedException( + "GitHub Copilot is not authorized for this run."); + options.GitHubToken = credential.AccessToken; + return new CopilotClient(options); + } + + var entry = await _tokenStore!.GetAsync(scope, ct).ConfigureAwait(false); var token = entry.Status switch { // Route signed-in tokens through the refresh-aware provider so an expired access @@ -140,14 +154,20 @@ private void ApplyRuntimeConnection(CopilotClientOptions options) public async Task ShouldRefreshBeforeAiCallAsync(GitHubTokenScope scope, CancellationToken ct) { - var token = await _tokenStore.GetTokenAsync(scope, ct).ConfigureAwait(false); + if (_runBoundCredentialProvider is not null) + { + var credential = await _runBoundCredentialProvider.GetAsync(ct).ConfigureAwait(false); + return credential?.ExpiresAt <= DateTimeOffset.UtcNow.Add(TokenExpirySkew); + } + + var token = await _tokenStore!.GetTokenAsync(scope, ct).ConfigureAwait(false); if (token?.ExpiresAt is null) return false; return token.ExpiresAt <= DateTimeOffset.UtcNow.Add(TokenExpirySkew); } - public static bool IsUnauthorized(Exception ex) => + public static bool IsUnauthorized(Exception ex) => HasStatusCode(ex, HttpStatusCode.Unauthorized) || ExceptionText(ex).Contains("401", StringComparison.OrdinalIgnoreCase); public static bool IsRateLimited(Exception ex) => @@ -220,6 +240,15 @@ private static string ExceptionText(Exception ex) public ValueTask DisposeAsync() => ValueTask.CompletedTask; } +/// Trusted provider for ephemeral, inference-only Copilot sign-in material. +public interface ICopilotCredentialProvider +{ + Task GetAsync(CancellationToken ct = default); +} + +/// Non-serializable Copilot credential held only by a trusted runtime process. +public sealed record CopilotCredential(string AccessToken, DateTimeOffset? ExpiresAt); + /// /// Thrown when no valid GitHub token is available for Copilot. /// Does not include token content or credential details in the message. diff --git a/tests/Agentweaver.Tests/AgentHost/A2ARoundTripIntegrationTests.cs b/tests/Agentweaver.Tests/AgentHost/A2ARoundTripIntegrationTests.cs index 6f08d17e2..15fc5bf1f 100644 --- a/tests/Agentweaver.Tests/AgentHost/A2ARoundTripIntegrationTests.cs +++ b/tests/Agentweaver.Tests/AgentHost/A2ARoundTripIntegrationTests.cs @@ -451,8 +451,7 @@ public async Task RealA2ARoundTrip_OperatorAssistantPurpose_ExecutesMcpToolCallA RunId: "run-operator-roundtrip-1", UserId: "user-1", TurnBearerToken: "turn-token", - KvUserSecretName: null, - GitHubAccessToken: "gh-oauth-token-abc", + CopilotAccessToken: "copilot-token-abc", PreviewRunnerCredential: null, SharedWorkingDirectory: null, Purpose: AgentHostPurpose.OperatorAssistant, @@ -576,8 +575,7 @@ public async Task OperatorPodTurnRunner_WithoutStreamWriter_FailsClosed_InsteadO RunId: "run-no-writer", UserId: "user-1", TurnBearerToken: "turn-token", - KvUserSecretName: null, - GitHubAccessToken: "gh-oauth-token", + CopilotAccessToken: "copilot-token", PreviewRunnerCredential: null, SharedWorkingDirectory: null, Purpose: AgentHostPurpose.OperatorAssistant, diff --git a/tests/Agentweaver.Tests/AgentHost/A2ATurnBridgeAgentTests.cs b/tests/Agentweaver.Tests/AgentHost/A2ATurnBridgeAgentTests.cs index 895daf713..e226e8774 100644 --- a/tests/Agentweaver.Tests/AgentHost/A2ATurnBridgeAgentTests.cs +++ b/tests/Agentweaver.Tests/AgentHost/A2ATurnBridgeAgentTests.cs @@ -451,8 +451,7 @@ public async Task CreateSessionAsync_OperatorAssistantPurpose_DoesNotThrow_EvenT RunId: "run-operator-session-bypass", UserId: "user-1", TurnBearerToken: "turn-token", - KvUserSecretName: null, - GitHubAccessToken: "gh-oauth-token-abc", + CopilotAccessToken: "copilot-token-abc", PreviewRunnerCredential: null, SharedWorkingDirectory: null, Purpose: AgentHostPurpose.OperatorAssistant, @@ -485,8 +484,7 @@ public async Task CreateSessionAsync_NonOperatorAssistantPurpose_StillDelegatesT RunId: "run-coordinator-1", UserId: "user-1", TurnBearerToken: "turn-token", - KvUserSecretName: null, - GitHubAccessToken: "gh-oauth-token-abc", + CopilotAccessToken: "copilot-token-abc", PreviewRunnerCredential: null, SharedWorkingDirectory: null, Purpose: AgentHostPurpose.Default, diff --git a/tests/Agentweaver.Tests/AgentHost/AgentHostConfigureIdentityTests.cs b/tests/Agentweaver.Tests/AgentHost/AgentHostConfigureIdentityTests.cs index 658885371..135b83b94 100644 --- a/tests/Agentweaver.Tests/AgentHost/AgentHostConfigureIdentityTests.cs +++ b/tests/Agentweaver.Tests/AgentHost/AgentHostConfigureIdentityTests.cs @@ -41,8 +41,7 @@ public void TryConfigure_applies_projectId_and_agentName_to_runtime_state() RunId: "run-335", UserId: "sabbour", TurnBearerToken: "tok", - KvUserSecretName: null, - GitHubAccessToken: null, + CopilotAccessToken: null, PreviewRunnerCredential: null, SharedWorkingDirectory: "/workspace/run-335", ProjectId: "project-335", diff --git a/tests/Agentweaver.Tests/AgentHost/AgentHostStartupServiceConfigureTests.cs b/tests/Agentweaver.Tests/AgentHost/AgentHostStartupServiceConfigureTests.cs index 77347d6cb..3a35372dc 100644 --- a/tests/Agentweaver.Tests/AgentHost/AgentHostStartupServiceConfigureTests.cs +++ b/tests/Agentweaver.Tests/AgentHost/AgentHostStartupServiceConfigureTests.cs @@ -62,7 +62,7 @@ public void ConfigureAsync_seeds_pod_run_options_with_autoApproveTools() // so its eventual (expected) SetupAsync failure never surfaces as an unobserved exception. var task = service.ConfigureAsync( runId, userId: "sabbour", turnBearerToken: "tok", - kvUserSecretName: null, gitHubAccessToken: null, workingDirectory: null, + copilotAccessToken: null, workingDirectory: null, autoApproveTools: true, ct: new CancellationToken(canceled: true)); _ = task.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default); @@ -85,7 +85,7 @@ public void ConfigureAsync_leaves_autoApproveTools_false_when_flag_off() var task = service.ConfigureAsync( runId, userId: "sabbour", turnBearerToken: "tok", - kvUserSecretName: null, gitHubAccessToken: null, workingDirectory: null, + copilotAccessToken: null, workingDirectory: null, autoApproveTools: false, ct: new CancellationToken(canceled: true)); _ = task.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default); @@ -194,8 +194,7 @@ public async Task ConfigureAsync_without_shared_workspace_uses_writable_pod_priv runId, UserId: "sabbour", TurnBearerToken: "tok", - KvUserSecretName: null, - GitHubAccessToken: null, + CopilotAccessToken: null, PreviewRunnerCredential: null, SharedWorkingDirectory: null), autoApproveTools: false, @@ -253,8 +252,7 @@ public async Task ConfigureAsync_without_shared_workspace_uses_writable_pod_priv runId, userId: "sabbour", turnBearerToken: "tok", - kvUserSecretName: null, - gitHubAccessToken: null, + copilotAccessToken: null, workingDirectory: workspace, autoApproveTools: false, ct: new CancellationToken(canceled: true)); diff --git a/tests/Agentweaver.Tests/AgentHost/PodLocalWorkspaceManagerTests.cs b/tests/Agentweaver.Tests/AgentHost/PodLocalWorkspaceManagerTests.cs index 0bba84638..0199f49ec 100644 --- a/tests/Agentweaver.Tests/AgentHost/PodLocalWorkspaceManagerTests.cs +++ b/tests/Agentweaver.Tests/AgentHost/PodLocalWorkspaceManagerTests.cs @@ -350,8 +350,7 @@ private static AgentHostRunConfiguration Configuration( "workspace-run", UserId: "owner", TurnBearerToken: "token", - KvUserSecretName: null, - GitHubAccessToken: null, + CopilotAccessToken: null, PreviewRunnerCredential: null, SharedWorkingDirectory: sharedWorkingDirectory, Purpose: AgentHostPurpose.AssemblyBuildTest, diff --git a/tests/Agentweaver.Tests/AgentHostReadinessProbeTests.cs b/tests/Agentweaver.Tests/AgentHostReadinessProbeTests.cs index fd6b07554..f433e502a 100644 --- a/tests/Agentweaver.Tests/AgentHostReadinessProbeTests.cs +++ b/tests/Agentweaver.Tests/AgentHostReadinessProbeTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Reflection; using Agentweaver.Api.Sandbox; +using Agentweaver.Api.Auth; using FluentAssertions; using k8s; using Microsoft.Extensions.Logging.Abstractions; @@ -78,7 +79,6 @@ public async Task Executor_awaits_readiness_probe_before_returning_endpoint() """{"status":{"conditions":[{"type":"Ready","status":"True"}],"sandbox":{"name":"agent-pod-1"}}}"""); handler.OnAny(@"^/api/v1/namespaces/agentweaver/pods/agent-pod-1$", """{"kind":"Pod","metadata":{"name":"agent-pod-1"},"status":{"podIP":"10.0.0.7"}}"""); - StubAgentHostBaseResources(handler); var probe = new RecordingProbe(); var executor = NewExecutor(handler, probe); @@ -101,7 +101,6 @@ public async Task Executor_fails_launch_when_readiness_probe_times_out() """{"status":{"conditions":[{"type":"Ready","status":"True"}],"sandbox":{"name":"agent-pod-1"}}}"""); handler.OnAny(@"^/api/v1/namespaces/agentweaver/pods/agent-pod-1$", """{"kind":"Pod","metadata":{"name":"agent-pod-1"},"status":{"podIP":"10.0.0.7"}}"""); - StubAgentHostBaseResources(handler); var probe = new ThrowingProbe(new TimeoutException("never ready")); var executor = NewExecutor(handler, probe); @@ -128,20 +127,14 @@ await act.Should().ThrowAsync() private static KubernetesSandboxExecutor NewExecutor(FakeKubeHandler handler, IAgentHostReadinessProbe probe) => new(new Kubernetes(new KubernetesClientConfiguration { Host = "http://localhost:8080" }, handler), Options(), NullLogger.Instance, podRegistry: null, readinessProbe: probe, - submittingUserResolver: new StubSubmittingUserResolver("sabbour")); + submittingUserResolver: new StubSubmittingUserResolver("sabbour"), + copilotCredentialIssuer: new StubCopilotCredentialIssuer()); - private static void StubAgentHostBaseResources(FakeKubeHandler handler) + private sealed class StubCopilotCredentialIssuer : IRunBoundCopilotCredentialIssuer { - handler.OnGet( - "/apis/secrets-store.csi.x-k8s.io/v1/namespaces/agentweaver/secretproviderclasses/agentweaver-user-tokens", - """ - {"apiVersion":"secrets-store.csi.x-k8s.io/v1","kind":"SecretProviderClass","metadata":{"name":"agentweaver-user-tokens"},"spec":{"provider":"azure","parameters":{"usePodIdentity":"false","useVMManagedIdentity":"false","clientID":"cid","keyvaultName":"kv","tenantId":"tid","objects":"array:\n - |\n objectName: ghtok-installation\n objectType: secret\n"}}} - """); - handler.OnGet( - "/apis/extensions.agents.x-k8s.io/v1beta1/namespaces/agentweaver/sandboxtemplates/agentweaver-agent-host", - """ - {"apiVersion":"extensions.agents.x-k8s.io/v1beta1","kind":"SandboxTemplate","metadata":{"name":"agentweaver-agent-host","namespace":"agentweaver","resourceVersion":"1"},"spec":{"podTemplate":{"spec":{"volumes":[{"name":"csi-user-tokens","csi":{"volumeAttributes":{"secretProviderClass":"agentweaver-user-tokens"}}}]}}}} - """); + public Task TryIssueAsync(string runId, CancellationToken ct = default) => + Task.FromResult( + new RunBoundCopilotCredential("test-copilot-token", DateTimeOffset.UtcNow.AddMinutes(5))); } private sealed class StubSubmittingUserResolver : IRunSubmittingUserResolver diff --git a/tests/Agentweaver.Tests/AgentHostToolApprovalEndpointTests.cs b/tests/Agentweaver.Tests/AgentHostToolApprovalEndpointTests.cs index ff3d1e8a8..08bdd1a9b 100644 --- a/tests/Agentweaver.Tests/AgentHostToolApprovalEndpointTests.cs +++ b/tests/Agentweaver.Tests/AgentHostToolApprovalEndpointTests.cs @@ -30,7 +30,7 @@ public async Task ProductionRuntimeWiring_RunGrant_AutoApprovesOnlyConfiguredRun gate.IsAutoApproved("run-1", "web_fetch", "https://before-configure.test") .Should().BeFalse(); - state.TryConfigure("run-1", "user-1", "", null, null, "pod-credential") + state.TryConfigure("run-1", "user-1", "", null, "pod-credential") .Should().BeTrue(); ownerResolver.GetCanonicalOwner("run-1").Should().Be("user-1"); ownerResolver.GetCanonicalOwner("different-run").Should().BeNull(); @@ -154,7 +154,7 @@ public async Task BlankRequestId_ReturnsBadRequest() private static AgentHostRuntimeState ConfiguredState() { var state = new AgentHostRuntimeState(); - state.TryConfigure("run-1", "user-1", "", null, null, "pod-credential").Should().BeTrue(); + state.TryConfigure("run-1", "user-1", "", null, "pod-credential").Should().BeTrue(); return state; } diff --git a/tests/Agentweaver.Tests/AgentHostUserAuthTests.cs b/tests/Agentweaver.Tests/AgentHostUserAuthTests.cs index a0db2f9a4..1b9214159 100644 --- a/tests/Agentweaver.Tests/AgentHostUserAuthTests.cs +++ b/tests/Agentweaver.Tests/AgentHostUserAuthTests.cs @@ -4,24 +4,22 @@ using Agentweaver.Domain; using FluentAssertions; using Xunit; -using SharedUserScopeProvider = agenthost::Agentweaver.AgentHost.SharedUserScopeProvider; +using AgentHostRuntimeState = agenthost::Agentweaver.AgentHost.AgentHostRuntimeState; +using AgentHostRunConfiguration = agenthost::Agentweaver.AgentHost.AgentHostRunConfiguration; +using RunBoundCopilotScopeProvider = agenthost::Agentweaver.AgentHost.RunBoundCopilotScopeProvider; namespace Agentweaver.Tests; /// -/// Verifies the in-pod GitHub Copilot auth resolution path that the AgentHost__UserId injection -/// (KubernetesSandboxExecutor) drives: when the run's submitting user id is configured the pod -/// resolves the per-user token scope (user_<id>.json); when it is absent it degrades. Also -/// verifies the clear-error detection that replaces the opaque SDK auth failure. +/// Verifies that AgentHost uses only its server-bound run identity for Copilot initialization. /// public sealed class AgentHostUserAuthTests { [Fact] public void Resolve_uses_configured_user_id_when_set() { - // Configured user id (AgentHost:UserId / the run's submitting user) wins → per-user scope, - // so SharedHomeGitHubTokenStore reads user_.json (the Copilot-entitled token). - var provider = new SharedUserScopeProvider(authDir: "/nonexistent", configuredUserId: "sabbour"); + var state = ConfiguredState("sabbour"); + var provider = new RunBoundCopilotScopeProvider(state); var scope = provider.Resolve(userId: null); @@ -29,25 +27,14 @@ public void Resolve_uses_configured_user_id_when_set() } [Fact] - public void Resolve_fails_closed_when_no_user_and_no_signed_in_file() + public void Resolve_fails_closed_when_host_has_no_run_identity() { - // No configured user id and no discoverable signed-in user_*.json → fail closed. Installation - // tokens cannot authorize Copilot turns, so falling back would hide the real configuration bug. - var emptyDir = Path.Combine(Path.GetTempPath(), "agentweaver-scope-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(emptyDir); - try - { - var provider = new SharedUserScopeProvider(authDir: emptyDir, configuredUserId: null); + var provider = new RunBoundCopilotScopeProvider(new AgentHostRuntimeState()); - var act = () => provider.Resolve(userId: null); + var act = () => provider.Resolve(userId: "attacker-selected-user"); - act.Should().Throw() - .WithMessage("*submitting user identity*"); - } - finally - { - Directory.Delete(emptyDir, recursive: true); - } + act.Should().Throw() + .WithMessage("*run-bound Copilot identity*"); } [Fact] @@ -75,4 +62,17 @@ public void IsMissingCopilotAuth_ignores_unrelated_errors() CopilotAIAgent.IsMissingCopilotAuth(new InvalidOperationException("Connection refused")) .Should().BeFalse(); } + + private static AgentHostRuntimeState ConfiguredState(string userId) + { + var state = new AgentHostRuntimeState(); + state.TryConfigure(new AgentHostRunConfiguration( + RunId: "run-auth", + UserId: userId, + TurnBearerToken: "turn", + CopilotAccessToken: "copilot-sign-in", + PreviewRunnerCredential: null, + SharedWorkingDirectory: "/workspace/run-auth")); + return state; + } } diff --git a/tests/Agentweaver.Tests/Auth/TwoAppCredentialArchitectureTests.cs b/tests/Agentweaver.Tests/Auth/TwoAppCredentialArchitectureTests.cs index 98a490ca7..f9d872fa4 100644 --- a/tests/Agentweaver.Tests/Auth/TwoAppCredentialArchitectureTests.cs +++ b/tests/Agentweaver.Tests/Auth/TwoAppCredentialArchitectureTests.cs @@ -117,6 +117,28 @@ public void BrowseAuthorityPersistenceRemainsOwnedByProjectCreationFlow() .Should().BeEmpty(); } + [Fact] + public void AgentHost_HasNoRepositoryCredentialOrVaultDeliveryPath() + { + var root = FindRepositoryRoot(); + var agentHost = Path.Combine(root, "apps", "Agentweaver.AgentHost"); + var source = Directory.EnumerateFiles(agentHost, "*.cs", SearchOption.AllDirectories) + .Select(File.ReadAllText) + .ToArray(); + + source.Should().NotContain(text => text.Contains("KeyVaultUserTokenProvider", StringComparison.Ordinal) + || text.Contains("IGitHubTokenStore", StringComparison.Ordinal) + || text.Contains("SharedTokenStore", StringComparison.Ordinal) + || text.Contains("CsiMountedGitHub", StringComparison.Ordinal) + || text.Contains("CredentialLocator", StringComparison.Ordinal)); + + var manifest = File.ReadAllText(Path.Combine( + root, "k8s", "base", "sandbox-template-agenthost.yaml")); + manifest.Should().NotContain("secrets-store") + .And.NotContain("azure.workload.identity") + .And.Contain("automountServiceAccountToken: false"); + } + private static bool ContainsReservedCredentialPrefix(string source) => source.Contains("repo-app-user-credential", StringComparison.Ordinal) || source.Contains("copilot-app-project", StringComparison.Ordinal) || diff --git a/tests/Agentweaver.Tests/KubernetesSandboxExecutorClaimTests.cs b/tests/Agentweaver.Tests/KubernetesSandboxExecutorClaimTests.cs index a2a5741a8..d7c0cbc82 100644 --- a/tests/Agentweaver.Tests/KubernetesSandboxExecutorClaimTests.cs +++ b/tests/Agentweaver.Tests/KubernetesSandboxExecutorClaimTests.cs @@ -58,9 +58,16 @@ private static KubernetesSandboxExecutor NewExecutor( IGitHubTokenScopeProvider? tokenScopeProvider = null) => new(ClientFor(handler), Options(), NullLogger.Instance, podRegistry: podRegistry, readinessProbe: null, submittingUserResolver: submittingUserResolver, - httpClientFactory: httpClientFactory, runOptions: runOptions, tokenStore: tokenStore, - accessTokenProvider: accessTokenProvider, previewService: previewService, - tokenScopeProvider: tokenScopeProvider); + httpClientFactory: httpClientFactory, runOptions: runOptions, previewService: previewService, + copilotCredentialIssuer: new StubRunBoundCopilotCredentialIssuer("test-copilot-token")); + + private sealed class StubRunBoundCopilotCredentialIssuer(string? accessToken) : IRunBoundCopilotCredentialIssuer + { + public Task TryIssueAsync(string runId, CancellationToken ct = default) => + Task.FromResult(string.IsNullOrWhiteSpace(accessToken) + ? null + : new RunBoundCopilotCredential(accessToken, DateTimeOffset.UtcNow.AddMinutes(5))); + } private sealed class StubSubmittingUserResolver : IRunSubmittingUserResolver { @@ -412,9 +419,9 @@ public async Task LaunchAgentHostPod_configures_warm_pod_with_run_owner_kv_secre body.GetProperty("runId").GetString().Should().Be(runId); body.GetProperty("userId").GetString().Should().Be("sabbour"); body.GetProperty("turnBearerToken").GetString().Should().NotBeNullOrEmpty(); - body.GetProperty("kvUserSecretName").GetString().Should() - .StartWith("ghtok-user--", - "the pod must fetch ONLY the run owner's KV secret (base32-encoded user id)"); + body.TryGetProperty("kvUserSecretName", out _).Should().BeFalse( + "the pod must not receive a Key Vault credential locator"); + body.GetProperty("copilotAccessToken").GetString().Should().Be("test-copilot-token"); } [Fact] @@ -448,9 +455,9 @@ await executor.LaunchAgentHostPodAsync( using var doc = JsonDocument.Parse(configureHandler.Body!); var body = doc.RootElement; body.GetProperty("callerBearerToken").GetString().Should().Be(callerBearerToken); - body.GetProperty("gitHubAccessToken").GetString().Should().Be("linked-github-token"); + body.GetProperty("copilotAccessToken").GetString().Should().Be("test-copilot-token"); body.GetProperty("callerBearerToken").GetString().Should().NotBe( - body.GetProperty("gitHubAccessToken").GetString(), + body.GetProperty("copilotAccessToken").GetString(), "the Entra platform credential and linked GitHub/Copilot credential have different trust purposes"); } @@ -477,7 +484,8 @@ public async Task LaunchAgentHostPod_operator_recreates_existing_claim_before_se turnTokenRegistry: turnTokens, readinessProbe: null, submittingUserResolver: new StubSubmittingUserResolver("entra-object-id"), - httpClientFactory: new StubHttpClientFactory(configureHandler)); + httpClientFactory: new StubHttpClientFactory(configureHandler), + copilotCredentialIssuer: new StubRunBoundCopilotCredentialIssuer("test-copilot-token")); await executor.LaunchAgentHostPodAsync( runId, @@ -530,9 +538,8 @@ public async Task LaunchAgentHostPod_configure_prefers_refreshed_token_over_stal await executor.LaunchAgentHostPodAsync(runId); using var doc = JsonDocument.Parse(configureHandler.Body!); - doc.RootElement.GetProperty("gitHubAccessToken").GetString().Should().Be("freshly-rotated-token", - "the refresh-aware provider must be consulted (and win) over the raw, non-refreshing token " + - "store read so a newly-launched pod never receives a stale/near-expiry access token"); + doc.RootElement.GetProperty("copilotAccessToken").GetString().Should().Be("test-copilot-token", + "the executor accepts only its run-bound Copilot credential issuer, never a raw token store"); } [Fact] @@ -563,11 +570,8 @@ public async Task LaunchAgentHostPod_configure_uses_project_selected_linked_iden await executor.LaunchAgentHostPodAsync(runId); - scopeProvider.UserId.Should().Be(userId); - scopeProvider.ProjectId.Should().Be(projectId); - accessTokenProvider.LastScope.Should().BeEquivalentTo(selectedScope); using var doc = JsonDocument.Parse(configureHandler.Body!); - doc.RootElement.GetProperty("gitHubAccessToken").GetString().Should().Be("tok-bob"); + doc.RootElement.GetProperty("copilotAccessToken").GetString().Should().Be("test-copilot-token"); } [Fact] @@ -598,12 +602,9 @@ public async Task LaunchAgentHostPod_configure_uses_active_linked_scope_for_secr await executor.LaunchAgentHostPodAsync(runId); - accessTokenProvider.LastValidScope.Should().Be(effectiveScope, - "the pre-resolved token and Key Vault secret must target the same active linked identity"); using var doc = JsonDocument.Parse(configureHandler.Body!); - doc.RootElement.GetProperty("gitHubAccessToken").GetString().Should().Be("fresh-linked-token"); - doc.RootElement.GetProperty("kvUserSecretName").GetString().Should() - .Be(KeyVaultSecretStore.SanitizeKey(effectiveScope.Key)); + doc.RootElement.GetProperty("copilotAccessToken").GetString().Should().Be("test-copilot-token"); + doc.RootElement.TryGetProperty("kvUserSecretName", out _).Should().BeFalse(); } @@ -639,11 +640,9 @@ public async Task LaunchAgentHostPod_configure_unauthorized_refreshes_once_and_r var act = () => executor.LaunchAgentHostPodAsync(runId); var exception = await act.Should().ThrowAsync(); - exception.Which.Reason.Should().Be("agenthost_configure_copilot_token_refreshed"); - exception.Which.Retryable.Should().BeTrue(); - exception.Which.RecoveryAction.Should().Be("recreate_pod_with_refreshed_credential"); - accessTokenProvider.LastRejectedScope.Should().Be(effectiveScope); - accessTokenProvider.RejectedToken.Should().Be("rejected-token"); + exception.Which.Reason.Should().Be("agenthost_configure_copilot_unauthorized"); + exception.Which.Retryable.Should().BeFalse(); + exception.Which.RecoveryAction.Should().BeNull(); handler.Requests.Should().Contain(r => r.Method == "DELETE" && r.Path.EndsWith($"/sandboxclaims/{claimName}"), "the one-time-configured pod must be discarded before the refreshed credential is retried"); @@ -678,7 +677,7 @@ public async Task LaunchAgentHostPod_configure_falls_back_to_raw_token_store_wit await executor.LaunchAgentHostPodAsync(runId); using var doc = JsonDocument.Parse(configureHandler.Body!); - doc.RootElement.GetProperty("gitHubAccessToken").GetString().Should().Be("raw-store-token"); + doc.RootElement.GetProperty("copilotAccessToken").GetString().Should().Be("test-copilot-token"); } [Fact] @@ -706,8 +705,8 @@ public async Task LaunchAgentHostPod_configure_does_not_fallback_to_raw_token_wh await executor.LaunchAgentHostPodAsync(runId); using var doc = JsonDocument.Parse(configureHandler.Body!); - doc.RootElement.GetProperty("gitHubAccessToken").ValueKind.Should().Be(JsonValueKind.Null, - "a refresh failure must not weaken auth by forwarding the stale raw token"); + doc.RootElement.GetProperty("copilotAccessToken").GetString().Should().Be("test-copilot-token", + "the raw-token fallback is removed"); } [Fact] @@ -920,7 +919,8 @@ public async Task LaunchAgentHostPod_retries_transient_reset_on_claim_create_the ClientFor(fault, fake), Options(), NullLogger.Instance, podRegistry: null, turnTokenRegistry: turnTokens, readinessProbe: null, submittingUserResolver: new StubSubmittingUserResolver("sabbour"), - httpClientFactory: new StubHttpClientFactory(configureHandler)); + httpClientFactory: new StubHttpClientFactory(configureHandler), + copilotCredentialIssuer: new StubRunBoundCopilotCredentialIssuer("test-copilot-token")); var endpoint = await executor.LaunchAgentHostPodAsync(runId); @@ -957,7 +957,8 @@ public async Task LaunchAgentHostPod_treats_409_after_own_create_reset_as_create ClientFor(fault, fake), Options(), NullLogger.Instance, podRegistry: null, turnTokenRegistry: turnTokens, readinessProbe: null, submittingUserResolver: new StubSubmittingUserResolver("sabbour"), - httpClientFactory: new StubHttpClientFactory(configureHandler)); + httpClientFactory: new StubHttpClientFactory(configureHandler), + copilotCredentialIssuer: new StubRunBoundCopilotCredentialIssuer("test-copilot-token")); var endpoint = await executor.LaunchAgentHostPodAsync(runId); diff --git a/tests/Agentweaver.Tests/Preview/PreviewRunnerAuthAndScrubTests.cs b/tests/Agentweaver.Tests/Preview/PreviewRunnerAuthAndScrubTests.cs index e3522341b..297ccff37 100644 --- a/tests/Agentweaver.Tests/Preview/PreviewRunnerAuthAndScrubTests.cs +++ b/tests/Agentweaver.Tests/Preview/PreviewRunnerAuthAndScrubTests.cs @@ -28,7 +28,7 @@ public sealed class PreviewRunnerAuthAndScrubTests private static AgentHostRuntimeState Configured(string? turn, string? credential) { var state = new AgentHostRuntimeState(); - state.TryConfigure("run-1", "user-1", turn ?? string.Empty, null, null, credential); + state.TryConfigure("run-1", "user-1", turn ?? string.Empty, null, credential); return state; } diff --git a/tests/Agentweaver.Tests/packages.lock.json b/tests/Agentweaver.Tests/packages.lock.json index cf600542a..bdcc0f870 100644 --- a/tests/Agentweaver.Tests/packages.lock.json +++ b/tests/Agentweaver.Tests/packages.lock.json @@ -1393,9 +1393,7 @@ "dependencies": { "Agentweaver.AgentRuntime": "[1.0.0, )", "Agentweaver.Domain": "[1.0.0, )", - "Azure.Identity": "[1.21.0, )", "Azure.Monitor.OpenTelemetry.Exporter": "[1.8.3, )", - "Azure.Security.KeyVault.Secrets": "[4.11.0, )", "Microsoft.Agents.AI.Hosting.A2A": "[1.11.1-preview.260625.1, )", "Microsoft.Agents.AI.Hosting.A2A.AspNetCore": "[1.11.1-preview.260625.1, )", "Microsoft.Extensions.AI": "[10.9.0, )",