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 @@ +