docs(a2a): client exposures, URLs without queries, README/architecture

This commit is contained in:
EugeneTes
2026-10-08 14:42:18 +00:00
parent 1817272b73
commit 923a6e04ac
6 changed files with 59 additions and 6 deletions

View File

@@ -19,6 +19,9 @@ Claude Code as a model provider without an API key.
| Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge |
| Google Calendar (tool + connect commands) | `extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`; one account per chat |
| Scheduler (scheduled prompts, watcher scripts) | `extensions/Volcanic.AgenticCore.Extensions.Scheduler` | Cronos; per-chat jobs in `<agent dir>/scheduler/jobs.json`; see [docs/telegram-bot.md](docs/telegram-bot.md#scheduling) |
| MCP client (stdio servers as tools, Playwright browser preset) | `extensions/Volcanic.AgenticCore.Extensions.Mcp` | See [docs/mcp.md](docs/mcp.md) |
| A2A client (`a2a` tool, watches for long remote tasks) | `extensions/Volcanic.AgenticCore.Extensions.A2A` | `A2AClient__Enabled`; agents in `<workspace>/.volcanic/a2a/agents.json`; see [docs/a2a.md](docs/a2a.md#client) |
| A2A server (other agents call the assistant) | `src/Volcanic.AgenticCore.A2A.Server` | `A2A__Enabled`; callers in `<workspace>/.volcanic/a2a/callers.json`; see [docs/a2a.md](docs/a2a.md) |
| Telegram bot | `hosts/Volcanic.AgenticCore.Telegram` | Chat with the agent from Telegram; long polling, Claude Code, allow-list |
| Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
| Tests | `tests/` | xunit; no test calls a paid API |

View File

@@ -64,6 +64,10 @@ malformed task file is read as "no such task" (`TaskNotFound`) without a log lin
Without it: name from `A2A:Card:Name`, description from the first non-empty line of the workspace's `PERSONA.md`
(else `A2A:Card:Description`), and one generic `chat` skill.
The [client](#client) keeps its own files in the same directory: `agents.json` (the agents this workspace's assistant
may call, with their keys; see [agents.json](#agentsjson)) and `pending.json` (its watches of remote tasks; see
[Watches](#watches)).
`tasks/<taskId>.json`: one task per file (the SDK's `AgentTask` plus which caller owns it and its push configs),
written atomically (temp file, then rename) and read fresh on every request.
@@ -154,7 +158,11 @@ zone into its sessions (`docs/telegram-bot.md#a2a`); a host that embeds the libr
- reading `.volcanic/a2a/callers.json` — every caller's plaintext key — and editing it, i.e. minting itself (or
anyone) new keys or changing other callers' entries;
- writing `.volcanic/scheduler/jobs.json` or `.volcanic/triggers/triggers.json`, whose prompts later run in the
owner's **main** session — the one that has the calendar and the owner's history.
owner's **main** session — the one that has the calendar and the owner's history;
- with the [client](#client) on, forging `.volcanic/a2a/pending.json`: a record with an `undelivered` text runs as a
prompt in the owner's chat on the next restart or session start;
- editing `.volcanic/a2a/agents.json` to point an agent name somewhere else, so the owner's next `send` to that
name — message, attachments and the configured key — goes there (and reading the keys in it).
These are accepted v1 exposures: the owner chose plain files with no protection. Give A2A keys only to agents you
would trust with that workspace.
@@ -215,7 +223,8 @@ Off (the default), no session gets the tool, no watches are restored, and `/a2a`
Only the owner's own chats get the `a2a` tool: the Telegram host adds `A2AClientExtension` to each allowed chat's
session (target = the chat id), never host-wide. So the isolated sessions the server opens for inbound A2A callers
(untrusted) can't use the keys in `agents.json` to call other agents. The startup log shows
(untrusted) can't call other agents through the tool (their `bash` can still read the keys in `agents.json`; see
[Client security](#client-security)). The startup log shows
`A2A client: on, agents in each chat's .volcanic/a2a/agents.json, watches subscribe or poll` (or `push at <url>`).
### agents.json
@@ -350,8 +359,10 @@ configured subscribes instead. On shutdown the watches stop and their records st
Every outgoing message carries `metadata["volcanic.hops"]` = the session's inbound hop count + 1. The count comes from
the `a2a.inbound` event the server's per-session extension emits before each inbound prompt; a session with no inbound
A2A counts 0. A Volcanic server refuses a task over its `MaxHops` (3), which stops two assistants delegating to each
other forever. In Telegram only the owner's chats have the tool, so their messages always carry 1; the listener
matters for hosts that add the extension to sessions that also receive A2A.
other forever. In Telegram only the owner's chats have the tool, so their messages always carry 1 and the listener is
inactive: the remote side's `MaxHops` (or whatever guard a non-Volcanic remote has) is the only thing that bounds a
chain of agents delegating onwards. The listener matters for hosts that add the extension to
sessions that also receive A2A.
### Client configuration
@@ -372,6 +383,10 @@ ignores any configured value otherwise.
- **`agents.json` keys are plaintext in the workspace**, like `callers.json`: anything that reads the workspace (the
agent's `bash`, an inbound A2A caller's isolated session, a backup) can read them.
- **An inbound A2A caller's isolated session can tamper with the client's files** through `bash` in the shared
workspace: forge `pending.json` (an `undelivered` text runs as a prompt in the owner's chat on restart or session
start) or redirect an agent name in `agents.json` (the owner's next `send`, its attachments and the key go to the new
URL). Accepted v1 exposures, like `jobs.json` (see [Security](#security)).
- **The agent chooses where data goes.** It can edit `agents.json` like any workspace file, so it can add an agent with
any URL (including internal addresses; the client's HTTP has no private-range guard) and send it workspace files.
That is the same trust it already has through `bash` (`curl`), not a new boundary; treat the `a2a` tool accordingly.

View File

@@ -6,7 +6,7 @@
src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers)
src/Volcanic.AgenticCore runtime, organised as vertical slices
src/Volcanic.AgenticCore.A2A.Server A2A protocol server on the official SDK, built on the core
extensions/* provider and feature extensions (MCP client, Google Calendar, Scheduler), built on the public API only
extensions/* provider and feature extensions (MCP client, Google Calendar, Scheduler, A2A client), built on the public API only
samples/* reference hosts
tests/* xunit
```
@@ -26,6 +26,12 @@ directories.
on a `TimeProvider` timer, jobs in each target's workspace, `.volcanic/scheduler/jobs.json`), a per-session `schedule` tool bound to a
host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)).
`A2A` is the A2A client, a feature extension: a per-session `a2a` tool bound to a host target over the workspace's
`.volcanic/a2a/agents.json`, and a process-wide `A2AWatchRegistry` that follows long remote tasks (push, subscribe or
poll), persists them in `.volcanic/a2a/pending.json` and hands results to an `IA2AResultDelivery` the host may
implement (default: the target's live session). Hosts add it per session, never host-wide, so A2A server sessions
don't get it (see [a2a.md](a2a.md#client)).
`Volcanic.AgenticCore.A2A.Server` exposes assistants over [A2A](a2a.md) on the official `A2A`/`A2A.AspNetCore` SDK.
It depends on the core (`IAgentSessionFactory`, `AgentSessionOptions`) directly, not through the extension API,
since it needs ASP.NET Core hosting the extension contract doesn't carry. Tenancy is structural: the SDK's

View File

@@ -230,5 +230,13 @@ What the code does where it differs from the sections above (the user guide, `do
- **Attachments**: paths are confined to the workspace after resolving symlinks and capped together at
`MaxFileBytes`; special files (FIFOs, devices) aren't detected, so a FIFO blocks the call until the run is aborted.
- **`IA2AResultDelivery.WorkspaceFor` returns `string?`** (null: unknown target).
- **Accepted v1 exposures** (like the server's `jobs.json` ones): an inbound A2A caller's isolated session shares the
workspace and has `bash`, so it can read the keys in `agents.json`, redirect an agent name there (the owner's next
`send`, attachments and key go to the new URL), and forge `pending.json` (an `undelivered` text runs as a prompt in
the owner's chat on restart or session start). It can't call other agents through the tool. In Telegram the hop
listener is inactive (only the owner's chats have the tool, so every message carries 1); the remote's `MaxHops` is
the only chain guard.
- **`/a2a` shows agent URLs as scheme, host, port and path only** (no query, fragment or user info, which may carry
credentials).
- **Docker**: the `Dockerfile` restore layer gained the A2A server and client project files (the server's was missing,
which already broke the image build).

View File

@@ -251,7 +251,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
var agents = AgentsFile.Read(workspace);
lines.Add("**Remote agents** (.volcanic/a2a/agents.json)");
if (agents.Error is not null) lines.Add(agents.Error);
lines.AddRange(agents.Agents.Count == 0 ? ["none"] : agents.Agents.Select(a => $"• {a.Name}: {a.Url}{(a.Key is null ? "" : " key " + Mask(a.Key))}"));
lines.AddRange(agents.Agents.Count == 0 ? ["none"] : agents.Agents.Select(a => $"• {a.Name}: {DisplayUrl(a.Url)}{(a.Key is null ? "" : " key " + Mask(a.Key))}"));
lines.AddRange(agents.Problems.Select(p => $"• skipped: {p}"));
var watches = a2aWatches?.List(TelegramJobDelivery.TargetOf(chat.ChatId)) ?? [];
lines.Add("**Watches** (remote tasks still running)");
@@ -264,6 +264,12 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
/// <summary>The last four characters of a key of 12 or more, else nothing: "…abcd" or "…".</summary>
private static string Mask(string key) => key.Length >= 12 ? "…" + key[^4..] : "…";
/// <summary>Scheme, host, port and path only: a query, fragment or user info may carry credentials.</summary>
private static string DisplayUrl(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri)
? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped)
: "(invalid url)";
private static string ShortId(string id) => id[..Math.Min(8, id.Length)];
/// <summary>", 5m ago" from a task's last status time; empty without one.</summary>

View File

@@ -197,4 +197,19 @@ public class A2AClientHostTests
var registration = ExtensionRegistration.FromType(typeof(A2AClientExtension), "test");
Assert.ThrowsAny<InvalidOperationException>(() => registration.Factory(host.Services));
}
[Fact]
public async Task A2aCommand_AgentUrlWithoutQueryOrUserInfo()
{
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
var ws = host.WorkspaceOf();
Directory.CreateDirectory(Path.GetDirectoryName(AgentsFile.PathFor(ws))!);
File.WriteAllText(AgentsFile.PathFor(ws), """[ { "name": "leaky", "url": "https://user:pw@leaky.example/a2a?key=secret#frag" } ]""");
await host.SendAsync("/a2a");
await host.SettleAsync();
var reply = host.LastReply();
Assert.Contains("https://leaky.example/a2a", reply);
Assert.DoesNotContain("secret", reply);
Assert.DoesNotContain("pw", reply);
}
}