diff --git a/.env.example b/.env.example
index fe6142d..d1134e9 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,15 @@ LLM_PROVIDER=
# ── Agent config ─────────────────────────────────────────────────────────────
AGENT_TIMEZONE=America/Chicago
+# ── Optional: Bluesky credentials for post-to-site ───────────────────────────
+# Leave empty to disable Bluesky posting end-to-end (post-to-site will respond
+# "Credential '…' is not configured"). Use a Bluesky app password from
+# https://bsky.app/settings/app-passwords, never your account password.
+# Stored only in-process by Foragent's InMemoryCredentialBroker — never logged,
+# never sent over A2A. For prod, swap in a k8s-secrets or vault broker.
+FORAGENT_BLUESKY_IDENTIFIER=
+FORAGENT_BLUESKY_APP_PASSWORD=
+
# ── Optional ─────────────────────────────────────────────────────────────────
# Only needed if RockBot is configured to use GitHub Copilot / GitHub Models.
GITHUB_TOKEN=
diff --git a/CLAUDE.md b/CLAUDE.md
index a3f4427..d24def5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Status
-Foragent is at **milestone 3** (spec §9.1): two capabilities now — `fetch-page-title` (step 2, Playwright) and `extract-structured-data` (step 3, Playwright + LLM). Credentials are milestone 4. The authoritative design document is `docs/foragent-specification.md` — read it before making non-trivial changes. Framework-level observations from each milestone are captured in `docs/framework-feedback.md`.
+Foragent is at **milestone 4** (spec §9.1): three capabilities now — `fetch-page-title` (step 2, Playwright), `extract-structured-data` (step 3, Playwright + LLM), and `post-to-site` (step 4, Playwright + credential broker). The credential broker + first `ISitePoster` (Bluesky) are in. Storage-state persistence, 2FA input-required flow, k8s-secrets broker, and per-tenant credential namespaces are all deferred — tracked in `docs/framework-feedback.md` step 4. The authoritative design document is `docs/foragent-specification.md` — read it before making non-trivial changes. Framework-level observations from each milestone are captured in `docs/framework-feedback.md`.
## Build / test
@@ -73,7 +73,7 @@ Foragent requires an LLM (for `extract-structured-data` and future capabilities)
## Browser
-`Foragent.Browser` wraps Playwright. `AddForagentBrowser()` in `Foragent.Agent/Program.cs` registers `PlaywrightBrowserHost` (`IHostedService` owning one shared Chromium per process) and `IBrowserSessionFactory` (hands out a fresh `IBrowserContext` per A2A task — isolation guarantee from spec §3.5). `IBrowserSession` exposes `FetchPageTitleAsync` and `CapturePageSnapshotAsync`; the snapshot uses Chromium's aria-snapshot (via `Locator.AriaSnapshotAsync`) and falls back to `
` inner text when the tree is empty. `Foragent.Browser` has `InternalsVisibleTo("Foragent.Browser.Tests")` so tests drive the real `PlaywrightBrowserSessionFactory` without promoting its implementation types to public.
+`Foragent.Browser` wraps Playwright. `AddForagentBrowser()` in `Foragent.Agent/Program.cs` registers `PlaywrightBrowserHost` (`IHostedService` owning one shared Chromium per process) and `IBrowserSessionFactory` (hands out a fresh `IBrowserContext` per A2A task — isolation guarantee from spec §3.5). `IBrowserSession` exposes `FetchPageTitleAsync` / `CapturePageSnapshotAsync` for one-shot reads, plus `OpenPageAsync` → `IBrowserPage` (navigate / fill / click / wait / read) for multi-step flows like login + post. The snapshot uses Chromium's aria-snapshot (via `Locator.AriaSnapshotAsync`) and falls back to `` inner text when the tree is empty. Selectors passed to `IBrowserPage` use Playwright's string-selector dialect (CSS + `role=role[name="..."]`); **regex is not accepted in string form**, use exact attribute matches. `Foragent.Browser` has `InternalsVisibleTo("Foragent.Browser.Tests")` so tests drive the real `PlaywrightBrowserSessionFactory` without promoting its implementation types to public.
## Capabilities
@@ -82,7 +82,16 @@ Foragent requires an LLM (for `extract-structured-data` and future capabilities)
- Each capability implements `ICapability` — owns its own `AgentSkill` metadata (exposed as a static `SkillDefinition`) and its own `ExecuteAsync` logic.
- `ForagentTaskHandler` is a pure dispatcher that resolves `IEnumerable` from DI and routes on `SkillId`. **Do not add skill-specific logic to the handler.** New capabilities go in new `ICapability` classes.
- `ForagentCapabilities.Skills` (static array) is the single source of truth for advertised skills — both the bus-side `AgentCard.Skills` and the HTTP gateway's `opts.Skills` read from it.
-- `CapabilityInput.Parse` is the input-parsing shim until rockbot#281 ships real metadata pass-through. Capabilities that need a URL + description accept a `{"url":"...","description":"..."}` JSON blob in the single text part today; capabilities that only need a URL also accept a bare URL string. When the framework change lands, swap this helper — capability contracts don't need to change.
+- `CapabilityInput.Parse` is the shared URL + description shim used by `fetch-page-title` and `extract-structured-data`. Capabilities with different input shapes (e.g. `post-to-site` needing `site` / `credentialId` / `content`) parse their own input near the capability — see `PostToSiteInput` in `PostToSiteCapability.cs`. Don't overload `CapabilityInput` for unrelated shapes.
+- `post-to-site` dispatches to an `ISitePoster` keyed on `Site` (in `SitePosting/`). `BlueskySitePoster` is the only implementation today; add new sites by registering another `ISitePoster` in `AddForagentCapabilities()`. The capability never echoes exception messages from posters back to callers — they may contain credential material; operators read the full exception in logs.
+
+## Credentials
+
+`Foragent.Credentials` ships `ICredentialBroker` + `CredentialReference(Id, Kind, Values)`. `AddForagentCredentials(configuration, "Credentials")` wires an `InMemoryCredentialBroker` bound to the config section — dev/test only per spec §6.3. Populate via user-secrets (`dotnet user-secrets set "Credentials:bluesky-rocky:Kind" username-password`, etc.), never appsettings.json. **Never log `CredentialReference.Values`**, never include them in A2A responses, never embed them in exception messages. `CredentialReference.ToString()` deliberately does not expose values. Missing credentials throw `CredentialNotFoundException` carrying only the id.
+
+`CredentialReference.Values` is `IReadOnlyDictionary>` — byte-shaped so backends like k8s Secrets (byte-native), cert stores, and storage-state blobs pass through without lossy text conversion. Text-origin credentials go in via `CredentialReference.FromText(id, kind, stringDict)` (UTF-8 encodes at the boundary); text-shaped fields come out via `cred.RequireText(key)` (UTF-8 decodes). Use `cred.Require(key)` for raw bytes. `InMemoryCredentialBroker`'s config binding stays text (user-secrets / env vars are string-native); UTF-8 encoding happens at the broker boundary, not at config time.
+
+Credential ids are free-form via user-secrets/appsettings (slashes are fine — `rockbot/social/bluesky-rocky` matches spec §6.2's example). Via env vars / docker-compose, ids must be single-segment: `__` separates config-path segments, so `Credentials__rockbot__social__bluesky-rocky__Kind` becomes the config path `Credentials:rockbot:social:bluesky-rocky:Kind` and fails to bind as an id. Stick with flat ids (`bluesky-rocky`) in the compose harness.
## Conventions
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7e49ec4..64e171d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,9 +6,12 @@
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 8d205cb..32e1e83 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -70,6 +70,15 @@ services:
ForagentLlm__Endpoint: ${FORAGENT_LLM_ENDPOINT:?FORAGENT_LLM_ENDPOINT is required}
ForagentLlm__ModelId: ${FORAGENT_LLM_MODEL_ID:?FORAGENT_LLM_MODEL_ID is required}
ForagentLlm__ApiKey: ${FORAGENT_LLM_API_KEY:?FORAGENT_LLM_API_KEY is required}
+ # Optional Bluesky credential for post-to-site. Callers invoke post-to-site
+ # with credentialId: "bluesky-rocky". Flat id (no slashes) because env-var
+ # keys use __ to separate config path segments — ids with slashes work via
+ # appsettings / user-secrets but not via env vars. Leave unset to disable;
+ # post-to-site will report "Credential '…' is not configured."
+ # For prod, replace InMemoryCredentialBroker with k8s-secrets.
+ Credentials__bluesky-rocky__Kind: username-password
+ Credentials__bluesky-rocky__Values__identifier: ${FORAGENT_BLUESKY_IDENTIFIER:-}
+ Credentials__bluesky-rocky__Values__password: ${FORAGENT_BLUESKY_APP_PASSWORD:-}
rockbot-init:
image: rockylhotka/rockbot-agent:latest
diff --git a/docs/framework-feedback.md b/docs/framework-feedback.md
index c5d8b61..41703de 100644
--- a/docs/framework-feedback.md
+++ b/docs/framework-feedback.md
@@ -54,6 +54,116 @@ feedback. Capture it."
beyond step 2 will make this more painful; the `switch (request.Skill)` in
`ForagentTaskHandler` is already starting to accumulate per-skill setup.
+## Step 4 — Credentials + first credentialed capability (post-to-site / Bluesky)
+
+### Framework observations
+
+- **`ICredentialBroker` is Foragent-local, not framework.** We deliberately did
+ not propose this for RockBot yet — spec §6.2 treats the broker as a Foragent
+ concept, and no second consumer exists. If future agents (RockBot or
+ third-party) grow similar needs, consider lifting a broker abstraction
+ upstream with the same value-dictionary shape (see below).
+- **`ISitePoster` dispatch is a repeat of the step-3 pattern.** We added a
+ small in-capability dispatcher (`PostToSiteCapability` → keyed
+ `IReadOnlyDictionary`) to route a single A2A skill to
+ a family of site-specific implementations. Together with the step-3
+ `ICapability` dispatcher, this is now the second hand-rolled skill-to-impl
+ dispatch inside Foragent. A framework helper (e.g. `AddRockBotCapability`,
+ `AddRockBotCapabilityVariant`) would fold both patterns down.
+- **No framework hook for per-tenant broker scoping.** Spec §7.5 calls for
+ tenant identity from A2A caller, not request payload. The RockBot framework
+ exposes the caller identity on `AgentTaskContext.MessageContext.Agent`, but
+ there's no established pattern for a broker to receive it. Today Foragent's
+ broker ignores tenancy; see "Deferred" below.
+- **Playwright string-selector dialect is regex-free.** The first cut of
+ `BlueskySitePoster` used `role=button[name=/sign in/i]`-style selectors;
+ Playwright's string parser does not accept regex. `getByRole(…, new() { NameRegex = … })`
+ works on `IPage` but not in `WaitForSelectorAsync` string form. Switched to
+ exact attribute matches. Worth a note in a future `RockBot.Browser` helper if
+ one materialises, so consumers don't repeat the mistake.
+- **`contenteditable` + Playwright `FillAsync` works for text but not rich
+ content.** Bluesky's real composer uses a ProseMirror editor that rejects
+ naive `FillAsync`. Our selector targets the contenteditable host, which the
+ test fake also uses. Real-world posting may require typing or scripting the
+ editor — when we exercise against real bsky.app we'll learn whether this
+ path holds. Flagged here so the next session doesn't chase it as a new bug.
+
+### Credential abstraction — backend generality check
+
+Before finalizing step 4 we sanity-checked whether
+`ICredentialBroker.ResolveAsync(id) → CredentialReference(Id, Kind, Values)`
+is general enough to back alternative secret stores beyond in-memory and
+k8s. The shape bends:
+
+- **k8s Secrets** — Secret name → `Id`. `data` map (base64-decoded) → `Values`. Clean fit.
+- **Azure Key Vault** — One vault secret per credential holding a JSON blob, deserialized into `Values`. Or naming convention (`bluesky-rocky-identifier`, `bluesky-rocky-password`); broker collates. Both work.
+- **AWS Secrets Manager** — Native JSON `SecretString` maps directly to `Values`.
+- **HashiCorp Vault (KV v2)** — `secret/data/` → string map → `Values`. Direct fit.
+- **File-based dev broker** — Gitignored JSON file, one-to-one with `Values`.
+
+`Values` was switched from `IReadOnlyDictionary` to
+`IReadOnlyDictionary>` pre-emptively. Most real
+backends (k8s Secrets, cert stores, storage-state blobs) are byte-native;
+text is the common case but not the *only* case. `CredentialReference.FromText`
++ `RequireText` cover the UTF-8 path at the edges without forcing every
+broker / consumer to care.
+
+### Known gaps in the credential interface (not yet fixed)
+
+These are not blocking step 4 but will force changes as the spec is filled
+in. Captured here so they aren't rediscovered:
+
+1. **No catalog / list.** Spec §6.4 calls for advertising which credential
+ ids exist (without values) so a caller can say "I'd need a Bluesky
+ credential, none is configured." Today's interface is `Resolve` only.
+ Every non-toy backend supports listing. Will need
+ `IAsyncEnumerable ListAsync(CancellationToken)` or equivalent.
+2. **No write path for storage state (§6.5).** Storage-state-as-credential
+ requires the broker to *persist* post-login session bytes. Will need a
+ `Task WriteAsync(CredentialReference)` — and some backends are read-only
+ (Key Vault read role), so the interface should signal write capability
+ (either a feature flag or a separate `IWritableCredentialBroker`).
+3. **Tenancy isn't on the interface.** `ResolveAsync(string id)` has no
+ tenant parameter. Production backends need to scope lookups to the A2A
+ caller's tenant id. Either `ResolveAsync(TenantId, string id)` or
+ per-tenant broker scoping. Blocked on the spec-level tenant-identity
+ decision (spec §12 open question 5).
+
+### Deferred (tracked so we don't lose them)
+
+All of these are on the step-4 line in spec §9.1 but intentionally punted to
+later iterations to keep the PR reviewable. Each is wired into the current
+design in a way that allows adding it without breaking changes:
+
+- **Storage state as a credential (spec §6.5).** `BlueskySitePoster` re-auths
+ every post. The fix is to call `IBrowserContext.StorageStateAsync()` after
+ successful login, persist it back through the broker under a new `Kind`
+ (`storage-state`), and re-apply via `Browser.NewContextAsync(new { StorageState = … })`
+ on subsequent runs. Requires either an `IBrowserSessionFactory.CreateSessionAsync(storageState)`
+ overload or a session-level "import" method. Keeping the broker
+ value-shape as `IReadOnlyDictionary` means storage state
+ (a JSON blob) just becomes `Values["json"]`.
+- **2FA via A2A `input-required` (spec §6.6).** RockBot's framework exposes
+ the `input-required` state on `AgentTaskContext`, but we haven't wired
+ BlueskySitePoster to detect a 2FA prompt and suspend. App passwords bypass
+ 2FA for now, which is why spec §6.6 recommends them — but the input-required
+ path is what unlocks non-app-password sites.
+- **Kubernetes secrets broker (spec §6.3).** Only `InMemoryCredentialBroker`
+ is implemented; prod deploy will need a `KubernetesCredentialBroker` reading
+ from a scoped service account. No deployment target exists yet (spec §9.2).
+- **Per-tenant credential namespaces (spec §7.5).** `ICredentialBroker.ResolveAsync`
+ takes only the credential id. A production broker should also take a tenant
+ id derived from `AgentTaskContext.MessageContext.Agent`, and scope its
+ lookup. Foragent is currently single-tenant by omission.
+- **Audit logging (spec §7.4).** We log capability invocation + credential id
+ via `ILogger`, but there's no dedicated audit sink separate from diagnostic
+ logging. Spec §7.4 calls for a per-tenant audit log with structured fields;
+ current logs are prose.
+- **Domain allowlists (spec §7.1).** `post-to-site` hard-codes the Bluesky
+ login URL; no request-level or tenant-level allowlist. When we add a second
+ poster, promote the URL to config and add an allowlist check around
+ `IBrowserSession.OpenPageAsync`.
+
## Step 3 — Second capability (extract-structured-data)
- **A2A metadata pass-through.** Filed as [rockbot#281](https://github.com/MarimerLLC/rockbot/issues/281),
diff --git a/src/Foragent.Agent/Foragent.Agent.csproj b/src/Foragent.Agent/Foragent.Agent.csproj
index bd1d6a5..1072622 100644
--- a/src/Foragent.Agent/Foragent.Agent.csproj
+++ b/src/Foragent.Agent/Foragent.Agent.csproj
@@ -20,5 +20,6 @@
+
diff --git a/src/Foragent.Agent/Program.cs b/src/Foragent.Agent/Program.cs
index 563eff2..19ccfa2 100644
--- a/src/Foragent.Agent/Program.cs
+++ b/src/Foragent.Agent/Program.cs
@@ -1,6 +1,7 @@
using System.ClientModel;
using Foragent.Browser;
using Foragent.Capabilities;
+using Foragent.Credentials;
using Microsoft.Extensions.AI;
using OpenAI;
using RockBot.A2A;
@@ -67,6 +68,12 @@
builder.Services.AddForagentBrowser();
+// ── Credentials ─────────────────────────────────────────────────────────────
+// In-memory broker bound to the "Credentials" config section (populated via
+// user-secrets in dev). Production deployments should swap in a k8s-secrets /
+// vault broker; tracked in docs/framework-feedback.md step 4.
+builder.Services.AddForagentCredentials(builder.Configuration);
+
// ── HTTP A2A gateway (in-process) ────────────────────────────────────────────
builder.Services.Configure>(
diff --git a/src/Foragent.Agent/appsettings.json b/src/Foragent.Agent/appsettings.json
index f2e6d13..bee7a13 100644
--- a/src/Foragent.Agent/appsettings.json
+++ b/src/Foragent.Agent/appsettings.json
@@ -26,5 +26,6 @@
"ModelId": "",
"ApiKey": ""
},
- "ApiKeys": {}
+ "ApiKeys": {},
+ "Credentials": {}
}
diff --git a/src/Foragent.Browser/IBrowserSession.cs b/src/Foragent.Browser/IBrowserSession.cs
index 4d1d0f8..f8d07c8 100644
--- a/src/Foragent.Browser/IBrowserSession.cs
+++ b/src/Foragent.Browser/IBrowserSession.cs
@@ -24,6 +24,54 @@ public interface IBrowserSession : IAsyncDisposable
/// title at the top so the LLM has enough context to reason.
///
Task CapturePageSnapshotAsync(Uri url, CancellationToken cancellationToken = default);
+
+ ///
+ /// Opens a page for a multi-step flow (login, fill form, navigate, read
+ /// back confirmation). The caller drives the page with the methods on
+ /// and disposes it when done. The surrounding
+ /// session's context still owns cookies / storage — close the page when
+ /// finished, dispose the session when the task ends.
+ ///
+ Task OpenPageAsync(Uri url, CancellationToken cancellationToken = default);
+}
+
+///
+/// A stateful page inside an . The grain is low
+/// enough to drive arbitrary HTML forms but the methods stay Playwright-free
+/// so capabilities don't pick up a hard dependency on Microsoft.Playwright.
+/// Selectors follow Playwright's syntax — CSS, text=, role=, etc. Sensitive
+/// values passed to must not be logged by the
+/// implementation.
+///
+public interface IBrowserPage : IAsyncDisposable
+{
+ /// Navigates the page to a new URL.
+ Task NavigateAsync(Uri url, CancellationToken cancellationToken = default);
+
+ /// Fills a field matched by .
+ Task FillAsync(string selector, string value, CancellationToken cancellationToken = default);
+
+ /// Clicks the element matched by .
+ Task ClickAsync(string selector, CancellationToken cancellationToken = default);
+
+ ///
+ /// Waits until the element matched by is attached
+ /// and visible. Throws on timeout.
+ ///
+ Task WaitForSelectorAsync(
+ string selector,
+ TimeSpan? timeout = null,
+ CancellationToken cancellationToken = default);
+
+ /// The current URL, after any redirects and client-side navigations.
+ Task GetUrlAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the inner text of the element matched by ,
+ /// or null if no element matches. Useful for reading back error
+ /// messages or confirmation text.
+ ///
+ Task GetTextAsync(string selector, CancellationToken cancellationToken = default);
}
///
diff --git a/src/Foragent.Browser/PlaywrightBrowserSessionFactory.cs b/src/Foragent.Browser/PlaywrightBrowserSessionFactory.cs
index 434725a..5bf333e 100644
--- a/src/Foragent.Browser/PlaywrightBrowserSessionFactory.cs
+++ b/src/Foragent.Browser/PlaywrightBrowserSessionFactory.cs
@@ -73,6 +73,93 @@ public async Task CapturePageSnapshotAsync(Uri url, CancellationTo
}
}
+ public async Task OpenPageAsync(Uri url, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var page = await context.NewPageAsync();
+ try
+ {
+ var response = await page.GotoAsync(url.ToString(), new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.DOMContentLoaded
+ });
+ if (response is null || !response.Ok)
+ throw new InvalidOperationException(
+ $"Navigation to {url} returned status {response?.Status.ToString() ?? "no response"}.");
+
+ return new PlaywrightBrowserPage(page);
+ }
+ catch
+ {
+ await page.CloseAsync();
+ throw;
+ }
+ }
+
public ValueTask DisposeAsync() => new(context.CloseAsync());
}
+
+internal sealed class PlaywrightBrowserPage(IPage page) : IBrowserPage
+{
+ public async Task NavigateAsync(Uri url, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var response = await page.GotoAsync(url.ToString(), new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.DOMContentLoaded
+ });
+ if (response is null || !response.Ok)
+ throw new InvalidOperationException(
+ $"Navigation to {url} returned status {response?.Status.ToString() ?? "no response"}.");
+ }
+
+ public Task FillAsync(string selector, string value, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return page.FillAsync(selector, value);
+ }
+
+ public Task ClickAsync(string selector, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return page.ClickAsync(selector);
+ }
+
+ public async Task WaitForSelectorAsync(
+ string selector,
+ TimeSpan? timeout = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ await page.WaitForSelectorAsync(selector, new PageWaitForSelectorOptions
+ {
+ State = WaitForSelectorState.Visible,
+ Timeout = timeout is null ? null : (float)timeout.Value.TotalMilliseconds
+ });
+ }
+ catch (TimeoutException)
+ {
+ throw;
+ }
+ }
+
+ public Task GetUrlAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(new Uri(page.Url));
+ }
+
+ public async Task GetTextAsync(string selector, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var locator = page.Locator(selector);
+ if (await locator.CountAsync() == 0)
+ return null;
+ return await locator.First.InnerTextAsync();
+ }
+
+ public ValueTask DisposeAsync() => new(page.CloseAsync());
+}
diff --git a/src/Foragent.Capabilities/Foragent.Capabilities.csproj b/src/Foragent.Capabilities/Foragent.Capabilities.csproj
index 6c23237..30e7579 100644
--- a/src/Foragent.Capabilities/Foragent.Capabilities.csproj
+++ b/src/Foragent.Capabilities/Foragent.Capabilities.csproj
@@ -9,5 +9,6 @@
+
diff --git a/src/Foragent.Capabilities/ForagentCapabilitiesServiceCollectionExtensions.cs b/src/Foragent.Capabilities/ForagentCapabilitiesServiceCollectionExtensions.cs
index 6425147..ce248d0 100644
--- a/src/Foragent.Capabilities/ForagentCapabilitiesServiceCollectionExtensions.cs
+++ b/src/Foragent.Capabilities/ForagentCapabilitiesServiceCollectionExtensions.cs
@@ -1,3 +1,4 @@
+using Foragent.Capabilities.SitePosting;
using Microsoft.Extensions.DependencyInjection;
using RockBot.A2A;
@@ -14,6 +15,8 @@ public static IServiceCollection AddForagentCapabilities(this IServiceCollection
{
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddScoped();
return services;
}
@@ -30,6 +33,7 @@ public static class ForagentCapabilities
public static IReadOnlyList Skills { get; } =
[
FetchPageTitleCapability.SkillDefinition,
- ExtractStructuredDataCapability.SkillDefinition
+ ExtractStructuredDataCapability.SkillDefinition,
+ PostToSiteCapability.SkillDefinition
];
}
diff --git a/src/Foragent.Capabilities/PostToSiteCapability.cs b/src/Foragent.Capabilities/PostToSiteCapability.cs
new file mode 100644
index 0000000..6a98c3a
--- /dev/null
+++ b/src/Foragent.Capabilities/PostToSiteCapability.cs
@@ -0,0 +1,155 @@
+using System.Text.Json;
+using Foragent.Browser;
+using Foragent.Capabilities.SitePosting;
+using Foragent.Credentials;
+using Microsoft.Extensions.Logging;
+using RockBot.A2A;
+
+namespace Foragent.Capabilities;
+
+///
+/// Authenticates against a configured site and posts content (spec §5.1).
+/// Site-specific work lives behind ; this capability
+/// handles input parsing, broker lookup, session creation, and error shaping.
+/// Credential material never appears in the returned
+/// (spec §6.1).
+///
+public sealed class PostToSiteCapability(
+ IBrowserSessionFactory browserFactory,
+ ICredentialBroker credentialBroker,
+ IEnumerable posters,
+ ILogger logger) : ICapability
+{
+ public static AgentSkill SkillDefinition { get; } = new()
+ {
+ Id = "post-to-site",
+ Name = "Post to Site",
+ Description = "Authenticate against a configured site (using a credential identifier) and publish a post. "
+ + "Input: JSON {\"site\":\"bluesky\",\"credentialId\":\"...\",\"content\":\"...\"} "
+ + "or metadata fields site / credentialId / content."
+ };
+
+ private readonly IReadOnlyDictionary _postersBySite =
+ posters.ToDictionary(p => p.Site, StringComparer.OrdinalIgnoreCase);
+
+ public string SkillId => SkillDefinition.Id;
+ public AgentSkill Skill => SkillDefinition;
+
+ public async Task ExecuteAsync(AgentTaskRequest request, AgentTaskContext context)
+ {
+ var ct = context.MessageContext.CancellationToken;
+ var input = PostToSiteInput.Parse(request);
+
+ if (input.Error is not null)
+ return CapabilityResult.Error(request, input.Error);
+
+ if (!_postersBySite.TryGetValue(input.Site!, out var poster))
+ {
+ var known = string.Join(", ", _postersBySite.Keys.OrderBy(k => k));
+ return CapabilityResult.Error(
+ request,
+ $"No poster configured for site '{input.Site}'. Known sites: {known}");
+ }
+
+ CredentialReference credential;
+ try
+ {
+ credential = await credentialBroker.ResolveAsync(input.CredentialId!, ct);
+ }
+ catch (CredentialNotFoundException ex)
+ {
+ logger.LogWarning("Credential '{CredentialId}' not found", ex.CredentialId);
+ return CapabilityResult.Error(request, $"Credential '{ex.CredentialId}' is not configured.");
+ }
+
+ try
+ {
+ await using var session = await browserFactory.CreateSessionAsync(ct);
+ await poster.PostAsync(session, credential, input.Content!, ct);
+ return CapabilityResult.Completed(request, $"Posted to {poster.Site}.");
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Never echo exception messages verbatim — site posters should not
+ // embed credentials in them, but belt-and-suspenders since these go
+ // back to the caller. Log the full exception for operator debugging.
+ logger.LogWarning(ex, "Post to {Site} failed for credential {CredentialId}",
+ poster.Site, credential.Id);
+ return CapabilityResult.Error(request, $"Post to {poster.Site} failed.");
+ }
+ }
+}
+
+///
+/// Parses the post-to-site input shape. Accepts either:
+///
+/// - A JSON object in the first text part: {"site":"...","credentialId":"...","content":"..."}.
+/// - Individual fields via message or request metadata (rockbot 0.8.5+):
+/// site, credentialId, content. Metadata overrides JSON when both are present.
+///
+/// No URL-extraction fallback — post-to-site is structured enough that bare
+/// text input would be ambiguous. Unparseable input yields .
+///
+internal readonly record struct PostToSiteInput(
+ string? Site, string? CredentialId, string? Content, string? Error)
+{
+ public static PostToSiteInput Parse(AgentTaskRequest request)
+ {
+ string? site = null;
+ string? credentialId = null;
+ string? content = null;
+
+ var text = request.Message.Parts
+ .Where(p => p.Kind == "text")
+ .Select(p => p.Text)
+ .FirstOrDefault(t => !string.IsNullOrWhiteSpace(t))
+ ?.Trim();
+
+ if (!string.IsNullOrEmpty(text) && text.StartsWith('{'))
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(text);
+ var root = doc.RootElement;
+ if (root.TryGetProperty("site", out var s)) site = s.GetString();
+ if (root.TryGetProperty("credentialId", out var c)) credentialId = c.GetString();
+ if (root.TryGetProperty("content", out var p)) content = p.GetString();
+ }
+ catch (JsonException)
+ {
+ return new PostToSiteInput(null, null, null,
+ "Input must be a JSON object with site, credentialId, and content fields.");
+ }
+ }
+
+ site = ReadMetadata(request, "site") ?? site;
+ credentialId = ReadMetadata(request, "credentialId") ?? credentialId;
+ content = ReadMetadata(request, "content") ?? content;
+
+ if (string.IsNullOrWhiteSpace(site))
+ return new PostToSiteInput(null, null, null, "Missing 'site' (e.g. 'bluesky').");
+ if (string.IsNullOrWhiteSpace(credentialId))
+ return new PostToSiteInput(null, null, null, "Missing 'credentialId'.");
+ if (string.IsNullOrWhiteSpace(content))
+ return new PostToSiteInput(null, null, null, "Missing 'content'.");
+
+ return new PostToSiteInput(site, credentialId, content, null);
+ }
+
+ private static string? ReadMetadata(AgentTaskRequest request, string key)
+ {
+ if (request.Message.Metadata is not null
+ && request.Message.Metadata.TryGetValue(key, out var msgValue)
+ && !string.IsNullOrWhiteSpace(msgValue))
+ {
+ return msgValue;
+ }
+ if (request.Metadata is not null
+ && request.Metadata.TryGetValue(key, out var reqValue)
+ && !string.IsNullOrWhiteSpace(reqValue))
+ {
+ return reqValue;
+ }
+ return null;
+ }
+}
diff --git a/src/Foragent.Capabilities/SitePosting/BlueskySitePoster.cs b/src/Foragent.Capabilities/SitePosting/BlueskySitePoster.cs
new file mode 100644
index 0000000..5339526
--- /dev/null
+++ b/src/Foragent.Capabilities/SitePosting/BlueskySitePoster.cs
@@ -0,0 +1,110 @@
+using Foragent.Browser;
+using Foragent.Credentials;
+using Microsoft.Extensions.Logging;
+
+namespace Foragent.Capabilities.SitePosting;
+
+///
+/// Drives the Bluesky web UI (bsky.app) to post content on behalf of a user
+/// authenticated with an app password (spec §6.6 prefers app passwords where
+/// available). Uses stable accessibility-role selectors rather than CSS so
+/// minor UI tweaks don't break the flow — selectors are still inherently
+/// fragile and are flagged in docs/framework-feedback.md.
+///
+///
+/// Expects with keys identifier
+/// (handle or email) and password (app password). Does not persist
+/// storageState yet — every post re-authenticates; spec §6.5's
+/// session-as-credential flow is deferred.
+///
+public sealed class BlueskySitePoster : ISitePoster
+{
+ public const string SiteId = "bluesky";
+
+ private static readonly Uri DefaultLoginUrl = new("https://bsky.app/");
+ private static readonly TimeSpan InteractiveTimeout = TimeSpan.FromSeconds(30);
+
+ private readonly ILogger logger;
+ private readonly Uri loginUrl;
+
+ // DI-friendly: defaults to the real bsky.app. Tests use the Uri overload
+ // to point at a local Kestrel-hosted fake login + compose UI.
+ public BlueskySitePoster(ILogger logger)
+ : this(logger, DefaultLoginUrl) { }
+
+ public BlueskySitePoster(ILogger logger, Uri loginUrl)
+ {
+ this.logger = logger;
+ this.loginUrl = loginUrl;
+ }
+
+ // Accessibility-role + attribute selectors. Playwright's string-selector
+ // dialect does not accept regex; for flexibility across the real bsky.app
+ // and the fake test UI we pick stable exact strings and update them here
+ // when Bluesky's copy changes. Flagged as fragile in docs/framework-feedback.md.
+ private const string SignInButton = "role=button[name=\"Sign in\"]";
+ private const string IdentifierField = "input[placeholder=\"Username or email address\"]";
+ private const string PasswordField = "input[placeholder=\"Password\"]";
+ private const string SubmitLoginButton = "role=button[name=\"Next\"]";
+ private const string ComposeButton = "role=button[name=\"New post\"]";
+ private const string ComposeEditor = "[contenteditable=\"true\"]";
+ private const string PublishButton = "role=button[name=\"Post\"]";
+ private const string HomeFeedHeading = "role=heading[name=\"Home\"]";
+
+ public string Site => SiteId;
+
+ public async Task PostAsync(
+ IBrowserSession session,
+ CredentialReference credential,
+ string content,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentNullException.ThrowIfNull(credential);
+ if (string.IsNullOrWhiteSpace(content))
+ throw new ArgumentException("Post content cannot be empty.", nameof(content));
+
+ var identifier = credential.RequireText("identifier");
+ var password = credential.RequireText("password");
+
+ logger.LogInformation(
+ "Posting to Bluesky as '{Identifier}' (credential {CredentialId}, {Length} chars)",
+ identifier, credential.Id, content.Length);
+
+ await using var page = await session.OpenPageAsync(loginUrl, cancellationToken);
+
+ await SignInAsync(page, identifier, password, cancellationToken);
+ await ComposeAsync(page, content, cancellationToken);
+ }
+
+ private async Task SignInAsync(
+ IBrowserPage page, string identifier, string password, CancellationToken ct)
+ {
+ await page.WaitForSelectorAsync(SignInButton, InteractiveTimeout, ct);
+ await page.ClickAsync(SignInButton, ct);
+
+ await page.WaitForSelectorAsync(IdentifierField, InteractiveTimeout, ct);
+ await page.FillAsync(IdentifierField, identifier, ct);
+ await page.FillAsync(PasswordField, password, ct);
+ await page.ClickAsync(SubmitLoginButton, ct);
+
+ await page.WaitForSelectorAsync(HomeFeedHeading, InteractiveTimeout, ct);
+ logger.LogInformation("Bluesky login succeeded for '{Identifier}'", identifier);
+ }
+
+ private async Task ComposeAsync(IBrowserPage page, string content, CancellationToken ct)
+ {
+ await page.WaitForSelectorAsync(ComposeButton, InteractiveTimeout, ct);
+ await page.ClickAsync(ComposeButton, ct);
+
+ await page.WaitForSelectorAsync(ComposeEditor, InteractiveTimeout, ct);
+ await page.FillAsync(ComposeEditor, content, ct);
+
+ await page.ClickAsync(PublishButton, ct);
+
+ // Publish closes the composer and returns to the home feed; wait for
+ // the composer to disappear as the success signal.
+ await page.WaitForSelectorAsync(HomeFeedHeading, InteractiveTimeout, ct);
+ logger.LogInformation("Bluesky post published ({Length} chars)", content.Length);
+ }
+}
diff --git a/src/Foragent.Capabilities/SitePosting/ISitePoster.cs b/src/Foragent.Capabilities/SitePosting/ISitePoster.cs
new file mode 100644
index 0000000..5e642a5
--- /dev/null
+++ b/src/Foragent.Capabilities/SitePosting/ISitePoster.cs
@@ -0,0 +1,35 @@
+using Foragent.Browser;
+using Foragent.Credentials;
+
+namespace Foragent.Capabilities.SitePosting;
+
+///
+/// Site-specific driver behind the generic post-to-site capability. One
+/// implementation per site family (Bluesky, Mastodon, …). The capability
+/// resolves an by matching to the
+/// site input field, so site dispatch stays out of the capability.
+///
+///
+/// Not yet lifted to RockBot.A2A — it's Foragent-local until a second
+/// framework consumer has the same shape. Noted in docs/framework-feedback.md.
+///
+public interface ISitePoster
+{
+ ///
+ /// Case-insensitive site identifier (e.g. bluesky, mastodon).
+ /// Matches the site input sent by the caller.
+ ///
+ string Site { get; }
+
+ ///
+ /// Authenticates (using ) and posts
+ /// . Implementations must not log credential
+ /// values or password form fields. Throws on failure; exception messages
+ /// must not contain credential material.
+ ///
+ Task PostAsync(
+ IBrowserSession session,
+ CredentialReference credential,
+ string content,
+ CancellationToken cancellationToken);
+}
diff --git a/src/Foragent.Credentials/CredentialReference.cs b/src/Foragent.Credentials/CredentialReference.cs
new file mode 100644
index 0000000..73e254e
--- /dev/null
+++ b/src/Foragent.Credentials/CredentialReference.cs
@@ -0,0 +1,95 @@
+using System.Text;
+
+namespace Foragent.Credentials;
+
+///
+/// A resolved credential. carries the actual secret
+/// material (passwords, tokens, cookies, cert material, storage state) keyed
+/// by field name — the exact keys a capability consumes are
+/// capability-specific (e.g. Bluesky's site poster reads identifier and
+/// password).
+///
+///
+///
+/// Values are of , not
+/// : most real backends (k8s Secrets, certificate stores,
+/// storage-state blobs) are byte-native, and forcing text conversion at the
+/// broker boundary loses fidelity for binary material. Text-origin
+/// credentials should use /
+/// to UTF-8 encode / decode at the edge.
+///
+///
+/// Not a record: records generate a ToString that dumps every property,
+/// which would round-trip secrets into logs the first time someone writes
+/// logger.LogDebug("{Cred}", cred). is overridden
+/// to expose only the id + kind.
+///
+///
+public sealed class CredentialReference
+{
+ public CredentialReference(
+ string id,
+ string kind,
+ IReadOnlyDictionary> values)
+ {
+ Id = id;
+ Kind = kind;
+ Values = values;
+ }
+
+ ///
+ /// Convenience factory for text-origin credentials (passwords, app
+ /// passwords, API tokens, JSON storage-state blobs). UTF-8 encodes each
+ /// value at the broker boundary so the internal representation stays
+ /// byte-oriented without forcing callers to encode by hand.
+ ///
+ public static CredentialReference FromText(
+ string id,
+ string kind,
+ IReadOnlyDictionary values)
+ {
+ var encoded = new Dictionary>(values.Count, StringComparer.Ordinal);
+ foreach (var kvp in values)
+ encoded[kvp.Key] = Encoding.UTF8.GetBytes(kvp.Value);
+ return new CredentialReference(id, kind, encoded);
+ }
+
+ /// The opaque identifier the caller passed over A2A.
+ public string Id { get; }
+
+ ///
+ /// Free-form label describing the shape of . Current
+ /// usage: username-password. Future: storage-state, totp,
+ /// certificate. Capabilities may validate this before attempting to
+ /// use the credential.
+ ///
+ public string Kind { get; }
+
+ ///
+ /// Credential material. Never log this dictionary, never include it in
+ /// A2A messages, never include it in exception messages.
+ ///
+ public IReadOnlyDictionary> Values { get; }
+
+ ///
+ /// Returns the raw bytes for . Throws if the key is
+ /// absent or empty. Exception messages name the missing field but never
+ /// echo any existing value.
+ ///
+ public ReadOnlyMemory Require(string key)
+ {
+ if (!Values.TryGetValue(key, out var value) || value.IsEmpty)
+ throw new InvalidOperationException(
+ $"Credential '{Id}' (kind '{Kind}') is missing required field '{key}'.");
+ return value;
+ }
+
+ ///
+ /// UTF-8 decode convenience for the common text-shaped field case
+ /// (username, password, token, URL). For binary fields use
+ /// and handle encoding directly.
+ ///
+ public string RequireText(string key) => Encoding.UTF8.GetString(Require(key).Span);
+
+ public override string ToString() => $"CredentialReference(Id={Id}, Kind={Kind})";
+}
diff --git a/src/Foragent.Credentials/Foragent.Credentials.csproj b/src/Foragent.Credentials/Foragent.Credentials.csproj
index 35e3d84..03d5db9 100644
--- a/src/Foragent.Credentials/Foragent.Credentials.csproj
+++ b/src/Foragent.Credentials/Foragent.Credentials.csproj
@@ -1,2 +1,8 @@
+
+
+
+
+
+
diff --git a/src/Foragent.Credentials/ForagentCredentialsServiceCollectionExtensions.cs b/src/Foragent.Credentials/ForagentCredentialsServiceCollectionExtensions.cs
new file mode 100644
index 0000000..8ba2267
--- /dev/null
+++ b/src/Foragent.Credentials/ForagentCredentialsServiceCollectionExtensions.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Foragent.Credentials;
+
+public static class ForagentCredentialsServiceCollectionExtensions
+{
+ ///
+ /// Registers the in-memory credential broker as the default
+ /// , bound to the supplied config section
+ /// (commonly Credentials). Production deployments should replace
+ /// this with a k8s-secrets / vault broker before opening the agent to
+ /// real callers; see spec §6.3 and docs/framework-feedback.md step 4.
+ ///
+ public static IServiceCollection AddForagentCredentials(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ string sectionName = "Credentials")
+ {
+ services.AddOptions()
+ .Bind(configuration.GetSection(sectionName));
+
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/src/Foragent.Credentials/ICredentialBroker.cs b/src/Foragent.Credentials/ICredentialBroker.cs
index 23031e5..e1ac742 100644
--- a/src/Foragent.Credentials/ICredentialBroker.cs
+++ b/src/Foragent.Credentials/ICredentialBroker.cs
@@ -1,25 +1,39 @@
namespace Foragent.Credentials;
///
-/// Resolves credential references to their actual values inside the Foragent
-/// process. Credential values never cross A2A boundaries or appear in logs.
+/// Resolves credential identifiers to credential values inside the Foragent
+/// process. The calling agent passes only the identifier across the A2A
+/// boundary (spec §6.2); produces the material that
+/// actually unlocks a browser session.
///
+///
+/// Implementations MUST NOT log credential contents or surface them in
+/// exception messages. Callers (Foragent capabilities) MUST NOT log
+/// or include them in A2A responses.
+/// Broker queries are expected to be scoped to a single tenant — the tenant
+/// id flows from A2A caller identity, not from request payloads (spec §7.5).
+/// Tenancy is not yet enforced at the broker interface; see
+/// docs/framework-feedback.md step 4.
+///
public interface ICredentialBroker
{
///
- /// Resolves a credential reference to a
- /// that can be used to supply credentials to a browser session.
+ /// Resolves a credential identifier. Throws
+ /// if no credential with that
+ /// id is configured.
///
- /// The unique identifier of the credential.
- /// A token to cancel the operation.
- /// A for the specified credential.
Task ResolveAsync(
string credentialId,
CancellationToken cancellationToken = default);
}
///
-/// A placeholder reference to a resolved credential.
+/// Thrown when cannot find a
+/// credential with the requested id. Carries the id but never any credential
+/// material.
///
-/// The unique identifier of the credential.
-public record CredentialReference(string CredentialId);
+public sealed class CredentialNotFoundException(string credentialId)
+ : Exception($"No credential configured with id '{credentialId}'.")
+{
+ public string CredentialId { get; } = credentialId;
+}
diff --git a/src/Foragent.Credentials/InMemoryCredentialBroker.cs b/src/Foragent.Credentials/InMemoryCredentialBroker.cs
new file mode 100644
index 0000000..973cd8a
--- /dev/null
+++ b/src/Foragent.Credentials/InMemoryCredentialBroker.cs
@@ -0,0 +1,45 @@
+using Microsoft.Extensions.Options;
+
+namespace Foragent.Credentials;
+
+///
+/// Reads credentials from —
+/// typically bound from a Credentials config section, which in dev is
+/// populated via user-secrets (never appsettings.json). Spec §6.3 marks this
+/// as dev/test only; production deployments plug in a k8s-secrets or vault
+/// broker instead.
+///
+public sealed class InMemoryCredentialBroker(
+ IOptionsMonitor options) : ICredentialBroker
+{
+ public Task ResolveAsync(
+ string credentialId, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var snapshot = options.CurrentValue;
+ if (!snapshot.Credentials.TryGetValue(credentialId, out var entry))
+ throw new CredentialNotFoundException(credentialId);
+
+ // Config binding is text-native (appsettings / user-secrets / env vars
+ // all hand us strings). CredentialReference is byte-shaped for backend
+ // generality — k8s Secrets and cert stores are byte-native — so UTF-8
+ // encode at the boundary here rather than pushing encoding into every
+ // consumer.
+ return Task.FromResult(CredentialReference.FromText(
+ credentialId,
+ entry.Kind,
+ entry.Values));
+ }
+}
+
+public sealed class InMemoryCredentialBrokerOptions
+{
+ /// Keyed by the credential id a caller passes to the broker.
+ public Dictionary Credentials { get; init; } = new();
+}
+
+public sealed class InMemoryCredentialEntry
+{
+ public string Kind { get; init; } = "username-password";
+ public Dictionary Values { get; init; } = new();
+}
diff --git a/tests/Foragent.Agent.Tests/Foragent.Agent.Tests.csproj b/tests/Foragent.Agent.Tests/Foragent.Agent.Tests.csproj
index 2b2125d..9c1735f 100644
--- a/tests/Foragent.Agent.Tests/Foragent.Agent.Tests.csproj
+++ b/tests/Foragent.Agent.Tests/Foragent.Agent.Tests.csproj
@@ -10,5 +10,6 @@
+
diff --git a/tests/Foragent.Agent.Tests/InMemoryCredentialBrokerTests.cs b/tests/Foragent.Agent.Tests/InMemoryCredentialBrokerTests.cs
new file mode 100644
index 0000000..ea607b0
--- /dev/null
+++ b/tests/Foragent.Agent.Tests/InMemoryCredentialBrokerTests.cs
@@ -0,0 +1,117 @@
+using Foragent.Credentials;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace Foragent.Agent.Tests;
+
+public class InMemoryCredentialBrokerTests
+{
+ [Fact]
+ public async Task ResolvesConfiguredCredential()
+ {
+ var options = Options.Create(new InMemoryCredentialBrokerOptions
+ {
+ Credentials = new()
+ {
+ ["rockbot/social/bluesky-rocky"] = new InMemoryCredentialEntry
+ {
+ Kind = "username-password",
+ Values = new()
+ {
+ ["identifier"] = "rocky.bsky.social",
+ ["password"] = "app-pass-xyz"
+ }
+ }
+ }
+ });
+ var broker = new InMemoryCredentialBroker(Monitor(options));
+
+ var cred = await broker.ResolveAsync("rockbot/social/bluesky-rocky");
+
+ Assert.Equal("rockbot/social/bluesky-rocky", cred.Id);
+ Assert.Equal("username-password", cred.Kind);
+ Assert.Equal("rocky.bsky.social", cred.RequireText("identifier"));
+ Assert.Equal("app-pass-xyz", cred.RequireText("password"));
+ }
+
+ [Fact]
+ public async Task Throws_WhenCredentialMissing()
+ {
+ var broker = new InMemoryCredentialBroker(Monitor(Options.Create(new InMemoryCredentialBrokerOptions())));
+
+ var ex = await Assert.ThrowsAsync(
+ () => broker.ResolveAsync("missing/id"));
+
+ Assert.Equal("missing/id", ex.CredentialId);
+ Assert.DoesNotContain("password", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ToString_DoesNotExposeValues()
+ {
+ var cred = CredentialReference.FromText(
+ "rockbot/social/bluesky-rocky",
+ "username-password",
+ new Dictionary { ["password"] = "super-secret-123" });
+
+ var rendered = cred.ToString();
+
+ Assert.Contains("rockbot/social/bluesky-rocky", rendered);
+ Assert.DoesNotContain("super-secret-123", rendered);
+ }
+
+ [Fact]
+ public void Require_Throws_WhenFieldMissing()
+ {
+ var cred = CredentialReference.FromText(
+ "id", "username-password",
+ new Dictionary { ["identifier"] = "u" });
+
+ var ex = Assert.Throws(() => cred.Require("password"));
+ Assert.Contains("password", ex.Message);
+ // The exception is about a missing field, so it can safely name the key
+ // — but it must never echo any existing value.
+ Assert.DoesNotContain("u", ex.Message.Split('\'')[^1]);
+ }
+
+ [Fact]
+ public void FromText_RoundTripsThroughUtf8()
+ {
+ var cred = CredentialReference.FromText(
+ "id", "username-password",
+ new Dictionary
+ {
+ ["identifier"] = "röcky@例え.test",
+ ["password"] = "\u00a0secret\u2603"
+ });
+
+ // UTF-8 round trip through RequireText should reproduce the original
+ // strings exactly — confirms we don't lose non-ASCII content at the
+ // encoding boundary.
+ Assert.Equal("röcky@例え.test", cred.RequireText("identifier"));
+ Assert.Equal("\u00a0secret\u2603", cred.RequireText("password"));
+ }
+
+ [Fact]
+ public void Values_AreReadOnlyMemoryBytes()
+ {
+ // Sanity check: binary-origin credentials (cert material, storage
+ // state blobs) go through the direct ctor without double-encoding.
+ var bytes = new byte[] { 0x30, 0x82, 0x01, 0x00 };
+ var cred = new CredentialReference(
+ "id", "certificate",
+ new Dictionary> { ["der"] = bytes });
+
+ Assert.True(cred.Require("der").Span.SequenceEqual(bytes));
+ }
+
+ private static IOptionsMonitor Monitor(IOptions options) where T : class =>
+ new StaticOptionsMonitor(options.Value);
+
+ private sealed class StaticOptionsMonitor(T value) : IOptionsMonitor
+ {
+ public T CurrentValue => value;
+ public T Get(string? name) => value;
+ public IDisposable? OnChange(Action listener) => null;
+ }
+}
diff --git a/tests/Foragent.Agent.Tests/PostToSiteCapabilityTests.cs b/tests/Foragent.Agent.Tests/PostToSiteCapabilityTests.cs
new file mode 100644
index 0000000..32b4621
--- /dev/null
+++ b/tests/Foragent.Agent.Tests/PostToSiteCapabilityTests.cs
@@ -0,0 +1,205 @@
+using Foragent.Browser;
+using Foragent.Capabilities;
+using Foragent.Capabilities.SitePosting;
+using Foragent.Credentials;
+using Microsoft.Extensions.Logging.Abstractions;
+using RockBot.A2A;
+using Xunit;
+
+namespace Foragent.Agent.Tests;
+
+public class PostToSiteCapabilityTests
+{
+ [Fact]
+ public async Task DispatchesToSitePoster_OnSuccess()
+ {
+ var poster = new CapturingPoster("bluesky");
+ var capability = Build(poster, broker: SingleCredential("rockbot/social/bluesky-rocky"));
+ var (context, _) = TestContext.Build();
+
+ var result = await capability.ExecuteAsync(
+ TestContext.Request("post-to-site",
+ """{"site":"bluesky","credentialId":"rockbot/social/bluesky-rocky","content":"hello world"}"""),
+ context);
+
+ Assert.Equal(AgentTaskState.Completed, result.State);
+ Assert.Equal("Posted to bluesky.", TestContext.TextOf(result));
+ Assert.Equal("hello world", poster.LastContent);
+ Assert.Equal("rockbot/social/bluesky-rocky", poster.LastCredentialId);
+ }
+
+ [Fact]
+ public async Task AcceptsInput_FromMetadata()
+ {
+ var poster = new CapturingPoster("bluesky");
+ var capability = Build(poster, broker: SingleCredential("cred-id"));
+ var (context, _) = TestContext.Build();
+ var request = TestContext.RequestWithMetadata(
+ "post-to-site",
+ messageMetadata: new Dictionary
+ {
+ ["site"] = "bluesky",
+ ["credentialId"] = "cred-id",
+ ["content"] = "via metadata"
+ });
+
+ var result = await capability.ExecuteAsync(request, context);
+
+ Assert.Equal(AgentTaskState.Completed, result.State);
+ Assert.Equal("via metadata", poster.LastContent);
+ }
+
+ [Fact]
+ public async Task ReportsMissingCredential_WithoutCreatingSession()
+ {
+ var poster = new CapturingPoster("bluesky");
+ var factory = new StubBrowserSessionFactory();
+ var capability = new PostToSiteCapability(
+ factory,
+ new StubCredentialBroker(),
+ [poster],
+ NullLogger.Instance);
+ var (context, _) = TestContext.Build();
+
+ var result = await capability.ExecuteAsync(
+ TestContext.Request("post-to-site",
+ """{"site":"bluesky","credentialId":"ghost","content":"hi"}"""),
+ context);
+
+ Assert.Equal(0, factory.SessionsCreated);
+ Assert.Contains("'ghost'", TestContext.TextOf(result));
+ Assert.Contains("not configured", TestContext.TextOf(result));
+ }
+
+ [Fact]
+ public async Task ReportsUnknownSite()
+ {
+ var poster = new CapturingPoster("bluesky");
+ var capability = Build(poster, broker: SingleCredential("cred-id"));
+ var (context, _) = TestContext.Build();
+
+ var result = await capability.ExecuteAsync(
+ TestContext.Request("post-to-site",
+ """{"site":"mastodon","credentialId":"cred-id","content":"hi"}"""),
+ context);
+
+ Assert.Contains("mastodon", TestContext.TextOf(result));
+ Assert.Contains("Known sites", TestContext.TextOf(result));
+ }
+
+ [Fact]
+ public async Task ReportsInvalidJson()
+ {
+ var poster = new CapturingPoster("bluesky");
+ var capability = Build(poster, broker: SingleCredential("cred-id"));
+ var (context, _) = TestContext.Build();
+
+ var result = await capability.ExecuteAsync(
+ TestContext.Request("post-to-site", "{not json"),
+ context);
+
+ Assert.Contains("JSON", TestContext.TextOf(result));
+ }
+
+ [Fact]
+ public async Task ReportsMissingFields()
+ {
+ var poster = new CapturingPoster("bluesky");
+ var capability = Build(poster, broker: SingleCredential("cred-id"));
+ var (context, _) = TestContext.Build();
+
+ var result = await capability.ExecuteAsync(
+ TestContext.Request("post-to-site", """{"site":"bluesky"}"""),
+ context);
+
+ Assert.Contains("credentialId", TestContext.TextOf(result));
+ }
+
+ [Fact]
+ public async Task ScrubsExceptionMessage_OnPosterFailure()
+ {
+ // If a poster throws with credential-shaped text in the message, the
+ // capability must NOT echo it back — the caller sees a generic
+ // failure message; the full exception is only logged.
+ var poster = new ThrowingPoster("bluesky", "secret-pw-leak");
+ var capability = Build(poster, broker: SingleCredential("cred-id"));
+ var (context, _) = TestContext.Build();
+
+ var result = await capability.ExecuteAsync(
+ TestContext.Request("post-to-site",
+ """{"site":"bluesky","credentialId":"cred-id","content":"hi"}"""),
+ context);
+
+ var text = TestContext.TextOf(result);
+ Assert.Equal("Post to bluesky failed.", text);
+ Assert.DoesNotContain("secret-pw-leak", text);
+ }
+
+ private static PostToSiteCapability Build(
+ ISitePoster poster,
+ ICredentialBroker broker)
+ {
+ var factory = new StubBrowserSessionFactory();
+ return new PostToSiteCapability(
+ factory,
+ broker,
+ [poster],
+ NullLogger.Instance);
+ }
+
+ private static StubCredentialBroker SingleCredential(string id) =>
+ new()
+ {
+ Credentials =
+ {
+ [id] = CredentialReference.FromText(id, "username-password",
+ new Dictionary
+ {
+ ["identifier"] = "u",
+ ["password"] = "p"
+ })
+ }
+ };
+
+ private sealed class CapturingPoster(string site) : ISitePoster
+ {
+ public string Site { get; } = site;
+ public string? LastContent { get; private set; }
+ public string? LastCredentialId { get; private set; }
+
+ public Task PostAsync(
+ IBrowserSession session,
+ CredentialReference credential,
+ string content,
+ CancellationToken ct)
+ {
+ LastContent = content;
+ LastCredentialId = credential.Id;
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class ThrowingPoster(string site, string sensitiveText) : ISitePoster
+ {
+ public string Site { get; } = site;
+
+ public Task PostAsync(
+ IBrowserSession session,
+ CredentialReference credential,
+ string content,
+ CancellationToken ct) =>
+ throw new InvalidOperationException($"Auth failed — {sensitiveText}");
+ }
+}
+
+internal sealed class StubCredentialBroker : ICredentialBroker
+{
+ public Dictionary Credentials { get; } = new();
+
+ public Task ResolveAsync(string credentialId, CancellationToken ct = default)
+ {
+ if (!Credentials.TryGetValue(credentialId, out var cred))
+ throw new CredentialNotFoundException(credentialId);
+ return Task.FromResult(cred);
+ }
+}
diff --git a/tests/Foragent.Agent.Tests/TestDoubles.cs b/tests/Foragent.Agent.Tests/TestDoubles.cs
index 4e4f07d..cab0d0c 100644
--- a/tests/Foragent.Agent.Tests/TestDoubles.cs
+++ b/tests/Foragent.Agent.Tests/TestDoubles.cs
@@ -94,6 +94,9 @@ internal sealed class StubBrowserSessionFactory : IBrowserSessionFactory
public Func> SnapshotResponder { get; set; } =
(url, _) => Task.FromResult(new PageSnapshot(url, "stub", "stub content", PageSnapshotSource.Accessibility));
+ public Func> PageResponder { get; set; } =
+ (_, _) => Task.FromResult(new StubBrowserPage());
+
public int SessionsCreated { get; private set; }
public int SessionsDisposed { get; private set; }
@@ -111,6 +114,9 @@ private sealed class StubSession(StubBrowserSessionFactory owner) : IBrowserSess
public Task CapturePageSnapshotAsync(Uri url, CancellationToken ct = default) =>
owner.SnapshotResponder(url, ct);
+ public Task OpenPageAsync(Uri url, CancellationToken ct = default) =>
+ owner.PageResponder(url, ct);
+
public ValueTask DisposeAsync()
{
owner.SessionsDisposed++;
@@ -119,6 +125,46 @@ public ValueTask DisposeAsync()
}
}
+internal sealed class StubBrowserPage : IBrowserPage
+{
+ public List Actions { get; } = [];
+ public Uri CurrentUrl { get; set; } = new("https://stub.example/");
+
+ public Task NavigateAsync(Uri url, CancellationToken ct = default)
+ {
+ CurrentUrl = url;
+ Actions.Add($"navigate:{url}");
+ return Task.CompletedTask;
+ }
+
+ public Task FillAsync(string selector, string value, CancellationToken ct = default)
+ {
+ // Record the selector but not the value — tests for post-to-site must
+ // never accidentally assert on password text.
+ Actions.Add($"fill:{selector}");
+ return Task.CompletedTask;
+ }
+
+ public Task ClickAsync(string selector, CancellationToken ct = default)
+ {
+ Actions.Add($"click:{selector}");
+ return Task.CompletedTask;
+ }
+
+ public Task WaitForSelectorAsync(string selector, TimeSpan? timeout = null, CancellationToken ct = default)
+ {
+ Actions.Add($"wait:{selector}");
+ return Task.CompletedTask;
+ }
+
+ public Task GetUrlAsync(CancellationToken ct = default) => Task.FromResult(CurrentUrl);
+
+ public Task GetTextAsync(string selector, CancellationToken ct = default) =>
+ Task.FromResult(null);
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
internal sealed class StubChatClient(Func, ChatOptions?, Task> responder)
: IChatClient
{
diff --git a/tests/Foragent.Browser.Tests/BlueskySitePosterIntegrationTests.cs b/tests/Foragent.Browser.Tests/BlueskySitePosterIntegrationTests.cs
new file mode 100644
index 0000000..e8a7b4c
--- /dev/null
+++ b/tests/Foragent.Browser.Tests/BlueskySitePosterIntegrationTests.cs
@@ -0,0 +1,205 @@
+using Foragent.Capabilities.SitePosting;
+using Foragent.Credentials;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Foragent.Browser.Tests;
+
+///
+/// Drives the real against a Kestrel-hosted
+/// fake bsky.app-shaped login + compose UI. Validates the full login → post
+/// → confirm flow through a real Chromium. The fake server mirrors the
+/// selectors the poster targets (sign-in button, identifier / password
+/// placeholders, compose contenteditable, post button, home feed heading).
+///
+[Collection("Playwright")]
+public class BlueskySitePosterIntegrationTests(TestPageServerFixture fixture)
+{
+ [Fact]
+ public async Task Posts_AfterLogin_OnHappyPath()
+ {
+ await using var fake = await FakeBlueskyServer.StartAsync(
+ expectedIdentifier: "rocky.bsky.social",
+ expectedPassword: "app-pass-xyz");
+
+ var poster = new BlueskySitePoster(
+ NullLogger.Instance,
+ new Uri(fake.BaseUrl + "/"));
+ var credential = CredentialReference.FromText(
+ "rockbot/social/bluesky-rocky",
+ "username-password",
+ new Dictionary
+ {
+ ["identifier"] = "rocky.bsky.social",
+ ["password"] = "app-pass-xyz"
+ });
+
+ await using var session = await fixture.Factory.CreateSessionAsync();
+ await poster.PostAsync(session, credential, "hello from Foragent integration test", CancellationToken.None);
+
+ Assert.Equal("hello from Foragent integration test", fake.LastPostedContent);
+ Assert.Equal(1, fake.SuccessfulLogins);
+ }
+
+ [Fact]
+ public async Task Throws_WhenCredentialFieldMissing()
+ {
+ var poster = new BlueskySitePoster(
+ NullLogger.Instance,
+ new Uri("http://127.0.0.1/"));
+ var credential = CredentialReference.FromText(
+ "id", "username-password",
+ new Dictionary { ["identifier"] = "u" });
+
+ await using var session = await fixture.Factory.CreateSessionAsync();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ poster.PostAsync(session, credential, "hi", CancellationToken.None));
+
+ Assert.Contains("password", ex.Message);
+ }
+}
+
+///
+/// A minimal HTML server that shapes like the Bluesky web UI enough for
+/// to drive. Hand-rolled HTML keeps the test
+/// deterministic — no JS frameworks, no network, no external state.
+///
+internal sealed class FakeBlueskyServer : IAsyncDisposable
+{
+ private const string SessionCookieName = "fake_bsky_session";
+
+ private readonly WebApplication _app;
+ private readonly string _expectedIdentifier;
+ private readonly string _expectedPassword;
+
+ public string BaseUrl { get; }
+ public string? LastPostedContent { get; private set; }
+ public int SuccessfulLogins { get; private set; }
+
+ private FakeBlueskyServer(WebApplication app, string baseUrl, string identifier, string password)
+ {
+ _app = app;
+ BaseUrl = baseUrl;
+ _expectedIdentifier = identifier;
+ _expectedPassword = password;
+ }
+
+ public static async Task StartAsync(string expectedIdentifier, string expectedPassword)
+ {
+ var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions());
+ builder.WebHost.UseKestrelCore();
+ builder.WebHost.UseUrls("http://127.0.0.1:0");
+ builder.Services.AddRoutingCore();
+ builder.Logging.ClearProviders();
+
+ var app = builder.Build();
+ app.UseRouting();
+
+ // Built first so handlers can close over the instance and write state
+ // directly. Routes are registered below.
+ FakeBlueskyServer? fake = null;
+
+ app.MapGet("/", () => Results.Content(Landing(), "text/html"));
+
+ app.MapGet("/login", () => Results.Content(LoginForm(), "text/html"));
+
+ app.MapPost("/login", async (HttpContext ctx) =>
+ {
+ var form = await ctx.Request.ReadFormAsync();
+ var id = form["identifier"].ToString();
+ var pw = form["password"].ToString();
+ if (id != fake!._expectedIdentifier || pw != fake._expectedPassword)
+ return Results.Content(LoginForm(error: "Invalid credentials"), "text/html");
+
+ fake.SuccessfulLogins++;
+ ctx.Response.Cookies.Append(SessionCookieName, "ok");
+ return Results.Redirect("/home");
+ });
+
+ app.MapGet("/home", (HttpContext ctx) =>
+ {
+ if (ctx.Request.Cookies[SessionCookieName] != "ok")
+ return Results.Redirect("/login");
+ return Results.Content(Home(), "text/html");
+ });
+
+ app.MapGet("/compose", (HttpContext ctx) =>
+ {
+ if (ctx.Request.Cookies[SessionCookieName] != "ok")
+ return Results.Redirect("/login");
+ return Results.Content(Compose(), "text/html");
+ });
+
+ app.MapPost("/compose", async (HttpContext ctx) =>
+ {
+ if (ctx.Request.Cookies[SessionCookieName] != "ok")
+ return Results.Redirect("/login");
+ var form = await ctx.Request.ReadFormAsync();
+ fake!.LastPostedContent = form["content"].ToString();
+ return Results.Redirect("/home");
+ });
+
+ await app.StartAsync();
+ var server = app.Services.GetRequiredService();
+ var baseUrl = server.Features.Get()!.Addresses.First().TrimEnd('/');
+
+ fake = new FakeBlueskyServer(app, baseUrl, expectedIdentifier, expectedPassword);
+ return fake;
+ }
+
+ public async ValueTask DisposeAsync() => await _app.DisposeAsync();
+
+ // ── HTML fragments — minimal but shaped for the poster's selectors ──────
+
+ private static string Landing() => """
+ Bluesky
+
+ Welcome
+ Sign in
+
+ """;
+
+ private static string LoginForm(string? error = null) => $$"""
+ Sign in
+
+
+ {{(error is null ? "" : $"{error}
")}}
+
+ """;
+
+ private static string Home() => """
+ Home - Bluesky
+
+ Home
+ New post
+
+ """;
+
+ private static string Compose() => """
+ Compose - Bluesky
+
+
+
+
+ """;
+}
diff --git a/tests/Foragent.Browser.Tests/Foragent.Browser.Tests.csproj b/tests/Foragent.Browser.Tests/Foragent.Browser.Tests.csproj
index eb17bcb..d68cd27 100644
--- a/tests/Foragent.Browser.Tests/Foragent.Browser.Tests.csproj
+++ b/tests/Foragent.Browser.Tests/Foragent.Browser.Tests.csproj
@@ -15,5 +15,6 @@
+