diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
index cda2402d9..9bd59723f 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
@@ -11,6 +11,7 @@
using CodeSpace.Core.Services.Agents.Cost;
using CodeSpace.Core.Services.Agents.Mcp;
using CodeSpace.Core.Services.Agents.Publish;
+using CodeSpace.Core.Services.Redaction;
using CodeSpace.Core.Services.Review;
using CodeSpace.Core.Services.Supervisor;
using CodeSpace.Core.Services.Workflows.Artifacts;
@@ -4229,13 +4230,8 @@ private sealed record ModelCredentialProjection(
public ResolvedModelCredential? Credential { get; init; }
}
- ///
- /// The shortest value usable as a redaction needle. Below this a value is a fragment, not an identifier —
- /// striking it would shred unrelated text (an injected 1 would hit every line carrying a digit), so it is
- /// left readable. The SAME threshold the real-model artifact redactor applies (MIN_NEEDLE_LENGTH in
- /// .github/scripts/collect-real-model-verdicts.sh); no real api key or access token is shorter.
- ///
- internal const int MinimumNeedleLength = 8;
+ /// The shortest value usable as a redaction needle — the shared , so the run's redactor and every other echo scrub agree on what counts as a fragment.
+ internal const int MinimumNeedleLength = CredentialNeedles.MinimumLength;
///
/// The most needles will hand back. is
@@ -4254,28 +4250,8 @@ private sealed record ModelCredentialProjection(
///
internal const int MaximumNeedles = 64;
- ///
- /// Name fragments that mark an injected value a SECRET — applied to an entry's
- /// variable name and to a base URL's query-parameter name alike. Both are opaque name/value string pairs with no
- /// per-entry secret flag for the executor to read, so the name is the only signal available — and it is the signal
- /// that separates the token from the values beside it that must stay READABLE: the gateway base URL, the model
- /// tier pins, an api-version stamp. Those are what an operator reads the error FOR, and masking a low-entropy
- /// one would shred every unrelated line that happens to carry it (an api-version date also matches every
- /// timestamp of that day).
- ///
- /// Substring matching over a deliberately WIDE list: a marker earns its place by naming a carrier that
- /// authenticates something (PASSPHRASE on a signing key, PWD on MYSQL_PWD, DSN and
- /// CONNECTION_STRING on a database URL with its password inline, COOKIE on a session, WEBHOOK
- /// on a URL whose path IS the capability). The known cost is a false-positive class — KEY_ID,
- /// TOKEN_LIMIT, an injected PWD — whose values are masked though they are not secret. That is the
- /// side to err on: a masked path is a garbled line an operator can work around, an unmasked token is a leak that
- /// the append-only log can never take back.
- ///
- private static readonly string[] SecretNameMarkers =
- ["KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "PASSPHRASE", "PWD", "CREDENTIAL", "AUTH", "COOKIE", "DSN", "CONNECTION_STRING", "CONNSTR", "PRIVATE", "WEBHOOK"];
-
- /// Whether a name — an env variable's or a URL query parameter's — marks its value a secret.
- private static bool MarksASecret(string name) => SecretNameMarkers.Any(marker => name.Contains(marker, StringComparison.OrdinalIgnoreCase));
+ /// Whether a name — an env variable's or a URL query parameter's — marks its value a secret ().
+ private static bool MarksASecret(string name) => CredentialNeedles.MarksASecret(name);
///
/// The run's redactor, built from EVERY secret this launch injects into the child process — not the model key
@@ -4359,39 +4335,8 @@ private static SecretRedactor WithMcpRunToken(SecretRedactor redactor, string? m
internal static SecretRedactor WithModelBrokerRunToken(SecretRedactor redactor, string? brokerRunToken) =>
brokerRunToken is { Length: >= MinimumNeedleLength } token ? redactor.With([token]) : redactor;
- ///
- /// The credential parts embedded IN a base URL — every userinfo segment, and each query value whose parameter
- /// name marks it a secret — in both the raw and the percent-decoded spelling,
- /// since a CLI may echo either. Userinfo is credential material by construction, so all of it is a needle; a
- /// query string is not, so it is filtered by name. The URL itself is never a needle: its host is the fact that
- /// says WHICH endpoint answered, which is the point of surfacing the error at all.
- ///
- /// ACCEPTED RESIDUAL: a gateway that carries its key in the URL PATH (https://gw.example/v1/<key>/chat)
- /// is not covered. A path segment has no name to read, so the only rules available are "mask every segment" —
- /// which strikes the route an operator reads the error for, and every /v1/ beside it — or a shape guess,
- /// which is a fresh leak the moment a gateway picks a shape it doesn't match. Userinfo and a marked query
- /// parameter are self-declaring; a path segment is not, so it stays out until a credential can say so itself.
- ///
- private static IEnumerable UrlEmbeddedSecrets(string? baseUrl)
- {
- if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var parsed)) yield break;
-
- foreach (var part in parsed.UserInfo.Split(':', StringSplitOptions.RemoveEmptyEntries))
- {
- yield return part;
- yield return Uri.UnescapeDataString(part);
- }
-
- foreach (var pair in parsed.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
- {
- var separator = pair.IndexOf('=');
-
- if (separator < 0 || !MarksASecret(pair[..separator])) continue;
-
- yield return pair[(separator + 1)..];
- yield return Uri.UnescapeDataString(pair[(separator + 1)..]);
- }
- }
+ /// The credential parts embedded IN a base URL — userinfo and secret-named query values ().
+ private static IEnumerable UrlEmbeddedSecrets(string? baseUrl) => CredentialNeedles.FromUrl(baseUrl);
/// Re-persist the run's stored task with its RESOLVED model filled, so the live projection shows what an "auto" run actually dispatches from the moment it starts (mirrors the harness-reconciliation write). The task is the ORIGINAL (no injected secret env) with only set — but it is the OPENED task, so it is re-sealed exactly as the store first persisted it: written back raw, the workflow secrets it embeds would land in task_jsonb again.
private Task PersistResolvedModelAsync(AgentRunOwnerToken owner, AgentTask taskWithModel, CancellationToken cancellationToken) => PersistRuntimeIdentityAsync(owner, null, AgentTaskSecrets.Seal(taskWithModel, _sealer), cancellationToken);
diff --git a/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/ModelCredentialService.cs b/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/ModelCredentialService.cs
index 398298247..a53306b0e 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/ModelCredentialService.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/ModelCredentialService.cs
@@ -4,10 +4,13 @@
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Core.Services.Identity;
+using CodeSpace.Core.Services.Redaction;
using CodeSpace.Messages.Agents;
using CodeSpace.Messages.Dtos.ModelCredentials;
using CodeSpace.Messages.Enums;
+using CodeSpace.Messages.Exceptions;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
namespace CodeSpace.Core.Services.Agents.ModelCredentials;
@@ -15,15 +18,19 @@ public sealed class ModelCredentialService : IModelCredentialService, IScopedDep
{
private readonly CodeSpaceDbContext _db;
private readonly ICurrentTeam _currentTeam;
+ private readonly ICurrentUser _currentUser;
private readonly IPayloadEncryptor _encryptor;
private readonly IModelReflector _reflector;
+ private readonly ILogger _logger;
- public ModelCredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, IPayloadEncryptor encryptor, IModelReflector reflector)
+ public ModelCredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, IPayloadEncryptor encryptor, IModelReflector reflector, ILogger logger)
{
_db = db;
_currentTeam = currentTeam;
+ _currentUser = currentUser;
_encryptor = encryptor;
_reflector = reflector;
+ _logger = logger;
}
public async Task> ListAsync(string? provider, CancellationToken cancellationToken)
@@ -64,9 +71,13 @@ public async Task AddAsync(string provider, string displayName, string? ap
public async Task UpdateAsync(Guid id, string displayName, string? apiKey, string? baseUrl, CancellationToken cancellationToken)
{
var credential = await LoadActiveAsync(id, cancellationToken).ConfigureAwait(false);
+ var nextBaseUrl = SubmittedBaseUrl(credential.BaseUrl, baseUrl);
+
+ EnsureKeyFollowsEndpoint(credential, nextBaseUrl, apiKey);
+ LogEndpointMove(credential, nextBaseUrl);
credential.DisplayName = displayName;
- credential.BaseUrl = NullIfBlank(baseUrl);
+ credential.BaseUrl = nextBaseUrl;
// Write-only secret: a blank key keeps the existing one; a value rotates it.
if (!string.IsNullOrWhiteSpace(apiKey)) credential.EncryptedApiKey = _encryptor.Encrypt(apiKey);
@@ -76,6 +87,36 @@ public async Task UpdateAsync(Guid id, string displayName, string? apiKey,
return credential.Id;
}
+ ///
+ /// The base URL an update stores. The list shows it MASKED (), so an edit form
+ /// that sends back exactly what it was shown means "unchanged" — the stored URL, with its real embedded credentials,
+ /// is kept; a masked placeholder left in any other submitted URL is refused ().
+ ///
+ internal static string? SubmittedBaseUrl(string? stored, string? submitted) => CredentialUrl.KeepStoredIfMasked(stored, NullIfBlank(submitted));
+
+ ///
+ /// Whether an update must carry the key again: it moves the endpoint (scheme, host or port — where a key is sent),
+ /// a key is stored, and none is supplied. Without this, anyone allowed to edit the credential could repoint it at a
+ /// host they control and have the next reflection, probe or run present the write-only key there. A path-only change,
+ /// a keyless credential, or an update that supplies the key moves nothing it shouldn't.
+ ///
+ internal static bool KeyMustBeReentered(bool hasStoredKey, string? storedBaseUrl, string? nextBaseUrl, string? apiKey) =>
+ hasStoredKey && string.IsNullOrWhiteSpace(apiKey) && !CredentialUrl.SameEndpoint(storedBaseUrl, nextBaseUrl);
+
+ private static void EnsureKeyFollowsEndpoint(ModelCredential credential, string? nextBaseUrl, string? apiKey)
+ {
+ if (KeyMustBeReentered(!string.IsNullOrEmpty(credential.EncryptedApiKey), credential.BaseUrl, nextBaseUrl, apiKey))
+ throw new ModelEndpointKeyRequiredException(credential.Id);
+ }
+
+ /// One audit line per endpoint move, naming only scheme://host:port on each side () — never the embedded credentials, path or query.
+ private void LogEndpointMove(ModelCredential credential, string? nextBaseUrl)
+ {
+ if (CredentialUrl.SameEndpoint(credential.BaseUrl, nextBaseUrl)) return;
+
+ _logger.LogInformation("Model credential {CredentialId} of team {TeamId} moved its endpoint from {PreviousEndpoint} to {Endpoint}, by user {UserId}", credential.Id, credential.TeamId, CredentialUrl.Endpoint(credential.BaseUrl), CredentialUrl.Endpoint(nextBaseUrl), _currentUser.Id);
+ }
+
public async Task RevokeAsync(Guid id, CancellationToken cancellationToken)
{
var teamId = _currentTeam.Id!.Value;
@@ -324,7 +365,9 @@ private ModelCredentialSummary ToSummary(ModelCredential c)
// null (a non-blank secret is never stored). Surface it so the UI prompts re-entry instead of showing a
// dead key as the benign "no key".
KeyUnreadable = !string.IsNullOrEmpty(c.EncryptedApiKey) && keyHint is null,
- BaseUrl = c.BaseUrl,
+ // Masked: userinfo and secret-named query values are credentials, and the list reaches every member.
+ // Update reads this exact string sent back as "keep the stored URL".
+ BaseUrl = CredentialUrl.Masked(c.BaseUrl),
Status = c.Status,
CreatedDate = c.CreatedDate,
};
diff --git a/backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceService.cs b/backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceService.cs
index 04bbc05f8..c0eabf03b 100644
--- a/backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceService.cs
+++ b/backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceService.cs
@@ -4,13 +4,16 @@
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Core.Middlewares.Transactional;
+using CodeSpace.Core.Services.Redaction;
using CodeSpace.Core.Services.RepositoryBinding;
using CodeSpace.Core.Services.Webhooks.Scope;
using CodeSpace.Messages.Commands.ProviderInstances;
using CodeSpace.Messages.Dtos.ProviderInstances;
using CodeSpace.Messages.Enums;
+using CodeSpace.Messages.Exceptions;
using CodeSpace.Messages.Queries.ProviderInstances;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
namespace CodeSpace.Core.Services.ProviderInstances;
@@ -18,21 +21,25 @@ public sealed class ProviderInstanceService : IProviderInstanceService, IScopedD
{
private readonly CodeSpaceDbContext _db;
private readonly ICurrentTeam _currentTeam;
+ private readonly ICurrentUser _currentUser;
private readonly IPayloadEncryptor _encryptor;
private readonly IRepositoryBindingService _binding;
private readonly IWebhookScopeTransitionService _webhookScope;
private readonly IPostCommitActions _postCommit;
private readonly TimeProvider _clock;
+ private readonly ILogger _logger;
- public ProviderInstanceService(CodeSpaceDbContext db, ICurrentTeam currentTeam, IPayloadEncryptor encryptor, IRepositoryBindingService binding, IWebhookScopeTransitionService webhookScope, IPostCommitActions postCommit, TimeProvider clock)
+ public ProviderInstanceService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, IPayloadEncryptor encryptor, IRepositoryBindingService binding, IWebhookScopeTransitionService webhookScope, IPostCommitActions postCommit, TimeProvider clock, ILogger logger)
{
_db = db;
_currentTeam = currentTeam;
+ _currentUser = currentUser;
_encryptor = encryptor;
_binding = binding;
_webhookScope = webhookScope;
_postCommit = postCommit;
_clock = clock;
+ _logger = logger;
}
public async Task> ListAsync(CancellationToken cancellationToken)
@@ -48,9 +55,11 @@ public async Task> ListAsync(Cancellation
TeamId = p.TeamId,
Provider = p.Provider,
DisplayName = p.DisplayName,
- BaseUrl = p.BaseUrl,
- ApiUrl = p.ApiUrl,
- WebUrl = p.WebUrl,
+ // Masked: userinfo and secret-named query values are credentials, and this list reaches every member.
+ // Update reads each exact string sent back as "keep the stored URL".
+ BaseUrl = CredentialUrl.Masked(p.BaseUrl)!,
+ ApiUrl = CredentialUrl.Masked(p.ApiUrl),
+ WebUrl = CredentialUrl.Masked(p.WebUrl),
CreatedDate = p.CreatedDate,
OauthEnabled = p.OauthClientId != null && p.OauthClientSecretEnc != null
})
@@ -112,24 +121,94 @@ public async Task UpdateAsync(UpdateProviderInstanceCommand request, Cancellatio
var teamId = _currentTeam.Id!.Value;
var instance = await LoadOwnedInstanceAsync(request.ProviderInstanceId, teamId, cancellationToken).ConfigureAwait(false);
+ var edits = WithStoredUrlsKept(instance, request);
// Compute the post-edit (base_url, client_id) tuple — needed for the uniqueness
// check. Only run the check when at least one of those keys is actually changing,
// because the no-op case would self-collide on the row's own values.
- var newBaseUrl = request.BaseUrl != null ? NormaliseBaseUrl(request.BaseUrl) : instance.BaseUrl;
- var newClientId = request.OauthClientId != null ? NormaliseClientId(request.OauthClientId) : instance.OauthClientId;
+ var newBaseUrl = NextBaseUrl(instance, edits);
+ var newClientId = edits.OauthClientId != null ? NormaliseClientId(edits.OauthClientId) : instance.OauthClientId;
var baseUrlChanged = !string.Equals(newBaseUrl, instance.BaseUrl, StringComparison.Ordinal);
var clientIdChanged = !string.Equals(newClientId, instance.OauthClientId, StringComparison.Ordinal);
if (baseUrlChanged || clientIdChanged) await EnsureUniquenessKeyAvailableAsync(instance, newBaseUrl, newClientId, cancellationToken).ConfigureAwait(false);
+ await EnsureSecretsStayWithTheirEndpointAsync(instance, edits, cancellationToken).ConfigureAwait(false);
+ LogEndpointMove(instance, edits);
+
var previousScope = instance.WebhookScope;
- ApplyEdits(instance, request);
+ ApplyEdits(instance, edits);
await ApplyWebhookScopeChangeAsync(instance, previousScope, cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// The edit with each URL the list shows MASKED () read back as "unchanged" when it
+ /// is sent back exactly as shown, so an edit form or API client that PATCHes back what it GETs keeps the stored URL
+ /// and its real embedded credentials; a placeholder left in an otherwise edited URL is refused. Resolved before the
+ /// uniqueness check, so a masked echo of the row's own URL never reads as a different one.
+ ///
+ private static UpdateProviderInstanceCommand WithStoredUrlsKept(ProviderInstance instance, UpdateProviderInstanceCommand request) => request with
+ {
+ BaseUrl = request.BaseUrl == null ? null : CredentialUrl.KeepStoredIfMasked(instance.BaseUrl, NormaliseBaseUrl(request.BaseUrl)),
+ ApiUrl = request.ApiUrl == null ? null : CredentialUrl.KeepStoredIfMasked(instance.ApiUrl, request.ApiUrl),
+ WebUrl = request.WebUrl == null ? null : CredentialUrl.KeepStoredIfMasked(instance.WebUrl, request.WebUrl)
+ };
+
+ ///
+ /// Whether an edit moves where this connection presents its secrets: the API every provider call goes to
+ /// (ApiUrl, or the base URL when unset) or the base URL the OAuth token, authorize and revoke endpoints are
+ /// derived from. A move is a change of scheme, host or port (); a path, a
+ /// trailing slash or a credential slot is not.
+ ///
+ internal static bool EndpointMoves(string storedBaseUrl, string? storedApiUrl, string nextBaseUrl, string? nextApiUrl) =>
+ !CredentialUrl.SameEndpoint(storedBaseUrl, nextBaseUrl) || !CredentialUrl.SameEndpoint(ApiOf(storedBaseUrl, storedApiUrl), ApiOf(nextBaseUrl, nextApiUrl));
+
+ ///
+ /// Refuse a repoint the connection's secrets would follow. Its connected tokens are write-only and belong to the
+ /// members who connected them, so an editor who may change the URL could otherwise point it at a host they control
+ /// and have the next repository read, git node, webhook registration or OAuth refresh present every one of them
+ /// there — they are disconnected first instead. The stored OAuth client secret is the editor's to re-enter, as a
+ /// model key is. A connection with nothing bound moves freely.
+ ///
+ private async Task EnsureSecretsStayWithTheirEndpointAsync(ProviderInstance instance, UpdateProviderInstanceCommand edits, CancellationToken cancellationToken)
+ {
+ if (!EndpointMoves(instance.BaseUrl, instance.ApiUrl, NextBaseUrl(instance, edits), NextApiUrl(instance, edits))) return;
+
+ var bound = await CountPresentableCredentialsAsync(instance.Id, cancellationToken).ConfigureAwait(false);
+
+ if (bound > 0)
+ throw new ProviderEndpointCredentialsBoundException(instance.Id, bound);
+
+ if (instance.OauthClientSecretEnc != null && string.IsNullOrEmpty(edits.OauthClientSecret))
+ throw new ProviderEndpointSecretRequiredException(instance.Id);
+ }
+
+ /// Credentials that would still be presented: connected through this instance and not disconnected (a disconnect revokes the row and blanks its token).
+ private Task CountPresentableCredentialsAsync(Guid providerInstanceId, CancellationToken cancellationToken) =>
+ _db.Credential.AsNoTracking().CountAsync(c => c.ProviderInstanceId == providerInstanceId && c.DeletedDate == null && c.Status != CredentialStatus.Revoked, cancellationToken);
+
+ /// One audit line per endpoint move, naming only scheme://host:port on each side () — never embedded credentials, path or query.
+ private void LogEndpointMove(ProviderInstance instance, UpdateProviderInstanceCommand edits)
+ {
+ var nextBaseUrl = NextBaseUrl(instance, edits);
+ var nextApiUrl = NextApiUrl(instance, edits);
+
+ if (!EndpointMoves(instance.BaseUrl, instance.ApiUrl, nextBaseUrl, nextApiUrl)) return;
+
+ _logger.LogInformation("Provider instance {ProviderInstanceId} of team {TeamId} moved its endpoint from {PreviousEndpoint} (API {PreviousApiEndpoint}) to {Endpoint} (API {ApiEndpoint}), by user {UserId}", instance.Id, instance.TeamId, CredentialUrl.Endpoint(instance.BaseUrl), CredentialUrl.Endpoint(ApiOf(instance.BaseUrl, instance.ApiUrl)), CredentialUrl.Endpoint(nextBaseUrl), CredentialUrl.Endpoint(ApiOf(nextBaseUrl, nextApiUrl)), _currentUser.Id);
+ }
+
+ private static string NextBaseUrl(ProviderInstance instance, UpdateProviderInstanceCommand edits) => edits.BaseUrl != null ? NormaliseBaseUrl(edits.BaseUrl) : instance.BaseUrl;
+
+ private static string? NextApiUrl(ProviderInstance instance, UpdateProviderInstanceCommand edits) => edits.ApiUrl == null ? instance.ApiUrl : NullIfBlank(edits.ApiUrl);
+
+ /// Where provider calls go: the API URL when one is set, the base URL otherwise — the fallback every provider reads.
+ private static string ApiOf(string baseUrl, string? apiUrl) => string.IsNullOrWhiteSpace(apiUrl) ? baseUrl : apiUrl;
+
+ private static string? NullIfBlank(string value) => string.IsNullOrWhiteSpace(value) ? null : value;
+
///
/// Deferred to after the edit commits, for the reason a bind defers its registration: the
/// transition reads the new scope off the row and dispatches registrations against it, and a
diff --git a/backend/src/CodeSpace.Core/Services/Providers/Auth/PresentedProviderCredentials.cs b/backend/src/CodeSpace.Core/Services/Providers/Auth/PresentedProviderCredentials.cs
new file mode 100644
index 000000000..411e0a8d9
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Providers/Auth/PresentedProviderCredentials.cs
@@ -0,0 +1,32 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Agents;
+using CodeSpace.Core.Services.Redaction;
+
+namespace CodeSpace.Core.Services.Providers.Auth;
+
+///
+/// The tokens a provider instance has been handed to present, so the SDK-error translation in
+/// can strike an upstream's echo of them. A provider call resolves its
+/// token through and then runs under the resilience layer with the SAME
+/// object (context.Instance); the resilience layer is a singleton that never sees
+/// the credential, so the token travels beside that object instead.
+///
+/// Keyed by the entity OBJECT, held weakly: the needles live exactly as long as the scope that loaded the
+/// instance, never in a process-wide list, and a second scope's instance object starts empty. A scope that resolves two
+/// credentials of one instance strikes both — over-striking an error line is the safe side.
+///
+internal static class PresentedProviderCredentials
+{
+ private static readonly ConditionalWeakTable> Presented = new();
+
+ /// Remember that was handed to present (ignored when too short to be a needle).
+ public static void Remember(ProviderInstance instance, string? token)
+ {
+ foreach (var needle in CredentialNeedles.Usable([token])) Presented.GetOrCreateValue(instance).TryAdd(needle, 0);
+ }
+
+ /// A redactor over every token was handed; when it was handed none.
+ public static SecretRedactor RedactorFor(ProviderInstance instance) => Presented.TryGetValue(instance, out var needles) ? CredentialNeedles.Redactor(needles.Keys) : SecretRedactor.None;
+}
diff --git a/backend/src/CodeSpace.Core/Services/Providers/Auth/ProviderAuthResolver.cs b/backend/src/CodeSpace.Core/Services/Providers/Auth/ProviderAuthResolver.cs
index 005e04ca5..fe036f026 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/Auth/ProviderAuthResolver.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/Auth/ProviderAuthResolver.cs
@@ -15,6 +15,12 @@ public async Task ResolveAsync(ProviderContext context, Cancellati
if (!_strategies.TryGetValue(key, out var strategy)) throw new NotSupportedException($"No auth strategy registered for ({key.Provider}, {key.AuthType})");
- return await strategy.ResolveAsync(context, cancellationToken).ConfigureAwait(false);
+ var auth = await strategy.ResolveAsync(context, cancellationToken).ConfigureAwait(false);
+
+ // Every token handed out here is one the provider may echo back in an error — remember it beside the instance
+ // so the resilience layer's SDK-error translation can strike it before a caller is shown the failure.
+ PresentedProviderCredentials.Remember(context.Instance, auth.Token);
+
+ return auth;
}
}
diff --git a/backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.ConnectionWebhooks.cs b/backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.ConnectionWebhooks.cs
index 88ddc3d0c..4988edca3 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.ConnectionWebhooks.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.ConnectionWebhooks.cs
@@ -33,7 +33,7 @@ public sealed partial class GitHubRepositoryProvider : IConnectionWebhookRegistr
{
// Failing to LIST fails the registration exactly as surely as failing to create, and for
// the same reasons — so the operator gets the same evidence from either step.
- throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureOrgHookRequest("GET", baseAddress, ownerPath, token, null)), ex);
+ throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureOrgHookRequest("GET", baseAddress, ownerPath, token, null), token), ex);
}
}
@@ -59,7 +59,7 @@ public async Task RegisterConnectionWebhookAsync(ProviderContext
catch (Exception ex)
{
var body = JsonSerializer.Serialize(new { name = "web", config, events = GitHubHookEvents.All, active = true });
- throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureOrgHookRequest("POST", baseAddress, ownerPath, token, body)), ex);
+ throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureOrgHookRequest("POST", baseAddress, ownerPath, token, body), token), ex);
}
}
diff --git a/backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs b/backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs
index d7cf0c17c..40b2cad41 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs
@@ -7,6 +7,7 @@
using CodeSpace.Core.Services.Providers.Markdown;
using CodeSpace.Core.Services.Providers.Resilience;
using CodeSpace.Core.Services.Providers.Source;
+using CodeSpace.Core.Services.Redaction;
using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Events;
using CodeSpace.Messages.Exceptions;
@@ -848,7 +849,7 @@ internal static RepositoryRole MapPermissions(bool admin, bool maintain, bool pu
// Failing to LIST hooks fails the registration exactly as surely as failing to create
// one, and for the same reasons (revoked token, no permission, instance down) — so the
// operator gets the same evidence from either step.
- throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureHookRequest("GET", baseAddress, repositoryId, token, null)), ex);
+ throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureHookRequest("GET", baseAddress, repositoryId, token, null), token), ex);
}
}
@@ -892,7 +893,7 @@ public async Task RegisterWebhookAsync(ProviderContext context, R
catch (Exception ex)
{
var body = JsonSerializer.Serialize(new { name = "web", config, events = GitHubHookEvents.All, active = true });
- throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureHookRequest("POST", baseAddress, repositoryId, token, body)), ex);
+ throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureHookRequest("POST", baseAddress, repositoryId, token, body), token), ex);
}
}
@@ -959,15 +960,19 @@ private static CapturedProviderRequest CaptureHookRequest(string method, Uri bas
/// is rarely the one we caught — the resilience layer translates it and the scope mapper replaces
/// it — so we look down the chain for the shape that still knows what GitHub said. Finding
/// nothing is itself informative: no status means the call never got an answer.
+ ///
+ /// The body is Octokit's raw copy of GitHub's answer — the resilience translation strikes only the message —
+ /// so the the request presented is struck from it here: the attempt row is readable by any
+ /// member of the team, and a GitHub (or a proxy) that quotes the credential it rejected would hand it to all of them.
///
- private static ProviderCallDiagnostic DescribeHookFailure(Exception exception, CapturedProviderRequest request)
+ private static ProviderCallDiagnostic DescribeHookFailure(Exception exception, CapturedProviderRequest request, string token)
{
var api = ProviderCallCapture.FindInChain(exception);
return new ProviderCallDiagnostic
{
StatusCode = api == null ? null : (int)api.StatusCode,
- ResponseBody = ProviderCallCapture.Clamp(api?.HttpResponse?.Body as string),
+ ResponseBody = ProviderCallCapture.Clamp(CredentialNeedles.Strike(api?.HttpResponse?.Body as string, [token])),
Request = request
};
}
diff --git a/backend/src/CodeSpace.Core/Services/Providers/GitLab/GitLabRepositoryProvider.ConnectionWebhooks.cs b/backend/src/CodeSpace.Core/Services/Providers/GitLab/GitLabRepositoryProvider.ConnectionWebhooks.cs
index d2a0f50fa..808fcb232 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/GitLab/GitLabRepositoryProvider.ConnectionWebhooks.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/GitLab/GitLabRepositoryProvider.ConnectionWebhooks.cs
@@ -4,6 +4,7 @@
using CodeSpace.Core.Services.Providers.Capabilities;
using CodeSpace.Core.Services.Providers.Diagnostics;
using CodeSpace.Core.Services.Providers.Resilience;
+using CodeSpace.Core.Services.Redaction;
using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Enums;
using CodeSpace.Messages.Exceptions;
@@ -132,6 +133,11 @@ private static async Task> ListGroupHooksAsync(string url,
private static int? NextPageAfter(int current, string? nextPage) =>
int.TryParse(nextPage, out var next) && next > current ? next : null;
+ ///
+ /// One raw call to the group-hook endpoint. A refusal's body is the evidence
+ /// persists to the attempt row any member can read, so the this request presented is struck
+ /// from it before anything reads it; an accepted answer is read as sent.
+ ///
private static async Task SendGroupHookRequestAsync(HttpMethod method, string url, string token, string? payload, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, url);
@@ -144,7 +150,7 @@ private static async Task SendGroupHookRequestAsync(HttpMethod
using var response = await _countsHttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
- return new GroupHookAnswer(response.StatusCode, body, ReadNextPage(response));
+ return new GroupHookAnswer(response.StatusCode, response.IsSuccessStatusCode ? body : CredentialNeedles.Strike(body, [token]), ReadNextPage(response));
}
/// GitLab's X-Next-Page: the next page's number on a list, empty on its last page, absent on anything else.
diff --git a/backend/src/CodeSpace.Core/Services/Providers/GitLab/GitLabRepositoryProvider.cs b/backend/src/CodeSpace.Core/Services/Providers/GitLab/GitLabRepositoryProvider.cs
index 7c63d49c0..6651f3455 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/GitLab/GitLabRepositoryProvider.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/GitLab/GitLabRepositoryProvider.cs
@@ -6,6 +6,7 @@
using CodeSpace.Core.Services.Providers.Markdown;
using CodeSpace.Core.Services.Providers.Resilience;
using CodeSpace.Core.Services.Providers.Source;
+using CodeSpace.Core.Services.Redaction;
using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Enums;
using CodeSpace.Messages.Events;
@@ -1048,7 +1049,10 @@ private async Task UnapproveAsync(string host, string token, int projectId, int
using var response = await _countsHttpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
- var failure = ClassifyUnapproveResponse((int)response.StatusCode, responseBody);
+ // The body becomes the failure's detail and scope hint, which reach external_call.failed and the node's error —
+ // this raw call never passes the resilience translation that strikes an echo, so the token it presented is
+ // struck here, before classification (which reads only the status and the scope tag).
+ var failure = ClassifyUnapproveResponse((int)response.StatusCode, CredentialNeedles.Strike(responseBody, [token]));
if (failure != null) throw failure;
}
@@ -1348,7 +1352,7 @@ public async Task GetActorAccessAsync(ProviderContext con
// Failing to LIST hooks fails the registration exactly as surely as failing to create
// one, and for the same reasons (revoked token, no permission, instance down) — so the
// operator gets the same evidence from either step.
- throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureHookRequest("GET", host, projectId, token, null)), ex);
+ throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, CaptureHookRequest("GET", host, projectId, token, null), token), ex);
}
}
@@ -1400,9 +1404,14 @@ public async Task RegisterWebhookAsync(ProviderContext context, R
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
+ {
+ // The refusal's body becomes last_error and the attempt's error, which any member can read: strike the token it may echo.
+ var refusal = CredentialNeedles.Strike(body, [token]);
+
throw new ProviderWebhookRegistrationException(
- DescribeHookFailure(new HttpRequestException($"GitLab refused the hook with {(int)response.StatusCode}: {body}"), CaptureHookRequest("POST", host, projectId, token, payload)),
- new HttpRequestException($"{(int)response.StatusCode}: {body}"));
+ DescribeHookFailure(new HttpRequestException($"GitLab refused the hook with {(int)response.StatusCode}: {refusal}"), CaptureHookRequest("POST", host, projectId, token, payload), token),
+ new HttpRequestException($"{(int)response.StatusCode}: {refusal}"));
+ }
return new RemoteWebhook
{
@@ -1416,7 +1425,7 @@ public async Task RegisterWebhookAsync(ProviderContext context, R
catch (Exception ex)
{
var sent = CaptureHookRequest("POST", host, projectId, token, payload);
- throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, sent), ex);
+ throw new ProviderWebhookRegistrationException(DescribeHookFailure(ex, sent, token), ex);
}
}
@@ -1466,15 +1475,19 @@ private static CapturedProviderRequest CaptureHookRequest(string method, string
/// is rarely the one we caught — the resilience layer translates it and the scope mapper replaces
/// it — so we look down the chain for the shape that still knows what GitLab said. Finding
/// nothing is itself informative: no status means the call never got an answer.
+ ///
+ /// The body is the SDK's raw copy of GitLab's answer — the resilience translation strikes only the message —
+ /// so the the request presented is struck from it here: the attempt row is readable by any
+ /// member of the team, and a GitLab (or a proxy) that quotes the credential it rejected would hand it to all of them.
///
- private static ProviderCallDiagnostic DescribeHookFailure(Exception exception, CapturedProviderRequest request)
+ private static ProviderCallDiagnostic DescribeHookFailure(Exception exception, CapturedProviderRequest request, string token)
{
var gitLab = ProviderCallCapture.FindInChain(exception);
return new ProviderCallDiagnostic
{
StatusCode = gitLab == null ? null : (int)gitLab.StatusCode,
- ResponseBody = ProviderCallCapture.Clamp(ReadErrorBody(gitLab)),
+ ResponseBody = ProviderCallCapture.Clamp(CredentialNeedles.Strike(ReadErrorBody(gitLab), [token])),
Request = request
};
}
diff --git a/backend/src/CodeSpace.Core/Services/Providers/Resilience/ExternalCallResilience.cs b/backend/src/CodeSpace.Core/Services/Providers/Resilience/ExternalCallResilience.cs
index b42624792..8bb8bf0de 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/Resilience/ExternalCallResilience.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/Resilience/ExternalCallResilience.cs
@@ -3,6 +3,7 @@
using System.Threading.RateLimiting;
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Providers.Auth;
using CodeSpace.Core.Services.Providers.Errors;
using CodeSpace.Messages.Exceptions;
using Microsoft.Extensions.Logging;
@@ -114,9 +115,13 @@ private void TranslateAndThrowIfScopeIssue(ProviderInstance instance, string ope
if (typed == null) return;
_logger.LogWarning(ex, "Provider {Provider} returned insufficient_scope for '{Operation}' on instance {InstanceId}; missing scopes: {MissingScopes}", instance.Provider, operationName, instance.Id, string.Join(", ", typed.MissingScopes));
- throw typed;
+ throw WithClientSafeHint(instance, typed);
}
+ /// The scope failure with its provider hint — returned to the caller in the failure details — made client-safe. Classification already ran on the raw SDK failure, so the scopes it names are unchanged.
+ private static ProviderInsufficientScopeException WithClientSafeHint(ProviderInstance instance, ProviderInsufficientScopeException typed) =>
+ typed.ProviderHint is null ? typed : new ProviderInsufficientScopeException(typed.ProviderKind, typed.CapabilityName, typed.MissingScopes, typed.GrantedScopes, ClientSafe(instance, typed.ProviderHint), typed.InnerException);
+
///
/// Re-throws any SDK exception with a known HTTP status code as a typed
/// . The check is duck-typed (StatusCode property)
@@ -132,10 +137,31 @@ private void TranslateAndThrowIfProviderApi(ProviderInstance instance, string op
var status = ExtractStatusCode(ex);
if (status == null) return;
- _logger.LogWarning(ex, "Provider {Provider} returned HTTP {StatusCode} for '{Operation}' on instance {InstanceId}: {Message}", instance.Provider, status, operationName, instance.Id, ex.Message);
- throw new ProviderApiException(instance.Provider, status.Value, operationName, ex.Message, ex);
+ var providerMessage = ClientSafe(instance, ex.Message);
+
+ _logger.LogWarning(ex, "Provider {Provider} returned HTTP {StatusCode} for '{Operation}' on instance {InstanceId}: {Message}", instance.Provider, status, operationName, instance.Id, providerMessage);
+ throw new ProviderApiException(instance.Provider, status.Value, operationName, providerMessage, ex);
+ }
+
+ ///
+ /// A provider's error text made safe to show the caller: every token the instance was handed to present is struck
+ /// () — a provider or proxy that echoes the request's PRIVATE-TOKEN /
+ /// Authorization would otherwise hand it to every Viewer of a repository page and every reader of a git node's
+ /// error — and the SDK's trailing Original call: <method> <url> is dropped: it names the internal
+ /// request, and a query-string token rides in it. The SDK exception itself stays the inner exception, for the log.
+ ///
+ private static string ClientSafe(ProviderInstance instance, string providerMessage) => PresentedProviderCredentials.RedactorFor(instance).Redact(WithoutOriginalCall(providerMessage));
+
+ private static string WithoutOriginalCall(string providerMessage)
+ {
+ var at = providerMessage.IndexOf(OriginalCallMarker, StringComparison.Ordinal);
+
+ return at < 0 ? providerMessage : providerMessage[..at].TrimEnd();
}
+ /// How NGitLab (and any SDK copying it) appends the request it made to an error message.
+ private const string OriginalCallMarker = "Original call:";
+
///
/// Charge one attempt. A call's first attempt is charged to the calling agent run's share, then waits its turn for the
/// instance's token and may be refused (): nothing has been sent yet. A retry
diff --git a/backend/src/CodeSpace.Core/Services/Redaction/CredentialNeedles.cs b/backend/src/CodeSpace.Core/Services/Redaction/CredentialNeedles.cs
new file mode 100644
index 000000000..97c0cdd57
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Redaction/CredentialNeedles.cs
@@ -0,0 +1,127 @@
+using System.Diagnostics.CodeAnalysis;
+using CodeSpace.Core.Services.Agents;
+using CodeSpace.Messages.Agents;
+
+namespace CodeSpace.Core.Services.Redaction;
+
+///
+/// The values a credential PRESENTS to an upstream — the exact strings an upstream that echoes its input (a 401 body
+/// quoting the key it rejected, a proxy that reflects request headers) can hand back into text the platform persists or
+/// shows. One rule, shared by every place that has to strike such an echo: the agent run's redactor, the in-process
+/// model transport, and the git-provider error translation.
+///
+/// Needles are exact values, never shapes. A shape guess (sk-…, glpat-…) is a fresh leak the moment
+/// a gateway picks a shape it doesn't match; an exact value is struck wherever it appears, in whatever text.
+///
+public static class CredentialNeedles
+{
+ ///
+ /// The shortest value usable as a redaction needle. Below this a value is a fragment, not an identifier —
+ /// striking it would shred unrelated text (an injected 1 would hit every line carrying a digit), so it is
+ /// left readable. The SAME threshold the real-model artifact redactor applies (MIN_NEEDLE_LENGTH in
+ /// .github/scripts/collect-real-model-verdicts.sh); no real api key or access token is shorter.
+ ///
+ public const int MinimumLength = 8;
+
+ ///
+ /// Name fragments that mark a value a SECRET — applied to an injected environment variable's name, a URL query
+ /// parameter's name and a request header's name alike. All three are opaque name/value string pairs with no
+ /// per-entry secret flag to read, so the name is the only signal available — and it is the signal that separates
+ /// the token from the values beside it that must stay READABLE: the gateway base URL, the model tier pins, an
+ /// api-version stamp. Those are what an operator reads the error FOR, and masking a low-entropy one would
+ /// shred every unrelated line that happens to carry it (an api-version date also matches every timestamp of
+ /// that day).
+ ///
+ /// Substring matching over a deliberately WIDE list: a marker earns its place by naming a carrier that
+ /// authenticates something (PASSPHRASE on a signing key, PWD on MYSQL_PWD, DSN and
+ /// CONNECTION_STRING on a database URL with its password inline, COOKIE on a session, WEBHOOK
+ /// on a URL whose path IS the capability). The known cost is a false-positive class — KEY_ID,
+ /// TOKEN_LIMIT, an injected PWD — whose values are masked though they are not secret. That is the
+ /// side to err on: a masked path is a garbled line an operator can work around, an unmasked token is a leak that
+ /// the append-only log can never take back.
+ ///
+ private static readonly string[] SecretNameMarkers =
+ ["KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "PASSPHRASE", "PWD", "CREDENTIAL", "AUTH", "COOKIE", "DSN", "CONNECTION_STRING", "CONNSTR", "PRIVATE", "WEBHOOK"];
+
+ /// Whether a name — an env variable's, a URL query parameter's or a header's — marks its value a secret.
+ public static bool MarksASecret(string name) => SecretNameMarkers.Any(marker => name.Contains(marker, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// The credential parts embedded IN a URL — every userinfo segment, and each query value whose parameter name
+ /// marks it a secret — in both the raw and the percent-decoded spelling, since an
+ /// upstream may echo either. Userinfo is credential material by construction, so all of it is a needle; a query
+ /// string is not, so it is filtered by name. The URL itself is never a needle: its host is the fact that says WHICH
+ /// endpoint answered, which is the point of surfacing the error at all.
+ ///
+ /// ACCEPTED RESIDUAL: a gateway that carries its key in the URL PATH (https://gw.example/v1/<key>/chat)
+ /// is not covered. A path segment has no name to read, so the only rules available are "mask every segment" —
+ /// which strikes the route an operator reads the error for, and every /v1/ beside it — or a shape guess,
+ /// which is a fresh leak the moment a gateway picks a shape it doesn't match. Userinfo and a marked query
+ /// parameter are self-declaring; a path segment is not, so it stays out until a credential can say so itself.
+ ///
+ public static IEnumerable FromUrl(string? url)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed)) yield break;
+
+ foreach (var part in parsed.UserInfo.Split(':', StringSplitOptions.RemoveEmptyEntries))
+ {
+ yield return part;
+ yield return Uri.UnescapeDataString(part);
+ }
+
+ foreach (var pair in parsed.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var separator = pair.IndexOf('=');
+
+ if (separator < 0 || !MarksASecret(pair[..separator])) continue;
+
+ yield return pair[(separator + 1)..];
+ yield return Uri.UnescapeDataString(pair[(separator + 1)..]);
+ }
+ }
+
+ /// Everything a resolved model credential presents: its api key, and the credential parts of its base URL.
+ public static IEnumerable FromCredential(ResolvedModelCredential? credential)
+ {
+ if (credential?.ApiKey is { } apiKey) yield return apiKey;
+
+ foreach (var needle in FromUrl(credential?.BaseUrl)) yield return needle;
+ }
+
+ ///
+ /// Everything an outgoing request presents: each header whose name marks it a secret
+ /// (Authorization, x-api-key, api-key, PRIVATE-TOKEN) and the credential parts of its URL.
+ /// A scheme credential header value (Bearer sk-…) yields the credential alone: the scheme word is not a
+ /// secret, and striking the credential is what leaves an echoed Authorization=Bearer *** legible.
+ ///
+ public static IEnumerable FromRequest(HttpRequestMessage request)
+ {
+ foreach (var header in request.Headers.Where(header => MarksASecret(header.Key)))
+ {
+ foreach (var value in header.Value) yield return CredentialOf(value);
+ }
+
+ foreach (var needle in FromUrl(request.RequestUri?.OriginalString)) yield return needle;
+ }
+
+ /// The long enough to be one (), de-duplicated.
+ public static IReadOnlyList Usable(IEnumerable needles) => needles.OfType().Where(needle => needle.Length >= MinimumLength).Distinct(StringComparer.Ordinal).ToList();
+
+ /// A redactor over the ; when none is.
+ public static SecretRedactor Redactor(IEnumerable needles) => Usable(needles) is { Count: > 0 } usable ? new SecretRedactor(usable) : SecretRedactor.None;
+
+ ///
+ /// — an answer an upstream gave a request: an error body, a refusal message — with every
+ /// credential in struck wherever the upstream echoed it. For the call
+ /// sites that read a refusal's body THEMSELVES (a raw-HTTP provider call) and hold the token they sent; null stays null.
+ ///
+ [return: NotNullIfNotNull(nameof(text))]
+ public static string? Strike(string? text, IEnumerable presented) => text is null ? null : Redactor(presented).Redact(text);
+
+ private static string CredentialOf(string headerValue)
+ {
+ var separator = headerValue.IndexOf(' ');
+
+ return separator < 0 ? headerValue : headerValue[(separator + 1)..].Trim();
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Services/Redaction/CredentialUrl.cs b/backend/src/CodeSpace.Core/Services/Redaction/CredentialUrl.cs
new file mode 100644
index 000000000..9f4708486
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Redaction/CredentialUrl.cs
@@ -0,0 +1,117 @@
+namespace CodeSpace.Core.Services.Redaction;
+
+///
+/// A URL that may carry credentials — a model gateway's base URL with user:key@host or ?api-key= — seen two
+/// ways: as a DISPLAY form safe to show any reader (the credential slots masked, everything else verbatim), and as the
+/// ENDPOINT a stored key is bound to (scheme, host and port: where the key is sent).
+///
+/// The credential slots are the ones strikes from echoed text: the whole
+/// userinfo, and each query value whose name marks it a secret. The
+/// display form is computed on the raw string rather than through , so a URL with nothing to mask reads
+/// back exactly as it was stored — no host lower-casing, no trailing slash — and a URL cannot parse
+/// still has its userinfo masked.
+///
+public static class CredentialUrl
+{
+ /// What a credential slot reads as in the display form.
+ public const string Mask = "***";
+
+ /// The display form: userinfo and secret-named query values replaced by ; null/blank and a URL with no credential slot verbatim.
+ public static string? Masked(string? url)
+ {
+ if (string.IsNullOrEmpty(url)) return url;
+
+ var masked = url;
+
+ foreach (var slot in CredentialSlots(url).OrderByDescending(slot => slot.Start)) masked = masked[..slot.Start] + MaskFor(url.Substring(slot.Start, slot.Length)) + masked[(slot.Start + slot.Length)..];
+
+ return masked;
+ }
+
+ /// Whether a credential slot of still holds the — a display form sent back with something else changed, which would store the placeholder as the credential.
+ public static bool CarriesMask(string? url) => !string.IsNullOrEmpty(url) && CredentialSlots(url).Any(slot => url.AsSpan(slot.Start, slot.Length).Contains(Mask.AsSpan(), StringComparison.Ordinal));
+
+ ///
+ /// The URL an update stores for a field the read side shows masked: the display form sent
+ /// back exactly as it was shown means "unchanged", so the stored URL — with its real embedded credentials — is kept,
+ /// and an edit form or API client that PUTs back what it GETs keeps working. A left in a credential
+ /// slot of any OTHER submitted URL would store the placeholder as the credential itself, so it is refused: the caller
+ /// re-types the credentials or removes them. Anything else, null included, is taken as submitted.
+ ///
+ public static string? KeepStoredIfMasked(string? stored, string? submitted)
+ {
+ if (submitted is not null && submitted == Masked(stored)) return stored;
+
+ if (CarriesMask(submitted))
+ throw new ArgumentException($"The URL still carries the masked placeholder ({Mask}) where a credential belongs. Send it back unchanged to keep the stored URL, or enter the embedded credentials in full (or remove them).");
+
+ return submitted;
+ }
+
+ ///
+ /// Whether two base URLs name the same endpoint — the same scheme, host and port, which is where a key presented to
+ /// either one is sent. Path, query and userinfo may differ. Blank on both sides is the provider default on both sides.
+ /// A value that is not an absolute http(s) URL only matches itself.
+ ///
+ public static bool SameEndpoint(string? stored, string? next)
+ {
+ if (string.IsNullOrWhiteSpace(stored) && string.IsNullOrWhiteSpace(next)) return true;
+
+ if (HttpUri(stored) is not { } a || HttpUri(next) is not { } b) return string.Equals(stored?.Trim(), next?.Trim(), StringComparison.Ordinal);
+
+ return Uri.Compare(a, b, UriComponents.Scheme | UriComponents.Host | UriComponents.StrongPort, UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase) == 0;
+ }
+
+ /// The endpoint alone — scheme://host:port, never userinfo, path or query — for a log line about where a key is sent.
+ public static string Endpoint(string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url)) return "(provider default)";
+
+ return HttpUri(url) is { } parsed ? $"{parsed.Scheme}://{parsed.IdnHost}:{parsed.Port}" : "(unparseable)";
+ }
+
+ private static Uri? HttpUri(string? url) => Uri.TryCreate(url?.Trim(), UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps) ? parsed : null;
+
+ /// A userinfo slot keeps its user:password shape; a query value is one opaque token.
+ private static string MaskFor(string slot) => slot.Contains(':') ? $"{Mask}:{Mask}" : Mask;
+
+ /// The (start, length) of every credential slot in the raw string: the userinfo, then each non-empty secret-named query value.
+ private static IEnumerable<(int Start, int Length)> CredentialSlots(string url)
+ {
+ var (authorityStart, authorityEnd) = AuthorityBounds(url);
+ var at = url.AsSpan(authorityStart, authorityEnd - authorityStart).LastIndexOf('@');
+
+ if (at > 0) yield return (authorityStart, at);
+
+ foreach (var slot in SecretQueryValues(url, authorityEnd)) yield return slot;
+ }
+
+ /// Where the authority (userinfo@host:port) sits: after scheme:// (or at the start when there is none), up to the first /, ? or #.
+ private static (int Start, int End) AuthorityBounds(string url)
+ {
+ var scheme = url.IndexOf("://", StringComparison.Ordinal);
+ var start = scheme < 0 ? 0 : scheme + 3;
+ var end = url.IndexOfAny(['/', '?', '#'], start);
+
+ return (start, end < 0 ? url.Length : end);
+ }
+
+ private static IEnumerable<(int Start, int Length)> SecretQueryValues(string url, int authorityEnd)
+ {
+ var query = url.IndexOf('?', authorityEnd);
+ if (query < 0) yield break;
+
+ var fragment = url.IndexOf('#', query);
+ var end = fragment < 0 ? url.Length : fragment;
+
+ for (var pairStart = query + 1; pairStart < end;)
+ {
+ var pairEnd = url.IndexOf('&', pairStart, end - pairStart) is var amp and >= 0 ? amp : end;
+ var equals = url.IndexOf('=', pairStart, pairEnd - pairStart);
+
+ if (equals > pairStart && equals + 1 < pairEnd && CredentialNeedles.MarksASecret(Uri.UnescapeDataString(url[pairStart..equals]))) yield return (equals + 1, pairEnd - equals - 1);
+
+ pairStart = pairEnd + 1;
+ }
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Llm/LlmApiException.cs b/backend/src/CodeSpace.Core/Services/Workflows/Llm/LlmApiException.cs
index 664107432..f3539e430 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Llm/LlmApiException.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Llm/LlmApiException.cs
@@ -60,7 +60,7 @@ public LlmApiException(string provider, int? statusCode, LlmErrorCategory catego
/// The machine-actionable failure category callers branch on.
public LlmErrorCategory Category { get; }
- /// The raw provider error body (the rate-limit / context-length / auth message), shown verbatim to operators.
+ /// The provider error body (the rate-limit / context-length / auth message), shown to operators — verbatim but for any credential the request presented, which the transport strikes before this exists.
public string ProviderMessage { get; }
/// The honored Retry-After delay when the provider supplied one (a 429/503), else null.
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Llm/LlmHttpTransport.cs b/backend/src/CodeSpace.Core/Services/Workflows/Llm/LlmHttpTransport.cs
index 53a848842..0932e9c7c 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Llm/LlmHttpTransport.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Llm/LlmHttpTransport.cs
@@ -2,6 +2,7 @@
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text.Json;
+using CodeSpace.Core.Services.Redaction;
namespace CodeSpace.Core.Services.Workflows.Llm;
@@ -50,7 +51,7 @@ public static async Task SendForJsonAsync(HttpClient http, HttpRequestMess
var status = (int)response.StatusCode;
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
- throw MarkIfAmbiguousSendPreceded(request, new LlmApiException(provider, status, LlmApiException.Classify(status, errorBody), errorBody, RetryAfterOf(response)));
+ throw MarkIfAmbiguousSendPreceded(request, new LlmApiException(provider, status, LlmApiException.Classify(status, errorBody), WithoutPresentedCredentials(request, errorBody), RetryAfterOf(response)));
}
try
@@ -97,7 +98,7 @@ public static async IAsyncEnumerable StreamSseAsync(HttpClient http
var status = (int)response.StatusCode;
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
- throw MarkIfAmbiguousSendPreceded(request, new LlmApiException(provider, status, LlmApiException.Classify(status, errorBody), errorBody, RetryAfterOf(response)));
+ throw MarkIfAmbiguousSendPreceded(request, new LlmApiException(provider, status, LlmApiException.Classify(status, errorBody), WithoutPresentedCredentials(request, errorBody), RetryAfterOf(response)));
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
@@ -128,6 +129,16 @@ public static StringContent JsonBody(T body, JsonSerializerOptions? options =
return content;
}
+ ///
+ /// The gateway's error body with every value THIS request presented struck out — its credential headers and the
+ /// credential parts of its URL (). An upstream that echoes the key it
+ /// rejected, or a proxy that reflects request headers, would otherwise hand the write-only key to every reader of
+ /// the failure: the node, interaction and external-call failure records, and the run a Viewer opens. Struck HERE,
+ /// before the exception exists, so no consumer downstream can forget to. The category is classified from the raw
+ /// body by the caller — a struck echo never changes the retry/degrade decision.
+ ///
+ private static string WithoutPresentedCredentials(HttpRequestMessage request, string errorBody) => CredentialNeedles.Redactor(CredentialNeedles.FromRequest(request)).Redact(errorBody);
+
/// Mark as riding over a possibly-billed earlier attempt when a PRECEDING attempt of this same (Polly-retried) request was sent and got no response back — see . This is what keeps LlmBudgetGuard.ObservedNoSpend holding the reservation instead of reading THIS attempt's own — possibly clean — outcome as proof the call spent nothing.
private static LlmApiException MarkIfAmbiguousSendPreceded(HttpRequestMessage request, LlmApiException fault) =>
request.Options.TryGetValue(PhysicalLlmAccountingHandler.AmbiguousSendKey, out var marker) && marker.Occurred ? fault.AfterBilledAttempt() : fault;
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingLLMClientDecorator.cs b/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingLLMClientDecorator.cs
index ecefe17e2..64bf23c5b 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingLLMClientDecorator.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingLLMClientDecorator.cs
@@ -1,8 +1,10 @@
using System.Text;
using System.Text.Json;
using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Redaction;
using CodeSpace.Core.Services.RunData;
using CodeSpace.Core.Services.Workflows.Runtime;
+using CodeSpace.Messages.Agents;
using CodeSpace.Messages.Contracts;
using CodeSpace.Messages.Constants;
using Serilog;
@@ -76,7 +78,7 @@ await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionStarted, correlat
{
// The caller token is commonly cancelled by the failure itself. Terminal capture gets one independent,
// fail-open attempt so cancellation does not erase the interaction's final fact.
- var failureRecorded = await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionFailed, correlationId, () => Task.FromResult(FailedPayload(scope, Provider, ex)), CancellationToken.None).ConfigureAwait(false);
+ var failureRecorded = await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionFailed, correlationId, () => Task.FromResult(FailedPayload(scope, Provider, ex, request.Credential)), CancellationToken.None).ConfigureAwait(false);
if (failureRecorded && declared) await MarkCapturePresentAsync(scope).ConfigureAwait(false);
throw;
@@ -212,7 +214,7 @@ protected static JsonElement CompletedPayload(LlmCallScope scope, string provide
output,
});
- protected static JsonElement FailedPayload(LlmCallScope scope, string provider, Exception ex)
+ protected static JsonElement FailedPayload(LlmCallScope scope, string provider, Exception ex, ResolvedModelCredential? credential)
{
var category = ex is LlmApiException llm ? llm.Category.ToString() : null;
var failureKind = ex switch
@@ -222,10 +224,21 @@ protected static JsonElement FailedPayload(LlmCallScope scope, string provider,
_ => "exception",
};
- var error = scope.CaptureRedactor is null ? ex.Message : Observe(scope, scope.CaptureRedactor.Redact(ex.Message));
+ var withoutCredential = WithoutCallCredential(scope, credential, ex.Message);
+ var error = scope.CaptureRedactor is null ? withoutCredential : Observe(scope, scope.CaptureRedactor.Redact(withoutCredential));
return JsonSerializer.SerializeToElement(new { kind = scope.Kind, accountingSource = scope.NativeModelCallId is not null ? Budget.BudgetLedger.PhysicalLlmSource : null, provider, error, category, failureKind });
}
+ ///
+ /// The failure text with the call's OWN credential struck — its key and the credential parts of its base URL
+ /// (). The transport already strikes an upstream's echo before the
+ /// typed failure exists; this backstop covers a failure raised anywhere else that still quotes it, so the
+ /// write-only key never reaches the interaction tape a Viewer reads. Folded into the call's masking observation like
+ /// every other capture-side redaction.
+ ///
+ private static string? WithoutCallCredential(LlmCallScope scope, ResolvedModelCredential? credential, string? text) =>
+ CredentialNeedles.Usable(CredentialNeedles.FromCredential(credential)) is { Count: > 0 } needles ? Observe(scope, new PersistenceSecretRedactor(needles).Redact(text)) : text;
+
/// Fold one redaction into THIS call's masking observation and hand its value back, so the flag the presence delta carries is a fact about the bytes that reached storage rather than about the configuration. A scope minted outside observes nothing and reads back verbatim, which is the conservative answer.
protected static T Observe(LlmCallScope scope, PersistenceRedaction redaction) => scope.Masking is { } masking ? masking.Observe(redaction) : redaction.Value;
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingStreamingStructuredLLMClientDecorator.cs b/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingStreamingStructuredLLMClientDecorator.cs
index 4d7708fff..6456f59a6 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingStreamingStructuredLLMClientDecorator.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingStreamingStructuredLLMClientDecorator.cs
@@ -93,7 +93,7 @@ await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionStarted, correlat
if (!pending.IsEmpty)
await RecordPendingDeltaAsync(scope, correlationId, pending, DeltaFlush.Cut, CancellationToken.None).ConfigureAwait(false);
- var failureRecorded = await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionFailed, correlationId, () => Task.FromResult(FailedPayload(scope, Provider, ex)), CancellationToken.None).ConfigureAwait(false);
+ var failureRecorded = await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionFailed, correlationId, () => Task.FromResult(FailedPayload(scope, Provider, ex, request.Credential)), CancellationToken.None).ConfigureAwait(false);
if (failureRecorded && declared) await MarkCapturePresentAsync(scope).ConfigureAwait(false);
throw;
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingStructuredLLMClientDecorator.cs b/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingStructuredLLMClientDecorator.cs
index 65e0e55cf..c146c9c0c 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingStructuredLLMClientDecorator.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Llm/RecordingStructuredLLMClientDecorator.cs
@@ -55,7 +55,7 @@ await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionStarted, correlat
}
catch (Exception ex)
{
- var failureRecorded = await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionFailed, correlationId, () => Task.FromResult(FailedPayload(scope, Provider, ex)), CancellationToken.None).ConfigureAwait(false);
+ var failureRecorded = await SafeRecordAsync(scope, WorkflowRunRecordTypes.InteractionFailed, correlationId, () => Task.FromResult(FailedPayload(scope, Provider, ex, request.Credential)), CancellationToken.None).ConfigureAwait(false);
if (failureRecorded && declared) await MarkCapturePresentAsync(scope).ConfigureAwait(false);
throw;
diff --git a/backend/src/CodeSpace.Messages/Dtos/ModelCredentials/ModelCredentialSummary.cs b/backend/src/CodeSpace.Messages/Dtos/ModelCredentials/ModelCredentialSummary.cs
index 493540180..7d6af5821 100644
--- a/backend/src/CodeSpace.Messages/Dtos/ModelCredentials/ModelCredentialSummary.cs
+++ b/backend/src/CodeSpace.Messages/Dtos/ModelCredentials/ModelCredentialSummary.cs
@@ -5,7 +5,7 @@ namespace CodeSpace.Messages.Dtos.ModelCredentials;
///
/// A team's model credential as shown in the management UI — NEVER carries the secret. The key is
/// surfaced only as a masked hint (last 4 chars, derived transiently on read, then discarded), exactly
-/// like the OpenAI / Anthropic consoles. is non-secret config and is shown verbatim.
+/// like the OpenAI / Anthropic consoles. is shown with its embedded credentials masked.
///
public sealed record ModelCredentialSummary
{
@@ -27,7 +27,11 @@ public sealed record ModelCredentialSummary
///
public bool KeyUnreadable { get; init; }
- /// Non-secret base-URL override (gateway / self-hosted endpoint), shown as-is. Null = the provider default.
+ ///
+ /// The base-URL override (gateway / self-hosted endpoint), with any credential it embeds masked — userinfo and
+ /// secret-named query values read *** (https://***:***@gw/v1?api-key=***); a URL embedding none reads
+ /// exactly as stored. Sending this exact value back on update keeps the stored URL. Null = the provider default.
+ ///
public string? BaseUrl { get; init; }
public required CredentialStatus Status { get; init; }
diff --git a/backend/src/CodeSpace.Messages/Exceptions/ModelEndpointKeyRequiredException.cs b/backend/src/CodeSpace.Messages/Exceptions/ModelEndpointKeyRequiredException.cs
new file mode 100644
index 000000000..f995db13e
--- /dev/null
+++ b/backend/src/CodeSpace.Messages/Exceptions/ModelEndpointKeyRequiredException.cs
@@ -0,0 +1,27 @@
+using CodeSpace.Messages.Failures;
+
+namespace CodeSpace.Messages.Exceptions;
+
+///
+/// Thrown when an update moves a model credential's endpoint — its base URL's scheme, host or port — without entering
+/// the API key again. A stored key is write-only: nobody can read it back, so the only thing that could carry it to a new
+/// host is a repoint that kept it, and every reflection, probe and run after that would present it there. Remedy: send
+/// the key in the same update as the new endpoint (or leave the endpoint where it is). A path-only change, or a keyless
+/// credential, needs no key.
+///
+public sealed class ModelEndpointKeyRequiredException : Exception, IFailure
+{
+ public FailureKind Kind => FailureKind.Invalid;
+
+ public string Code => FailureCodes.ModelEndpointKeyRequired;
+
+ public IReadOnlyDictionary? Details => new Dictionary { ["credentialId"] = CredentialId };
+
+ public Guid CredentialId { get; }
+
+ public ModelEndpointKeyRequiredException(Guid credentialId)
+ : base("Changing this credential's endpoint (its scheme, host or port) needs the API key entered again in the same update — a stored key is only ever sent to the endpoint it was entered for.")
+ {
+ CredentialId = credentialId;
+ }
+}
diff --git a/backend/src/CodeSpace.Messages/Exceptions/ProviderApiException.cs b/backend/src/CodeSpace.Messages/Exceptions/ProviderApiException.cs
index 5ab65c32d..937c069d0 100644
--- a/backend/src/CodeSpace.Messages/Exceptions/ProviderApiException.cs
+++ b/backend/src/CodeSpace.Messages/Exceptions/ProviderApiException.cs
@@ -47,7 +47,7 @@ public ProviderApiException(ProviderKind providerKind, int statusCode, string op
/// The capability method that failed (e.g. "ListPullRequestsAsync"). Surfaced to the operator for context.
public string OperationName { get; }
- /// Raw provider error string (e.g. Octokit's "Not Found"). Stripped from logs / shown verbatim to operators.
+ /// The provider's error string (e.g. Octokit's "Not Found"), shown to the caller — with every token the instance presented struck and the SDK's trailing "Original call: <url>" dropped by the translation that raises this.
public string ProviderMessage { get; }
private static string BuildMessage(ProviderKind kind, int statusCode, string operation, string providerMessage)
diff --git a/backend/src/CodeSpace.Messages/Exceptions/ProviderEndpointCredentialsBoundException.cs b/backend/src/CodeSpace.Messages/Exceptions/ProviderEndpointCredentialsBoundException.cs
new file mode 100644
index 000000000..cdbffa43a
--- /dev/null
+++ b/backend/src/CodeSpace.Messages/Exceptions/ProviderEndpointCredentialsBoundException.cs
@@ -0,0 +1,31 @@
+using CodeSpace.Messages.Failures;
+
+namespace CodeSpace.Messages.Exceptions;
+
+///
+/// Thrown when an edit moves a git provider connection's endpoint — the scheme, host or port of its API or base URL —
+/// while credentials are still connected through it. A connected token is write-only: nobody can read it back, so the
+/// only thing that could carry it to a new host is a repoint that kept it, and every repository read, git node, webhook
+/// registration and OAuth refresh after that would present it there. The tokens belong to the members who connected
+/// them, so the editor cannot re-enter them the way a model key is re-entered; remedy: disconnect them first (each
+/// member reconnects against the new host), or add the new host as a provider of its own.
+///
+public sealed class ProviderEndpointCredentialsBoundException : Exception, IFailure
+{
+ public FailureKind Kind => FailureKind.PreconditionRequired;
+
+ public string Code => FailureCodes.ProviderEndpointCredentialsBound;
+
+ public IReadOnlyDictionary? Details => new Dictionary { ["providerInstanceId"] = ProviderInstanceId, ["boundCredentialCount"] = BoundCredentialCount };
+
+ public Guid ProviderInstanceId { get; }
+
+ public int BoundCredentialCount { get; }
+
+ public ProviderEndpointCredentialsBoundException(Guid providerInstanceId, int boundCredentialCount)
+ : base($"Changing this provider's host (its scheme, host or port) is refused while {boundCredentialCount} connected credential(s) would follow it — a connected token is only ever sent to the host it was connected for. Disconnect them first (members reconnect against the new host), or add the new host as its own provider.")
+ {
+ ProviderInstanceId = providerInstanceId;
+ BoundCredentialCount = boundCredentialCount;
+ }
+}
diff --git a/backend/src/CodeSpace.Messages/Exceptions/ProviderEndpointSecretRequiredException.cs b/backend/src/CodeSpace.Messages/Exceptions/ProviderEndpointSecretRequiredException.cs
new file mode 100644
index 000000000..2742aa329
--- /dev/null
+++ b/backend/src/CodeSpace.Messages/Exceptions/ProviderEndpointSecretRequiredException.cs
@@ -0,0 +1,27 @@
+using CodeSpace.Messages.Failures;
+
+namespace CodeSpace.Messages.Exceptions;
+
+///
+/// Thrown when an edit moves a git provider connection's endpoint — the scheme, host or port of its API or base URL —
+/// without entering its stored OAuth client secret again. The secret is write-only and is sent with every token
+/// exchange, refresh and revocation, to endpoints derived from the base URL; kept across a repoint, it would go to the
+/// new host on the next sign-in. Remedy: send the client secret in the same update as the new endpoint (an OAuth app
+/// belongs to one host, so this is normally the new host's app's secret, with its client ID).
+///
+public sealed class ProviderEndpointSecretRequiredException : Exception, IFailure
+{
+ public FailureKind Kind => FailureKind.Invalid;
+
+ public string Code => FailureCodes.ProviderEndpointSecretRequired;
+
+ public IReadOnlyDictionary? Details => new Dictionary { ["providerInstanceId"] = ProviderInstanceId };
+
+ public Guid ProviderInstanceId { get; }
+
+ public ProviderEndpointSecretRequiredException(Guid providerInstanceId)
+ : base("Changing this provider's host (its scheme, host or port) needs the OAuth client secret entered again in the same update — a stored secret is only ever sent to the host it was entered for.")
+ {
+ ProviderInstanceId = providerInstanceId;
+ }
+}
diff --git a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs
index 8d1c572e1..54de365e8 100644
--- a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs
+++ b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs
@@ -55,6 +55,15 @@ public static class FailureCodes
/// Something tried to authenticate through a credential that was disconnected — revoked, its token material destroyed. Remedy: reconnect the provider (bound repositories follow their owner's reconnect) or re-link to an active credential; a retry as-is reads the same dead row.
public const string CredentialDisconnected = "credential_disconnected";
+ /// An update moved a model credential's endpoint (scheme, host or port) without entering its key again. Remedy: send the key with the new endpoint; a stored key never follows a repoint on its own.
+ public const string ModelEndpointKeyRequired = "model_endpoint_key_required";
+
+ /// An edit moved a git provider connection's endpoint (the scheme, host or port of its API or base URL) while credentials are still connected through it. Remedy: disconnect them first (members reconnect against the new host), or add the new host as its own provider; a connected token never follows a repoint.
+ public const string ProviderEndpointCredentialsBound = "provider_endpoint_credentials_bound";
+
+ /// An edit moved a git provider connection's endpoint without entering its stored OAuth client secret again. Remedy: send the secret (normally the new host's OAuth app's) with the new endpoint.
+ public const string ProviderEndpointSecretRequired = "provider_endpoint_secret_required";
+
// ── Workflows and runs ─────────────────────────────────────────────────────────
public const string WorkflowDefinitionInvalid = "workflow_definition_invalid";
public const string TaskRouteConfirmationRequired = "task_route_confirmation_required";
diff --git a/backend/tests/CodeSpace.E2ETests/Agents/ModelKeyExposureE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Agents/ModelKeyExposureE2ETests.cs
new file mode 100644
index 000000000..9edc43565
--- /dev/null
+++ b/backend/tests/CodeSpace.E2ETests/Agents/ModelKeyExposureE2ETests.cs
@@ -0,0 +1,188 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using CodeSpace.E2ETests.Infrastructure;
+using CodeSpace.IntegrationTests.Workflows.Infrastructure;
+using CodeSpace.Messages.Dtos.ModelCredentials;
+using CodeSpace.Messages.Enums;
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+
+namespace CodeSpace.E2ETests.Agents;
+
+///
+/// A team's model key is write-only to every member — nobody reads it back, and nobody can make the platform carry it
+/// somewhere it was not entered for. Driven as a client drives it: the real HTTP surface (routing, JWT, X-Team-Id,
+/// model binding, the controller, the GlobalExceptionFilter, MediatR) over real Postgres, with loopback gateways that
+/// record every connection they accept and echo the credentials they are presented.
+///
+/// Fidelity: HIGH. The production ModelCredentialService, the production reflector over its production
+/// named client, the production engine and OpenAiClient — only the gateways are stubs, and they are real sockets.
+///
+[Trait("Category", "E2E")]
+[Trait("Surface", "Http")]
+public sealed class ModelKeyExposureE2ETests : IClassFixture
+{
+ private readonly TaskLaunchApiFactory _factory;
+ private readonly TenantHarness _tenants;
+
+ public ModelKeyExposureE2ETests(TaskLaunchApiFactory factory)
+ {
+ _factory = factory;
+ _tenants = new TenantHarness(factory);
+ }
+
+ [Fact]
+ public async Task An_admin_cannot_repoint_a_credential_to_take_its_key_somewhere_else()
+ {
+ var key = Canary("OWNER-KEY");
+ await using var legit = LoopbackResponder.Start(_ => ModelList());
+ await using var attacker = LoopbackResponder.Start(_ => ModelList());
+ var world = await SeedAsync();
+ var id = await AddCredentialAsync(world.Owner, world.Team, key, $"http://127.0.0.1:{legit.Port}/v1");
+
+ var repoint = await _tenants.SendAsync(HttpMethod.Put, $"/api/model-credentials/{id}", world.Admin, world.Team, new { displayName = "gateway", baseUrl = $"http://127.0.0.1:{attacker.Port}/v1" });
+ repoint.StatusCode.ShouldBe(HttpStatusCode.BadRequest, await TenantHarness.DescribeAsync(repoint));
+ (await repoint.Content.ReadAsStringAsync()).ShouldContain("model_endpoint_key_required");
+
+ var refresh = await _tenants.SendAsync(HttpMethod.Post, $"/api/model-credentials/{id}/models/refresh", world.Admin, world.Team);
+ refresh.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(refresh));
+
+ attacker.Accepted.ShouldBe(0, "the refused repoint stored nothing, so the refresh after it went to the endpoint the key was entered for — check the credential's base_url row if this fails");
+ legit.Requests.ShouldContain(head => head.Contains($"Bearer {key}"), "fixture check: the refresh really presented the key, to its own endpoint");
+
+ var moved = await _tenants.SendAsync(HttpMethod.Put, $"/api/model-credentials/{id}", world.Admin, world.Team, new { displayName = "gateway", apiKey = "sk-admins-own-key-0123456789", baseUrl = $"http://127.0.0.1:{attacker.Port}/v1" });
+ moved.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(moved));
+ (await _tenants.SendAsync(HttpMethod.Post, $"/api/model-credentials/{id}/models/refresh", world.Admin, world.Team)).StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ attacker.Requests.ShouldNotBeEmpty("fixture check: a repoint WITH a key is the supported way to move a credential");
+ attacker.Requests.ShouldAllBe(head => !head.Contains(key), "the new endpoint only ever sees the key entered with the move");
+ }
+
+ [Theory]
+ [InlineData(TeamRole.Owner)]
+ [InlineData(TeamRole.Admin)]
+ [InlineData(TeamRole.Member)]
+ [InlineData(TeamRole.Viewer)]
+ public async Task Every_member_reads_the_base_url_with_its_credentials_masked(TeamRole role)
+ {
+ var password = Canary("URL-PASSWORD");
+ var queryKey = Canary("URL-QUERY-KEY");
+ var world = await SeedAsync();
+ await AddCredentialAsync(world.Owner, world.Team, Canary("KEY"), $"https://svc:{password}@gw.example/v1?api-key={queryKey}&api-version=2024-01-01");
+
+ var list = await _tenants.SendAsync(HttpMethod.Get, "/api/model-credentials", world.As(role), world.Team);
+ var body = await list.Content.ReadAsStringAsync();
+
+ list.StatusCode.ShouldBe(HttpStatusCode.OK, body);
+ body.ShouldNotContain(password);
+ body.ShouldNotContain(queryKey);
+ JsonSerializer.Deserialize(body, Json)!.ShouldHaveSingleItem().BaseUrl.ShouldBe("https://***:***@gw.example/v1?api-key=***&api-version=2024-01-01");
+ }
+
+ [Fact]
+ public async Task An_edit_form_that_sends_back_what_it_was_shown_keeps_the_stored_url()
+ {
+ var stored = $"https://svc:{Canary("URL-PASSWORD")}@gw.example/v1";
+ var world = await SeedAsync();
+ var id = await AddCredentialAsync(world.Owner, world.Team, Canary("KEY"), stored);
+ var shown = (await (await _tenants.SendAsync(HttpMethod.Get, "/api/model-credentials", world.Admin, world.Team)).Content.ReadFromJsonAsync(Json))!.Single();
+
+ var save = await _tenants.SendAsync(HttpMethod.Put, $"/api/model-credentials/{id}", world.Admin, world.Team, new { displayName = "renamed", apiKey = (string?)null, baseUrl = shown.BaseUrl });
+
+ save.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(save));
+ (await _tenants.DbAsync(db => db.ModelCredential.AsNoTracking().SingleAsync(c => c.Id == id))).BaseUrl.ShouldBe(stored, "the masked round-trip keeps the credentials the platform needs to reach the gateway");
+ }
+
+ [Fact]
+ public async Task A_viewer_reads_a_failed_run_without_the_key_the_gateway_echoed()
+ {
+ var key = Canary("RUN-KEY");
+ await using var gateway = LoopbackResponder.Start(EchoTheBearer);
+ var world = await SeedAsync();
+ var id = await AddCredentialAsync(world.Owner, world.Team, key, $"http://127.0.0.1:{gateway.Port}/v1");
+ (await _tenants.SendAsync(HttpMethod.Post, $"/api/model-credentials/{id}/models", world.Owner, world.Team, new { modelId = "gpt-echo" })).StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var runId = await LaunchLlmRunAsync(world);
+ await _factory.JobClient.DrainAsync();
+
+ gateway.Requests.ShouldContain(head => head.Contains(key), "fixture check: the run presented the key to the gateway, whose 401 echoed it");
+ foreach (var path in new[] { $"/api/workflows/runs/{runId}", $"/api/workflows/runs/{runId}/records", $"/api/workflows/runs/{runId}/timeline" })
+ {
+ var response = await _tenants.SendAsync(HttpMethod.Get, path, world.Viewer, world.Team);
+ var body = await response.Content.ReadAsStringAsync();
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK, $"{path}: {body}");
+ body.ShouldNotContain(key, Case.Sensitive, $"{path} handed a Viewer the echoed model key");
+ }
+ }
+
+ private async Task LaunchLlmRunAsync(World world)
+ {
+ var workflow = new
+ {
+ name = $"echo-{Guid.NewGuid():N}"[..20], enabled = true, activations = Array.Empty