docs(a2a): client exposures, URLs without queries, README/architecture
This commit is contained in:
@@ -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 |
|
||||
|
||||
23
docs/a2a.md
23
docs/a2a.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user