diff --git a/client/src/pages/SeatsPage.tsx b/client/src/pages/SeatsPage.tsx index 3017aaa..f2b1c6d 100644 --- a/client/src/pages/SeatsPage.tsx +++ b/client/src/pages/SeatsPage.tsx @@ -105,7 +105,12 @@ export function SeatsPage() { if (!organizationId) return void run(async () => { setTeams(await api.get(`/api/orgboard/teams?organizationId=${organizationId}`)) - setSkills(await api.get('/api/skills/')) + // The org's library = shared builtins + its own authored/installed skills. The API returns + // every version (newest first per key); collapse to one selectable entry per key. + const lib = await api.get(`/api/skills/?organizationId=${organizationId}`) + const byKey = new Map() + for (const s of lib) if (!byKey.has(s.skillKey)) byKey.set(s.skillKey, s) + setSkills([...byKey.values()]) await loadConfigs() }) }, [organizationId, loadConfigs, run]) @@ -359,9 +364,9 @@ export function SeatsPage() { )}
{skills.map((skill) => ( - ))} diff --git a/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs b/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs index 8781596..882e0f9 100644 --- a/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs +++ b/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs @@ -39,7 +39,7 @@ internal sealed class AgentRunExecutor( var context = await contextProvider.GetAsync(run.SeatId, run.WorkItemId, cancellationToken) ?? throw new InvalidOperationException("Agent or task not found for the run."); - var skills = await skillCatalog.GetByKeysAsync(context.SkillKeys, cancellationToken); + var skills = await skillCatalog.GetByKeysAsync(context.OrganizationId, context.SkillKeys, cancellationToken); // Working memory: recall the team's most relevant decisions/corrections for this task. var memories = await teamMemory.SearchAsync( diff --git a/src/Modules/TeamUp.Modules.Assembler/Runtime/PromptAssembler.cs b/src/Modules/TeamUp.Modules.Assembler/Runtime/PromptAssembler.cs index 7fccab6..ffde293 100644 --- a/src/Modules/TeamUp.Modules.Assembler/Runtime/PromptAssembler.cs +++ b/src/Modules/TeamUp.Modules.Assembler/Runtime/PromptAssembler.cs @@ -35,7 +35,7 @@ internal static class PromptAssembler builder.AppendLine("# Skills"); foreach (var skill in ordered) { - builder.AppendLine("## " + skill.Name).AppendLine(skill.Body).AppendLine(); + builder.AppendLine("## " + skill.Name + " (v" + skill.Version + ")").AppendLine(skill.Body).AppendLine(); } if (context.Docs.Count > 0) @@ -69,7 +69,7 @@ internal static class PromptAssembler { agent = context.AgentName, autonomy = context.Autonomy.ToString(), - skills = ordered.Select(s => s.Key).ToArray(), + skills = ordered.Select(s => s.Key + "@" + s.Version).ToArray(), docs = context.Docs, memories = memories.Count, apiConfigId = context.ApiConfigId, diff --git a/src/Modules/TeamUp.Modules.Skills/Catalog/SkillCatalog.cs b/src/Modules/TeamUp.Modules.Skills/Catalog/SkillCatalog.cs index fe4e7aa..261ce9e 100644 --- a/src/Modules/TeamUp.Modules.Skills/Catalog/SkillCatalog.cs +++ b/src/Modules/TeamUp.Modules.Skills/Catalog/SkillCatalog.cs @@ -9,6 +9,7 @@ namespace TeamUp.Modules.Skills.Catalog; internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog { public async Task> GetByKeysAsync( + Guid organizationId, IReadOnlyCollection keys, CancellationToken cancellationToken = default) { @@ -18,17 +19,28 @@ internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog } var wanted = keys.ToHashSet(); - var skills = await db.Skills.Where(s => wanted.Contains(s.SkillKey)).ToListAsync(cancellationToken); + + // Org-scoped: only this org's own skills or shared builtins (null org) — never another org's. + // Published-only, so a run never executes an ungated (Draft) skill. + var skills = await db.Skills + .Where(s => wanted.Contains(s.SkillKey) + && s.Status == SkillStatus.Published + && (s.OrganizationId == organizationId || s.OrganizationId == null)) + .ToListAsync(cancellationToken); return skills .GroupBy(s => s.SkillKey) - .Select(group => group.OrderByDescending(s => s.Version, StringComparer.Ordinal).First()) + .Select(group => group + .OrderByDescending(s => s.OrganizationId == organizationId) // a forked/authored org skill beats the builtin + .ThenByDescending(s => s.Version, StringComparer.Ordinal) // then the latest version in that scope + .First()) .Select(s => { var primary = s.Actions.Count > 0 ? s.Actions[0] : null; return new SkillPrompt( s.SkillKey, s.Name, + s.Version, s.Body, primary?.Name ?? "respond", (primary?.Risk ?? ActionRisk.Draft).ToString(), diff --git a/src/Shared/TeamUp.SharedKernel/Ai/ISkillCatalog.cs b/src/Shared/TeamUp.SharedKernel/Ai/ISkillCatalog.cs index 83cc497..94ed9e6 100644 --- a/src/Shared/TeamUp.SharedKernel/Ai/ISkillCatalog.cs +++ b/src/Shared/TeamUp.SharedKernel/Ai/ISkillCatalog.cs @@ -4,15 +4,21 @@ namespace TeamUp.SharedKernel.Ai; public sealed record SkillPrompt( string Key, string Name, + string Version, string Body, string PrimaryAction, string PrimaryActionRisk, IReadOnlyList Roles); -/// Resolves skill prompts by key (latest version). Implemented by the Skills module. +/// +/// Resolves skill prompts for one org's agent run. Each key resolves within that org's namespace +/// only — the org's own (published) skill, else a shared builtin — never another org's; latest +/// version, org-owned preferred over builtin. Implemented by the Skills module. +/// public interface ISkillCatalog { Task> GetByKeysAsync( + Guid organizationId, IReadOnlyCollection keys, CancellationToken cancellationToken = default); } diff --git a/tests/TeamUp.IntegrationTests/SkillRunScopingTests.cs b/tests/TeamUp.IntegrationTests/SkillRunScopingTests.cs new file mode 100644 index 0000000..8d75cbf --- /dev/null +++ b/tests/TeamUp.IntegrationTests/SkillRunScopingTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using TeamUp.Modules.Assembler.Queue; +using TeamUp.Modules.Assembler.Runtime; +using TeamUp.Modules.Skills.Domain; +using TeamUp.Modules.Skills.Indexing; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// Closes the library → delivery loop: when an AI seat runs a task, the skill bodies assembled into +/// its prompt are resolved within the org's namespace only — the org's own (authored/forked) skill is +/// preferred over the shared builtin of the same key, and another org's same-key skill never leaks in. +/// +public sealed class SkillRunScopingTests(PostgresFixture postgres) : IClassFixture +{ + private const string OrgABody = "ACME_ORG_A_CUSTOM_IMPL_BODY — our house implementation steps."; + private const string OrgBPoison = "ORG_B_POISON_BODY_MUST_NEVER_APPEAR_IN_ANOTHER_ORGS_PROMPT."; + + private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); + + private sealed record IdResponse(Guid Id); + + private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name); + + private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId); + + private sealed record TaskResponse(Guid Id); + + private sealed record SyncResult(int Indexed); + + private sealed record RunResponse( + Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status, + string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error); + + [Fact] + public async Task A_run_assembles_the_orgs_own_skill_over_builtin_and_never_another_orgs() + { + var settings = new Dictionary + { + ["GitSource:Provider"] = "filesystem", + ["GitSource:Root"] = LocateSkillsDirectory(), + }; + + await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings); + using var anon = factory.CreateClient(); + + var owner = await PostOk(anon, "/api/identity/bootstrap", new + { + organizationName = "OrgA", + ownerEmail = "owner@alia.test", + ownerDisplayName = "Owner", + ownerPassword = "Passw0rd!", + }); + using var client = Authed(factory, owner.Token); + + await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "OrgA" }); + var team = await PostOk(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" }); + var config = await PostOk(client, "/api/integrations/api-configs", new + { + organizationId = owner.OrganizationId, + name = "Stub", + provider = "stub", + model = "gemini-pro", + apiKey = "sk-demo-key", + }); + + // The shared builtin "code-implementation" exists (Published) after a sync. + var sync = await PostOk(client, "/api/skills/sync", new { }); + Assert.True(sync.Indexed >= 8); + + // Org A authors its OWN "code-implementation" (same key as the builtin) with a distinctive body. + await PostOk(client, "/api/skills/authored", new + { + organizationId = owner.OrganizationId, + skillKey = "code-implementation", + name = "ACME Code Impl", + version = "1.0.0", + summary = "Our house implementation skill.", + roles = new[] { "engineer" }, + inputs = (string?)null, + outputs = (string?)null, + actions = new[] { new { name = "implement-code", risk = "draft", description = (string?)null } }, + tools = Array.Empty(), + context = Array.Empty(), + visibility = "private", + minTier = "free", + body = OrgABody, + goldenTests = new[] { new { input = "task", expected = "code" } }, + }); + + // A DIFFERENT org publishes its own "code-implementation" — it must never reach org A's prompt. + await SeedSkillAsync(factory, Guid.NewGuid(), "code-implementation", OrgBPoison); + + var seat = await PostOk(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Backend Engineer" }); + await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new + { + name = "Edison", + monogram = "ED", + autonomy = "Gated", + apiConfigId = config.Id, + skillKeys = new[] { "code-implementation" }, + docs = Array.Empty(), + }); + + var task = await PostOk(client, "/api/orgboard/tasks", new + { + teamId = team.Id, + title = "Implement the logout endpoint", + description = "POST /logout clears the session.", + type = "Story", + }); + + var run = await PostOk(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id }); + await DrainOneJob(factory); + + var done = await client.GetFromJsonAsync($"/api/assembler/runs/{run.Id}"); + Assert.Equal("Completed", done!.Status); + Assert.Equal("implement-code", done.ActionType); + + // The org's own skill body was assembled in — preferred over the builtin of the same key… + Assert.Contains(OrgABody, done.Prompt); + // …and another org's same-key skill never leaked across the tenant boundary. + Assert.DoesNotContain(OrgBPoison, done.Prompt); + } + + private static async Task SeedSkillAsync(TeamUpWebFactory factory, Guid orgId, string key, string body) + { + using var scope = factory.Services.CreateScope(); + var indexer = scope.ServiceProvider.GetRequiredService(); + var manifest = new SkillManifest + { + Id = key, + Name = key, + Version = "1.0.0", + Summary = "another org's skill", + Roles = ["engineer"], + Visibility = "public", + GoldenTests = [new GoldenExample { Input = "task", Expected = "code" }], + }; + await indexer.IndexAsync(manifest, body, SkillOwnership.Authored(orgId, Guid.NewGuid())); + } + + private static async Task DrainOneJob(TeamUpWebFactory factory) + { + await using var scope = factory.Services.CreateAsyncScope(); + var queue = scope.ServiceProvider.GetRequiredService(); + var job = await queue.ClaimNextAsync("test-worker"); + Assert.NotNull(job); + await scope.ServiceProvider.GetRequiredService().ProcessAsync(job!); + } + + private static HttpClient Authed(TeamUpWebFactory factory, string token) + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey); + return client; + } + + private static async Task PostOk(HttpClient client, string url, object body) + { + var response = await client.PostAsJsonAsync(url, body); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var value = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(value); + return value!; + } + + private static string LocateSkillsDirectory() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx"))) + { + dir = dir.Parent; + } + + Assert.NotNull(dir); + return Path.Combine(dir!.FullName, "skills"); + } +}