Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RockBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
<Project Path="tests/RockBot.Subagent.Tests/RockBot.Subagent.Tests.csproj" />
<Project Path="tests/RockBot.Wisp.Tests/RockBot.Wisp.Tests.csproj" />
<Project Path="tests/RockBot.Llm.Copilot.Tests/RockBot.Llm.Copilot.Tests.csproj" />
<Project Path="tests/RockBot.UserProxy.Blazor.Tests/RockBot.UserProxy.Blazor.Tests.csproj" />
<Project Path="tests/RockBot.A2A.IntegrationTests/RockBot.A2A.IntegrationTests.csproj" />
<Project Path="tests/RockBot.A2A.Gateway/RockBot.A2A.Gateway.csproj" />
</Folder>
<Project Path="src/RockBot.Messaging.Abstractions/RockBot.Messaging.Abstractions.csproj" />
<Project Path="src/RockBot.Messaging.RabbitMQ/RockBot.Messaging.RabbitMQ.csproj" />
Expand Down
29 changes: 29 additions & 0 deletions deploy/Dockerfile.a2a-gateway
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:0a506ab0c8aa077361af42f82569d364ab1b8741e967955d883e3f23683d473a AS build
WORKDIR /src

RUN printf '<configuration><fallbackPackageFolders><clear /></fallbackPackageFolders></configuration>' \
> /src/NuGet.config

COPY RockBot.slnx Directory.Build.props ./
COPY src/ src/
COPY tests/ tests/

RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish tests/RockBot.A2A.Gateway/RockBot.A2A.Gateway.csproj \
-c Release -o /app/publish

# Runtime stage — ASP.NET Core needed for web hosting
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app

RUN groupadd -r rockbot && useradd -r -g rockbot rockbot

COPY --from=build /app/publish .

RUN chown -R rockbot:rockbot /app
USER rockbot

EXPOSE 5200
ENTRYPOINT ["dotnet", "RockBot.A2A.Gateway.dll"]
31 changes: 31 additions & 0 deletions deploy/Dockerfile.a2a-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:0a506ab0c8aa077361af42f82569d364ab1b8741e967955d883e3f23683d473a AS build
WORKDIR /src

# Clear Windows-specific NuGet fallback folders that break Linux builds
RUN printf '<configuration><fallbackPackageFolders><clear /></fallbackPackageFolders></configuration>' \
> /src/NuGet.config

COPY RockBot.slnx Directory.Build.props ./
COPY src/ src/
COPY tests/ tests/

# BuildKit cache mount avoids re-downloading packages on every build
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish tests/RockBot.A2A.IntegrationTests/RockBot.A2A.IntegrationTests.csproj \
-c Release -o /app/publish

# Runtime stage — console app, no ASP.NET needed
FROM mcr.microsoft.com/dotnet/runtime:10.0@sha256:3de49150e48790fa845547e14bff5add0e4194a8901e727cf88f83423bcbe2b0 AS runtime
WORKDIR /app

# Non-root user
RUN groupadd -r rockbot && useradd -r -g rockbot rockbot

COPY --from=build /app/publish .

RUN chown -R rockbot:rockbot /app
USER rockbot

ENTRYPOINT ["dotnet", "RockBot.A2A.IntegrationTests.dll"]
129 changes: 129 additions & 0 deletions deploy/docker-compose/docker-compose.a2a-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: rockbot-a2a-test

services:
rabbitmq:
image: rabbitmq:4-management
hostname: rabbitmq
environment:
RABBITMQ_DEFAULT_USER: rockbot
RABBITMQ_DEFAULT_PASS: rockbot
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 5s
timeout: 3s
retries: 15

agent-init:
build:
context: ../..
dockerfile: deploy/Dockerfile.agent
image: rockylhotka/rockbot-agent:latest
user: root
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
echo "Seeding agent data volume for A2A test..."
for f in soul.md directives.md subagent-directives.md style.md memory-rules.md \
dream.md skill-dream.md common-directives.md session-evaluator.md \
session-start.md heartbeat-patrol.md skill-optimize.md \
dlq-dream.md routing-dream.md; do
src="/app/agent/$$f"
dst="/data/agent/$$f"
if [ -f "$$src" ] && [ ! -s "$$dst" ]; then
cp "$$src" "$$dst"
fi
done
if [ -f /app/agent/well-known-agents.json ] && [ ! -s /data/agent/well-known-agents.json ]; then
cp /app/agent/well-known-agents.json /data/agent/well-known-agents.json
fi
# Minimal mcp.json — no external MCP servers needed for A2A test
if [ ! -f /data/agent/mcp.json ]; then
echo '{"mcpServers":{}}' > /data/agent/mcp.json
fi
# Per-model behavior files
for model_dir in /app/model-behaviors/*/; do
[ -d "$$model_dir" ] || continue
model_name=$$(basename "$$model_dir")
mkdir -p "/data/agent/model-behaviors/$$model_name"
for src in "$$model_dir"*; do
[ -f "$$src" ] || continue
dst="/data/agent/model-behaviors/$$model_name/$$(basename $$src)"
if [ ! -s "$$dst" ]; then
cp "$$src" "$$dst"
fi
done
done
mkdir -p /data/agent/memory /data/agent/skills /data/agent/conversations /data/agent/feedback
chmod -R 777 /data/agent
echo "Agent data volume ready."
volumes:
- agent-data:/data/agent

rockbot-agent:
build:
context: ../..
dockerfile: deploy/Dockerfile.agent
image: rockylhotka/rockbot-agent:latest
depends_on:
agent-init:
condition: service_completed_successfully
rabbitmq:
condition: service_healthy
environment:
RabbitMq__HostName: rabbitmq
RabbitMq__Port: "5672"
RabbitMq__UserName: rockbot
RabbitMq__Password: rockbot
RabbitMq__VirtualHost: /
AgentProfile__BasePath: /data/agent
Memory__BasePath: /data/agent/memory
Skill__BasePath: /data/agent/skills
McpBridge__ConfigPath: /data/agent/mcp.json
ModelBehaviors__BasePath: /data/agent/model-behaviors
Agent__Timezone: America/Chicago
# No LLM config — falls back to EchoChatClient (free, deterministic)
volumes:
- agent-data:/data/agent
- shared:/rockbot/shared

a2a-gateway:
build:
context: ../..
dockerfile: deploy/Dockerfile.a2a-gateway
depends_on:
rockbot-agent:
condition: service_started
rabbitmq:
condition: service_healthy
environment:
ASPNETCORE_URLS: http://0.0.0.0:5200
RabbitMq__HostName: rabbitmq
RabbitMq__Port: "5672"
RabbitMq__UserName: rockbot
RabbitMq__Password: rockbot

a2a-test-harness:
build:
context: ../..
dockerfile: deploy/Dockerfile.a2a-test
depends_on:
a2a-gateway:
condition: service_started
rockbot-agent:
condition: service_started
rabbitmq:
condition: service_healthy
environment:
RabbitMq__HostName: rabbitmq
RabbitMq__Port: "5672"
RabbitMq__UserName: rockbot
RabbitMq__Password: rockbot
A2A_GATEWAY_URL: http://a2a-gateway:5200
TRUST_STORE_PATH: /data/agent/agent-trust.json
volumes:
- agent-data:/data/agent:ro

volumes:
agent-data:
shared:
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
26 changes: 26 additions & 0 deletions src/RockBot.A2A.Abstractions/AgentTrustEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace RockBot.A2A;

/// <summary>
/// Per-caller trust record tracking the trust level, approved skills, and
/// interaction history for an external agent identified by <see cref="AgentId"/>.
/// </summary>
public sealed record AgentTrustEntry
{
/// <summary>Canonical unique identifier for the caller (from <see cref="VerifiedAgentIdentity.AgentId"/>).</summary>
public required string AgentId { get; init; }

/// <summary>Current trust level for this caller.</summary>
public required AgentTrustLevel Level { get; init; }

/// <summary>Skill IDs this caller is approved to invoke autonomously (Level 4).</summary>
public IReadOnlyList<string> ApprovedSkills { get; init; } = [];

/// <summary>When this caller was first seen.</summary>
public DateTimeOffset FirstSeen { get; init; }

/// <summary>When the last interaction with this caller occurred.</summary>
public DateTimeOffset LastInteraction { get; init; }

/// <summary>Total number of inbound tasks received from this caller.</summary>
public int InteractionCount { get; init; }
}
20 changes: 20 additions & 0 deletions src/RockBot.A2A.Abstractions/AgentTrustLevel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace RockBot.A2A;

/// <summary>
/// Trust level assigned to an external agent caller. Each caller progresses
/// through these levels independently based on user approval.
/// </summary>
public enum AgentTrustLevel
{
/// <summary>Read-only access; summarize request and notify user.</summary>
Observe = 1,

/// <summary>Same as Observe, but system observes user responses and proposes skill drafts.</summary>
Learn = 2,

/// <summary>System has candidate skills and asks user to approve them.</summary>
Propose = 3,

/// <summary>Approved skills execute autonomously; results reported to user post-hoc.</summary>
Act = 4
}
18 changes: 18 additions & 0 deletions src/RockBot.A2A.Abstractions/IAgentIdentityVerifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using RockBot.Messaging;

namespace RockBot.A2A;

/// <summary>
/// Verifies the identity of an agent from an inbound message envelope.
/// Implementations may inspect headers (tokens, signatures), the Source field,
/// or any other envelope metadata to establish a verified identity.
/// Register a custom implementation via DI to replace the default name-based verifier.
/// </summary>
public interface IAgentIdentityVerifier
{
/// <summary>
/// Verifies the sender identity from the envelope metadata.
/// Returns a <see cref="VerifiedAgentIdentity"/> on success, or throws if verification fails.
/// </summary>
Task<VerifiedAgentIdentity> VerifyAsync(MessageEnvelope envelope, CancellationToken ct);
}
24 changes: 24 additions & 0 deletions src/RockBot.A2A.Abstractions/IAgentTrustStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace RockBot.A2A;

/// <summary>
/// Persistent store for per-caller trust entries. Implementations must be
/// thread-safe — concurrent A2A requests may read/write simultaneously.
/// </summary>
public interface IAgentTrustStore
{
/// <summary>
/// Returns the trust entry for <paramref name="agentId"/>, creating a new
/// entry at <see cref="AgentTrustLevel.Observe"/> if none exists.
/// </summary>
Task<AgentTrustEntry> GetOrCreateAsync(string agentId, CancellationToken ct);

/// <summary>
/// Persists an updated trust entry. The entry is matched by <see cref="AgentTrustEntry.AgentId"/>.
/// </summary>
Task UpdateAsync(AgentTrustEntry entry, CancellationToken ct);

/// <summary>
/// Returns all known trust entries.
/// </summary>
Task<IReadOnlyList<AgentTrustEntry>> ListAsync(CancellationToken ct);
}
40 changes: 40 additions & 0 deletions src/RockBot.A2A.Abstractions/VerifiedAgentIdentity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace RockBot.A2A;

/// <summary>
/// The result of identity verification for an inbound agent message.
/// <see cref="AgentId"/> is the stable key used for trust tracking.
/// </summary>
public sealed record VerifiedAgentIdentity
{
/// <summary>
/// Key used to store/retrieve <see cref="VerifiedAgentIdentity"/> in
/// <see cref="RockBot.Host.MessageHandlerContext.Items"/>.
/// </summary>
public const string ContextKey = "verified-identity";

/// <summary>
/// Canonical unique identifier for the agent. Used as the key in trust stores.
/// For name-based verification this equals the Source string; for registry-backed
/// verification it would be a registry-issued identifier.
/// </summary>
public required string AgentId { get; init; }

/// <summary>Human-readable display name for the agent.</summary>
public required string DisplayName { get; init; }

/// <summary>
/// Who vouched for this identity (e.g. "self", a registry URL, an IdP issuer).
/// </summary>
public string? Issuer { get; init; }

/// <summary>
/// Extensible claims extracted during verification (e.g. roles, scopes, OBO subject).
/// </summary>
public IReadOnlyDictionary<string, string>? Claims { get; init; }

/// <summary>
/// True when identity is based solely on the sender's self-asserted Source string
/// with no cryptographic or registry-backed verification.
/// </summary>
public bool IsSelfAsserted { get; init; }
}
7 changes: 7 additions & 0 deletions src/RockBot.A2A/A2AOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ public sealed class A2AOptions
/// </summary>
public TimeSpan DirectoryEntryTtl { get; set; } = TimeSpan.FromHours(24);

/// <summary>
/// Path to the file where per-caller trust entries are persisted.
/// Relative paths are resolved from <see cref="AppContext.BaseDirectory"/>.
/// Set to null or empty to disable persistence.
/// </summary>
public string? TrustStorePath { get; set; } = "agent-trust.json";

/// <summary>
/// Statically-configured agents that are always included in <c>list_known_agents</c>
/// regardless of whether they have announced themselves on the discovery bus.
Expand Down
10 changes: 10 additions & 0 deletions src/RockBot.A2A/A2AServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ public static AgentHostBuilder AddA2A(
sp => sp.GetRequiredService<AgentDirectory>());
}

// Identity verification — default to name-based; users can override via DI
builder.Services.TryAddSingleton<IAgentIdentityVerifier, NameBasedAgentIdentityVerifier>();

// Trust store — default to file-backed; users can override via DI
builder.Services.TryAddSingleton<IAgentTrustStore>(sp =>
new FileAgentTrustStore(options.TrustStorePath));

// Identity verification middleware — verifies A2A inbound messages
builder.UseMiddleware<IdentityVerificationMiddleware>();

// Summarizer — uses ILlmClient if available, otherwise falls back gracefully
builder.Services.TryAddSingleton<AgentCardSummarizer>();

Expand Down
Loading
Loading