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.

@@ -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, )",