diff --git a/backend/deploy/e2e/docker-compose.e2e.yml b/backend/deploy/e2e/docker-compose.e2e.yml
index f5fcef319..526b2c982 100644
--- a/backend/deploy/e2e/docker-compose.e2e.yml
+++ b/backend/deploy/e2e/docker-compose.e2e.yml
@@ -36,6 +36,7 @@ services:
# P2 slice 3: Production refuses to start with unconfigured durable roots — the exact discipline this
# smoke stack must model. The images prepare these dirs with app ownership; a real deployment mounts volumes at them.
Artifacts__StoreDirectory: /var/lib/codespace/artifacts
+ Artifacts__LocalRwxShared: "true"
Agents__RunSpoolDirectory: /var/lib/codespace/spool
volumes:
- artifacts:/var/lib/codespace/artifacts
@@ -59,6 +60,7 @@ services:
Authentication__Jwt__SymmetricKey: "${E2E_JWT_KEY}"
CODESPACE_VARIABLE_MASTER_KEY: "${E2E_VARIABLE_MASTER_KEY}"
Artifacts__StoreDirectory: /var/lib/codespace/artifacts
+ Artifacts__LocalRwxShared: "true"
Agents__RunSpoolDirectory: /var/lib/codespace/spool
HangfireHosting: "Worker"
CODESPACE_CODEX_CLI_PATH: "/opt/fake-codex"
diff --git a/backend/src/CodeSpace.Api/appsettings.json b/backend/src/CodeSpace.Api/appsettings.json
index c1a1b5f29..06290cac5 100644
--- a/backend/src/CodeSpace.Api/appsettings.json
+++ b/backend/src/CodeSpace.Api/appsettings.json
@@ -58,8 +58,9 @@
"PackAllowedHosts": ""
},
"Artifacts": {
- "_doc": "Root directory for offloaded artifact bytes. Blank uses a path under the system temp dir; point it at a persistent volume in any deployment whose artifacts must outlive the pod.",
- "StoreDirectory": ""
+ "_doc": "StoreDirectory is the root for offloaded artifact bytes. LocalRwxShared is a separate deployment qualification: true means every API/worker instance using local-rwx sees the same namespace (for example one RWX volume). A durable-looking path alone is not proof, so false is the fail-closed default.",
+ "StoreDirectory": "",
+ "LocalRwxShared": false
},
"Shutdown": {
"_doc": "Graceful-shutdown drain budget in seconds. The orchestrator's grace period MUST be at least this or the process is SIGKILLed before in-flight background work drains (k8s: terminationGracePeriodSeconds). 30 matches k8s's own default.",
diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs
new file mode 100644
index 000000000..42b50aa3a
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs
@@ -0,0 +1,122 @@
+using System.Text.Json;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
+using CodeSpace.Core.Settings;
+using CodeSpace.Messages.Constants;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+
+namespace CodeSpace.Core.Services.Agents.AgentRunLogging;
+
+///
+/// Gives an unconfigured team one real Settings-visible local route only after the deployment explicitly qualifies its
+/// local-rwx namespace as shared. This is a missing-only bootstrap, not a routing fallback: an existing route in any
+/// lifecycle state or any collision on the reserved profile name remains authoritative, and later Settings revisions can
+/// move the data class to any registered cloud provider without changing Agent Run capture.
+///
+public sealed class AgentRunLogStorageReadiness : IAgentRunLogStorageReadiness
+{
+ internal const string DefaultProfileStableName = "codespace-agent-run-log-default";
+ private const int AdvisoryLockNamespace = 117;
+ private readonly CodeSpaceDbContext _db;
+ private readonly TimeProvider _clock;
+
+ public AgentRunLogStorageReadiness(CodeSpaceDbContext db, TimeProvider clock)
+ {
+ _db = db;
+ _clock = clock;
+ }
+
+ public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken)
+ {
+ if (teamId == Guid.Empty || !RuntimeSettings.Current.ArtifactLocalRwxShared) return;
+
+ await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtextextended({teamId.ToString()}, {AdvisoryLockNamespace}))", cancellationToken).ConfigureAwait(false);
+ if (!await _db.Team.AsNoTracking().AnyAsync(team => team.Id == teamId, cancellationToken).ConfigureAwait(false)
+ || await _db.StorageRoute.AsNoTracking().AnyAsync(route => route.TeamId == teamId && route.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey, cancellationToken).ConfigureAwait(false))
+ {
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ var rootPath = DurableRoots.ArtifactStore(RuntimeSettings.Current.ArtifactStoreDirectory);
+ var canonicalConfig = CanonicalConfig(rootPath);
+ if (await _db.StorageProfile.AsNoTracking().AnyAsync(
+ value => value.TeamId == teamId && value.StableName == DefaultProfileStableName, cancellationToken).ConfigureAwait(false))
+ {
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ var profile = BuildProfile(teamId, canonicalConfig, _clock.GetUtcNow());
+ _db.StorageProfile.Add(profile);
+ var route = BuildRoute(teamId, profile.Id, _clock.GetUtcNow());
+ _db.StorageRoute.Add(route);
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ route.State = StorageRouteState.Active;
+ route.LastModifiedDate = _clock.GetUtcNow();
+ route.LastModifiedBy = SystemUsers.SeederId;
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (IsUniqueViolation(exception))
+ {
+ await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
+ _db.ChangeTracker.Clear();
+ }
+ }
+
+ private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig, DateTimeOffset now)
+ {
+ var profile = new StorageProfile
+ {
+ Id = Guid.NewGuid(), TeamId = teamId, StableName = DefaultProfileStableName, CurrentRevision = 1,
+ State = StorageProfileState.Active, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
+ LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
+ };
+ using var document = JsonDocument.Parse(canonicalConfig);
+ profile.Revisions.Add(new StorageProfileRevision
+ {
+ Id = Guid.NewGuid(), TeamId = teamId, StorageProfileId = profile.Id, Revision = 1,
+ ProviderTypeKey = LocalRwxArtifactStorageDriverFactory.TypeKey, NonSecretConfigJson = canonicalConfig,
+ CredentialRef = null, NamespaceFingerprint = StorageProfileRules.NamespaceFingerprint(LocalRwxArtifactStorageDriverFactory.TypeKey, document.RootElement),
+ CreatedDate = now, CreatedBy = SystemUsers.SeederId,
+ });
+ return profile;
+ }
+
+ private static StorageRoute BuildRoute(Guid teamId, Guid profileId, DateTimeOffset now)
+ {
+ var route = new StorageRoute
+ {
+ Id = Guid.NewGuid(), TeamId = teamId, DataClassTypeKey = AgentRunLogStorageResolver.DataClassTypeKey,
+ CurrentRevision = 1, State = StorageRouteState.Draft, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
+ LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
+ };
+ route.Revisions.Add(new StorageRouteRevision
+ {
+ Id = Guid.NewGuid(), TeamId = teamId, StorageRouteId = route.Id, Revision = 1, StorageProfileId = profileId,
+ ProfileRevisionMode = StorageProfileRevisionMode.CurrentAtWrite, PinnedProfileRevision = null,
+ CreatedDate = now, CreatedBy = SystemUsers.SeederId,
+ });
+ return route;
+ }
+
+ private static string CanonicalConfig(string rootPath)
+ {
+ using var document = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath }));
+ return StorageProfileRules.CanonicalJson(document.RootElement);
+ }
+
+ private static bool IsUniqueViolation(Exception exception)
+ {
+ for (Exception? current = exception; current != null; current = current.InnerException)
+ if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) return true;
+ return false;
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs
index 787b29254..6a6cd19f9 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs
@@ -10,14 +10,27 @@ public sealed class AgentRunLogStorageResolver : IAgentRunLogStorageResolver
{
public const string DataClassTypeKey = "agent-run-log/v1";
private readonly IStorageRouteSnapshotResolver _routes;
+ private readonly IAgentRunLogStorageReadiness _readiness;
- public AgentRunLogStorageResolver(IStorageRouteSnapshotResolver routes) => _routes = routes;
+ public AgentRunLogStorageResolver(IStorageRouteSnapshotResolver routes, IAgentRunLogStorageReadiness readiness)
+ {
+ _routes = routes;
+ _readiness = readiness;
+ }
public async Task ResolveAsync(Guid teamId, CancellationToken cancellationToken)
{
- var resolution = await _routes.ResolveAsync(new StorageRouteSnapshotRequest(teamId, DataClassTypeKey), cancellationToken).ConfigureAwait(false);
+ var request = new StorageRouteSnapshotRequest(teamId, DataClassTypeKey);
+ var resolution = await _routes.ResolveAsync(request, cancellationToken).ConfigureAwait(false);
if (resolution is StorageRouteSnapshotResolution.Cancelled && cancellationToken.IsCancellationRequested)
throw new OperationCanceledException(cancellationToken);
+ if (resolution is StorageRouteSnapshotResolution.Missing)
+ {
+ await _readiness.EnsureDefaultRouteAsync(teamId, cancellationToken).ConfigureAwait(false);
+ resolution = await _routes.ResolveAsync(request, cancellationToken).ConfigureAwait(false);
+ if (resolution is StorageRouteSnapshotResolution.Cancelled && cancellationToken.IsCancellationRequested)
+ throw new OperationCanceledException(cancellationToken);
+ }
return resolution switch
{
diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs
index b945a5df7..0e24269f9 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs
@@ -48,6 +48,16 @@ public interface IAgentRunLogStorageResolver : IScopedDependency
Task ResolveAsync(Guid teamId, CancellationToken cancellationToken);
}
+///
+/// Establishes the explicit Settings-visible default route for a team that has never configured Agent Run log storage,
+/// but only when the deployment explicitly qualified local-rwx as shared. Implementations may act only on a missing
+/// exact route; lifecycle states and reserved stable names chosen by an operator are final.
+///
+public interface IAgentRunLogStorageReadiness : IScopedDependency
+{
+ Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken);
+}
+
public abstract record AgentRunLogStorageResolution
{
private AgentRunLogStorageResolution() { }
diff --git a/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs b/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs
index 312798af3..223767dc0 100644
--- a/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs
+++ b/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs
@@ -39,6 +39,13 @@ public sealed record RuntimeSettings
/// Root directory for offloaded artifact bytes. Null ⇒ a path under the system temp dir. Same durability caveat as the spool: point it at a persistent volume in any deployment whose artifacts must outlive the pod.
public string? ArtifactStoreDirectory { get; init; }
+ ///
+ /// Explicit deployment qualification that the local-rwx artifact root is one shared namespace visible to every API
+ /// and worker instance that may use it. False by default: a durable-looking or existing path does not prove two
+ /// hosts see the same bytes, so it cannot authorize an automatically active team route.
+ ///
+ public bool ArtifactLocalRwxShared { get; init; }
+
///
/// Graceful-shutdown drain budget in seconds — how long the host waits on SIGTERM for in-flight background work
/// before exiting. The orchestrator's own grace period MUST be at least this (k8s
@@ -78,6 +85,7 @@ public sealed record RuntimeSettings
AgentCgroupRoot = Trimmed(configuration["Sandbox:CgroupRoot"]),
AgentRunSpoolDirectory = Trimmed(configuration["Agents:RunSpoolDirectory"]),
ArtifactStoreDirectory = Trimmed(configuration["Artifacts:StoreDirectory"]),
+ ArtifactLocalRwxShared = configuration.GetValue("Artifacts:LocalRwxShared", false),
ShutdownDrainSeconds = Positive(configuration["Shutdown:DrainSeconds"], DefaultShutdownDrainSeconds),
PackAllowedHosts = Trimmed(configuration["Agents:PackAllowedHosts"]),
// Secrets. The LEGACY flat keys are still honoured, and that is load-bearing rather than tidy: every
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs
index 4d0f08b50..6ff30a431 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs
@@ -1,13 +1,17 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
+using System.Text.Json;
using Autofac;
using CodeSpace.Core.Handlers.QueryHandlers.Agents;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Agents.AgentRunLogging;
using CodeSpace.Core.Services.Identity;
+using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
using CodeSpace.Core.Services.Workflows.Artifacts.Runtime;
+using CodeSpace.Core.Settings;
using CodeSpace.IntegrationTests.Infrastructure;
+using CodeSpace.Messages.Constants;
using CodeSpace.Messages.Enums;
using CodeSpace.Messages.Queries.Agents;
using Microsoft.EntityFrameworkCore;
@@ -148,17 +152,157 @@ public async Task Current_at_write_uses_the_exact_current_route_revision_and_tha
}
[Fact]
- public async Task Missing_inactive_invalid_and_foreign_routes_fail_closed_without_an_active_profile_fallback()
+ public async Task Missing_route_stays_typed_missing_when_the_deployment_did_not_qualify_local_rwx_as_shared()
+ {
+ var world = await SeedWorldAsync();
+ var nonTemporaryExistingRoot = Path.Combine(Directory.GetCurrentDirectory(), "agent-log-unqualified", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(nonTemporaryExistingRoot);
+ using var settings = RuntimeSettings.Override(current => current with
+ {
+ ArtifactStoreDirectory = nonTemporaryExistingRoot,
+ ArtifactLocalRwxShared = false,
+ });
+ try
+ {
+ using var scope = _fixture.BeginScope();
+ var result = await scope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None);
+
+ result.ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing));
+ var db = scope.Resolve();
+ (await db.StorageProfile.CountAsync(value => value.TeamId == world.TeamId && value.StableName == AgentRunLogStorageReadiness.DefaultProfileStableName)).ShouldBe(0);
+ (await db.StorageRoute.CountAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey)).ShouldBe(0);
+ }
+ finally
+ {
+ Directory.Delete(nonTemporaryExistingRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task First_resolution_creates_one_idempotent_settings_visible_local_route_without_selecting_an_unrelated_profile()
+ {
+ var world = await SeedWorldAsync();
+ var rootPath = Path.Combine(Path.GetTempPath(), "codespace-agent-log-readiness", Guid.NewGuid().ToString("N"));
+ using var settings = RuntimeSettings.Override(current => current with { ArtifactStoreDirectory = rootPath, ArtifactLocalRwxShared = true });
+ using var scope = _fixture.BeginScope();
+ var resolver = scope.Resolve();
+
+ var first = (await resolver.ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType();
+ var second = (await resolver.ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType();
+
+ first.ShouldBe(second);
+ first.StorageProfileId.ShouldNotBe(world.StorageProfileId, "an arbitrary active profile is never a routing fallback");
+ var db = scope.Resolve();
+ var route = await db.StorageRoute.AsNoTracking().Include(value => value.Revisions)
+ .SingleAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey);
+ route.State.ShouldBe(StorageRouteState.Active);
+ route.CurrentRevision.ShouldBe(1);
+ route.Revisions.ShouldHaveSingleItem().StorageProfileId.ShouldBe(first.StorageProfileId);
+ route.Revisions.Single().ProfileRevisionMode.ShouldBe(StorageProfileRevisionMode.CurrentAtWrite);
+ var profile = await db.StorageProfile.AsNoTracking().Include(value => value.Revisions).SingleAsync(value => value.TeamId == world.TeamId && value.Id == first.StorageProfileId);
+ profile.StableName.ShouldBe(AgentRunLogStorageReadiness.DefaultProfileStableName);
+ profile.State.ShouldBe(StorageProfileState.Active);
+ profile.CreatedBy.ShouldBe(SystemUsers.SeederId);
+ var revision = profile.Revisions.ShouldHaveSingleItem();
+ revision.ProviderTypeKey.ShouldBe("local-rwx/v1");
+ revision.CredentialRef.ShouldBeNull();
+ using var config = JsonDocument.Parse(revision.NonSecretConfigJson);
+ config.RootElement.GetProperty("rootPath").GetString().ShouldBe(Path.GetFullPath(rootPath));
+ }
+
+ [Fact]
+ public async Task Reserved_profile_collision_is_not_hijacked_and_missing_route_stays_explicit()
+ {
+ var world = await SeedWorldAsync();
+ var rootPath = Path.Combine(Path.GetTempPath(), "codespace-agent-log-collision", Guid.NewGuid().ToString("N"));
+ using var settings = RuntimeSettings.Override(current => current with { ArtifactStoreDirectory = rootPath, ArtifactLocalRwxShared = true });
+ using var scope = _fixture.BeginScope();
+ var db = scope.Resolve();
+ AddReservedDefaultProfile(db, world, rootPath, DateTimeOffset.UtcNow);
+ await db.SaveChangesAsync();
+
+ var result = await scope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None);
+
+ result.ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing));
+ (await db.StorageRoute.CountAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey)).ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Concurrent_first_resolutions_converge_on_one_profile_route_and_revision_chain()
+ {
+ var world = await SeedWorldAsync();
+ using var settings = RuntimeSettings.Override(current => current with { ArtifactLocalRwxShared = true });
+ using var firstScope = _fixture.BeginScope();
+ using var secondScope = _fixture.BeginScope();
+
+ var resolutions = await Task.WhenAll(
+ firstScope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None),
+ secondScope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None));
+
+ resolutions.ShouldAllBe(value => value is AgentRunLogStorageResolution.Ready);
+ resolutions.Cast().Select(value => value.StorageProfileId).Distinct().Count().ShouldBe(1);
+ using var readScope = _fixture.BeginScope();
+ var db = readScope.Resolve();
+ (await db.StorageProfile.CountAsync(value => value.TeamId == world.TeamId && value.StableName == AgentRunLogStorageReadiness.DefaultProfileStableName)).ShouldBe(1);
+ (await db.StorageRoute.CountAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey)).ShouldBe(1);
+ (await db.StorageRouteRevision.CountAsync(value => value.TeamId == world.TeamId && value.Route.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey)).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Default_route_persists_and_reads_log_bytes_through_the_profile_driven_local_runtime()
+ {
+ var world = await SeedWorldAsync();
+ var rootPath = Path.Combine(Path.GetTempPath(), "codespace-agent-log-default-runtime", Guid.NewGuid().ToString("N"));
+ using var settings = RuntimeSettings.Override(current => current with { ArtifactStoreDirectory = rootPath, ArtifactLocalRwxShared = true });
+ try
+ {
+ var captureSessionId = Guid.NewGuid();
+ var bytes = "durable-default-log"u8.ToArray();
+ Guid streamId;
+
+ using (var writeScope = _fixture.BeginScope())
+ {
+ var storage = (await writeScope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType();
+ var logs = writeScope.Resolve();
+ var opened = (await logs.OpenAsync(Open(world, captureSessionId, AgentRunLogKinds.StandardOutput), CancellationToken.None)).ShouldBeOfType();
+ streamId = opened.Metadata.StreamId;
+ var appended = await logs.AppendAsync(Append(world, streamId, captureSessionId, 1, 0, bytes) with
+ {
+ StorageProfileId = storage.StorageProfileId,
+ StorageProfileRevision = storage.StorageProfileRevision,
+ }, CancellationToken.None);
+
+ appended.ShouldBeOfType();
+ }
+
+ using (var readScope = _fixture.BeginScope())
+ {
+ var read = (await readScope.Resolve().ReadRangeAsync(
+ new AgentRunLogRangeRequest(world.TeamId, streamId, 0, bytes.Length), CancellationToken.None)).ShouldBeOfType();
+ read.Bytes.ShouldBe(bytes);
+ }
+ Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories).ShouldNotBeEmpty();
+ }
+ finally
+ {
+ if (Directory.Exists(rootPath)) Directory.Delete(rootPath, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task Missing_routes_get_tenant_defaults_while_inactive_invalid_routes_remain_authoritative_without_fallback()
{
var world = await SeedWorldAsync();
var foreign = await SeedWorldAsync();
+ using var settings = RuntimeSettings.Override(current => current with { ArtifactLocalRwxShared = true });
using var scope = _fixture.BeginScope();
var db = scope.Resolve();
var resolver = scope.Resolve();
- (await resolver.ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing));
- var route = await AddRouteAsync(db, world, world.StorageProfileId, StorageProfileRevisionMode.CurrentAtWrite);
- (await resolver.ResolveAsync(foreign.TeamId, CancellationToken.None)).ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing));
+ var ready = (await resolver.ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType();
+ var foreignReady = (await resolver.ResolveAsync(foreign.TeamId, CancellationToken.None)).ShouldBeOfType();
+ ready.StorageProfileId.ShouldNotBe(foreignReady.StorageProfileId);
+ var route = await db.StorageRoute.SingleAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey);
route.State = StorageRouteState.Disabled;
route.LastModifiedDate = DateTimeOffset.UtcNow;
@@ -425,6 +569,27 @@ private static void AddProfile(CodeSpaceDbContext db, World world, Guid profileI
db.StorageProfile.Add(profile);
}
+ private static void AddReservedDefaultProfile(CodeSpaceDbContext db, World world, string rootPath, DateTimeOffset now)
+ {
+ using var document = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath = Path.GetFullPath(rootPath) }));
+ var canonicalConfig = StorageProfileRules.CanonicalJson(document.RootElement);
+ using var canonical = JsonDocument.Parse(canonicalConfig);
+ var profile = new StorageProfile
+ {
+ Id = Guid.NewGuid(), TeamId = world.TeamId, StableName = AgentRunLogStorageReadiness.DefaultProfileStableName,
+ State = StorageProfileState.Active, CurrentRevision = 1, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
+ LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
+ };
+ profile.Revisions.Add(new StorageProfileRevision
+ {
+ Id = Guid.NewGuid(), TeamId = world.TeamId, StorageProfileId = profile.Id, Revision = 1,
+ ProviderTypeKey = "local-rwx/v1", NonSecretConfigJson = canonicalConfig, CredentialRef = null,
+ NamespaceFingerprint = StorageProfileRules.NamespaceFingerprint("local-rwx/v1", canonical.RootElement),
+ CreatedDate = now, CreatedBy = SystemUsers.SeederId,
+ });
+ db.StorageProfile.Add(profile);
+ }
+
private sealed record World(Guid TeamId, Guid ActorId, Guid StorageProfileId, Guid AgentRunId);
private sealed class StubCurrentTeam(Guid id) : ICurrentTeam
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentRunLogStorageResolverTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentRunLogStorageResolverTests.cs
index 71efbbfb2..4ae3ab7dc 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/AgentRunLogStorageResolverTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentRunLogStorageResolverTests.cs
@@ -18,19 +18,45 @@ public async Task Ready_route_forwards_the_exact_frozen_profile_coordinates_for_
NamespaceFingerprint = $"sha256:{new string('a', 64)}",
};
var route = new StubRouteResolver(new StorageRouteSnapshotResolution.Ready(snapshot));
+ var readiness = new StubReadiness();
- var result = await new AgentRunLogStorageResolver(route).ResolveAsync(teamId, CancellationToken.None);
+ var result = await new AgentRunLogStorageResolver(route, readiness).ResolveAsync(teamId, CancellationToken.None);
result.ShouldBe(new AgentRunLogStorageResolution.Ready(profileId, 11));
route.Requests.ShouldBe([new StorageRouteSnapshotRequest(teamId, "agent-run-log/v1")]);
+ readiness.TeamIds.ShouldBeEmpty();
}
[Fact]
- public async Task Route_policy_failures_are_typed_and_never_fall_back_to_an_unrelated_profile()
+ public async Task Missing_route_is_bootstrapped_once_then_resolved_through_the_same_exact_route()
+ {
+ var teamId = Guid.NewGuid();
+ var profileId = Guid.NewGuid();
+ var route = new StubRouteResolver(
+ new StorageRouteSnapshotResolution.Missing(),
+ new StorageRouteSnapshotResolution.Ready(new StorageRouteSnapshot
+ {
+ RouteId = Guid.NewGuid(), RouteRevision = 1, DataClassTypeKey = AgentRunLogStorageResolver.DataClassTypeKey,
+ StorageProfileId = profileId, StorageProfileRevision = 1, ProviderTypeKey = "local-rwx/v1",
+ NamespaceFingerprint = $"sha256:{new string('b', 64)}",
+ }));
+ var readiness = new StubReadiness();
+
+ var result = await new AgentRunLogStorageResolver(route, readiness).ResolveAsync(teamId, CancellationToken.None);
+
+ result.ShouldBe(new AgentRunLogStorageResolution.Ready(profileId, 1));
+ readiness.TeamIds.ShouldBe([teamId]);
+ route.Requests.ShouldBe([
+ new StorageRouteSnapshotRequest(teamId, AgentRunLogStorageResolver.DataClassTypeKey),
+ new StorageRouteSnapshotRequest(teamId, AgentRunLogStorageResolver.DataClassTypeKey),
+ ]);
+ }
+
+ [Fact]
+ public async Task Non_missing_route_policy_failures_are_typed_and_never_bootstrap_or_fall_back()
{
var cases = new (StorageRouteSnapshotResolution Resolution, AgentRunLogStorageProblemCode Expected)[]
{
- (new StorageRouteSnapshotResolution.Missing(), AgentRunLogStorageProblemCode.Missing),
(new StorageRouteSnapshotResolution.RouteNotActive(), AgentRunLogStorageProblemCode.Inactive),
(new StorageRouteSnapshotResolution.ProfileNotActive(), AgentRunLogStorageProblemCode.Inactive),
(new StorageRouteSnapshotResolution.RouteRevisionMissing(), AgentRunLogStorageProblemCode.Invalid),
@@ -43,12 +69,27 @@ public async Task Route_policy_failures_are_typed_and_never_fall_back_to_an_unre
foreach (var (resolution, expected) in cases)
{
var route = new StubRouteResolver(resolution);
- var result = await new AgentRunLogStorageResolver(route).ResolveAsync(Guid.NewGuid(), CancellationToken.None);
+ var readiness = new StubReadiness();
+ var result = await new AgentRunLogStorageResolver(route, readiness).ResolveAsync(Guid.NewGuid(), CancellationToken.None);
result.ShouldBe(new AgentRunLogStorageResolution.Unavailable(expected));
route.Requests.Count.ShouldBe(1);
+ readiness.TeamIds.ShouldBeEmpty();
}
}
+ [Fact]
+ public async Task Missing_route_that_cannot_be_bootstrapped_remains_typed_missing_without_another_profile_fallback()
+ {
+ var route = new StubRouteResolver(new StorageRouteSnapshotResolution.Missing(), new StorageRouteSnapshotResolution.Missing());
+ var readiness = new StubReadiness();
+
+ var result = await new AgentRunLogStorageResolver(route, readiness).ResolveAsync(Guid.NewGuid(), CancellationToken.None);
+
+ result.ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing));
+ readiness.TeamIds.Count.ShouldBe(1);
+ route.Requests.Count.ShouldBe(2);
+ }
+
[Fact]
public async Task Caller_cancellation_is_not_downgraded_into_capture_health()
{
@@ -56,17 +97,28 @@ public async Task Caller_cancellation_is_not_downgraded_into_capture_health()
cancellation.Cancel();
var route = new StubRouteResolver(new StorageRouteSnapshotResolution.Cancelled());
- await Should.ThrowAsync(() => new AgentRunLogStorageResolver(route).ResolveAsync(Guid.NewGuid(), cancellation.Token));
+ await Should.ThrowAsync(() => new AgentRunLogStorageResolver(route, new StubReadiness()).ResolveAsync(Guid.NewGuid(), cancellation.Token));
}
- private sealed class StubRouteResolver(StorageRouteSnapshotResolution result) : IStorageRouteSnapshotResolver
+ private sealed class StubRouteResolver(params StorageRouteSnapshotResolution[] results) : IStorageRouteSnapshotResolver
{
public List Requests { get; } = [];
public Task ResolveAsync(StorageRouteSnapshotRequest request, CancellationToken cancellationToken)
{
Requests.Add(request);
- return Task.FromResult(result);
+ return Task.FromResult(results[Math.Min(Requests.Count - 1, results.Length - 1)]);
+ }
+ }
+
+ private sealed class StubReadiness : IAgentRunLogStorageReadiness
+ {
+ public List TeamIds { get; } = [];
+
+ public Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken)
+ {
+ TeamIds.Add(teamId);
+ return Task.CompletedTask;
}
}
}
diff --git a/backend/tests/CodeSpace.UnitTests/Settings/ArtifactStorageQualificationTests.cs b/backend/tests/CodeSpace.UnitTests/Settings/ArtifactStorageQualificationTests.cs
new file mode 100644
index 000000000..548512d9b
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/Settings/ArtifactStorageQualificationTests.cs
@@ -0,0 +1,20 @@
+using CodeSpace.Core.Settings;
+using Microsoft.Extensions.Configuration;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.Settings;
+
+[Trait("Category", "Unit")]
+public sealed class ArtifactStorageQualificationTests
+{
+ [Fact]
+ public void Local_rwx_shared_qualification_is_false_unless_the_deployment_explicitly_enables_it()
+ {
+ RuntimeSettings.Read(Configuration([])).ArtifactLocalRwxShared.ShouldBeFalse();
+ RuntimeSettings.Read(Configuration(new() { ["Artifacts:LocalRwxShared"] = "false" })).ArtifactLocalRwxShared.ShouldBeFalse();
+ RuntimeSettings.Read(Configuration(new() { ["Artifacts:LocalRwxShared"] = "true" })).ArtifactLocalRwxShared.ShouldBeTrue();
+ }
+
+ private static IConfiguration Configuration(Dictionary values) =>
+ new ConfigurationBuilder().AddInMemoryCollection(values).Build();
+}
diff --git a/backend/tests/CodeSpace.UnitTests/Settings/DurableStorageTopologyTests.cs b/backend/tests/CodeSpace.UnitTests/Settings/DurableStorageTopologyTests.cs
index a18737531..022a6b3ab 100644
--- a/backend/tests/CodeSpace.UnitTests/Settings/DurableStorageTopologyTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Settings/DurableStorageTopologyTests.cs
@@ -25,6 +25,28 @@ public void Worker_spool_is_a_declared_named_volume(string relativePath, string
volumes.Children.Keys.Select(ScalarValue).ShouldContain(volumeName);
}
+ [Theory]
+ [InlineData("docker-compose.yml", "codespace-artifacts")]
+ [InlineData("backend/deploy/e2e/docker-compose.e2e.yml", "artifacts")]
+ public void Local_rwx_is_qualified_only_in_stacks_where_api_and_worker_share_one_declared_artifact_volume(string relativePath, string volumeName)
+ {
+ var root = Mapping(Load(relativePath));
+ var services = Mapping(root.Children[Scalar("services")]);
+ var volumes = Mapping(root.Children[Scalar("volumes")]);
+
+ foreach (var serviceName in new[] { "api", "worker" })
+ {
+ var service = Mapping(services.Children[Scalar(serviceName)]);
+ var environment = Mapping(service.Children[Scalar("environment")]);
+ var mounts = Sequence(service.Children[Scalar("volumes")]).Children.Select(ScalarValue).ToArray();
+
+ ScalarValue(environment.Children[Scalar("Artifacts__LocalRwxShared")]).ShouldBe("true");
+ mounts.ShouldContain($"{volumeName}:{CodeSpace.Core.Settings.DurableRoots.ContainerArtifactStore}");
+ }
+
+ volumes.Children.Keys.Select(ScalarValue).ShouldContain(volumeName);
+ }
+
private static YamlNode Load(string relativePath)
{
using var reader = File.OpenText(Path.Combine(FindRepoRoot(), relativePath));
diff --git a/docker-compose.yml b/docker-compose.yml
index dc4dcb4ea..47b790620 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -74,6 +74,8 @@ services:
# Stated explicitly rather than inheriting appsettings.json's committed dev key — a Production-labelled pod
# silently signing tokens with a source-controlled key is exactly the footgun this line makes visible.
Authentication__Jwt__SymmetricKey: "${CODESPACE_JWT_SYMMETRIC_KEY:-local-dev-only-jwt-key-minimum-32-chars-long}"
+ # Both app roles mount the same named artifact volume below, so local-rwx is a genuinely shared namespace.
+ Artifacts__LocalRwxShared: "true"
volumes:
# Artifact metadata lives in Postgres, but offloaded bytes must be visible to both roles. An image-layer path
# has the same spelling in each container while still being a different filesystem, and disappears on recreate.
@@ -116,6 +118,7 @@ services:
# Both pods share ONE key-ring and ONE token signer, so these MUST match the api service's values above.
CODESPACE_VARIABLE_MASTER_KEY: "${CODESPACE_VARIABLE_MASTER_KEY:-bG9jYWwtZGV2LW9ubHkta2V5LW5vdC1mb3ItcHJvZCE=}"
Authentication__Jwt__SymmetricKey: "${CODESPACE_JWT_SYMMETRIC_KEY:-local-dev-only-jwt-key-minimum-32-chars-long}"
+ Artifacts__LocalRwxShared: "true"
# The full tool fabric and its governance used to be turned on here. Both are committed constants now
# (AgentRunExecutor.FullToolCatalogByDefault, McpRequestHandler.GovernanceEnabled) at exactly the values this
# file set, so every deployment gets the posture this one had rather than whatever its env happened to say.