From 5687e1e363b390d8a0edb03b51d108522ac37456 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 4 Jun 2026 10:15:18 -0400 Subject: [PATCH 1/2] feat(a2a): A2A 0.3.0 Agent Card conformance (closes #85, FWS-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forge's Agent Card now conforms to the A2A 0.3.0 spec end-to-end: canonical path, required-field shape, security schemes derived from the configured auth chain, and SKILL.md skills bridged into the published card at both forge build and forge run time. What was wrong before: Path: served at /.well-known/agent.json; A2A 0.3.0 spec is /.well-known/agent-card.json. Shape: missing required fields version, protocolVersion, defaultInputModes, defaultOutputModes, securitySchemes, security. Skills: SkillBuilderCodegen aside, the build pipeline's ConfigToAgentSpec never populated spec.A2A.Skills from SKILL.md — so the runtime's AgentCardFromSpec path mapped an empty list. AgentCardFromConfig only surfaced builtin tools. Net effect: SKILL.md skills never reached the card, dev or post-build. Audit: no agent_card_published event despite the spec calling for one at startup. What landed: forge-core/a2a/types.go AgentCard + Skill extended additively with the A2A 0.3.0 required fields. SecurityScheme, OAuthFlows, OAuthFlow, AgentProvider types added. Existing consumers unaffected. forge-core/runtime/agentcard.go AgentCardFromSpec + AgentCardFromConfig populate Version (from spec/cfg, default "0.0.0"), pinned ProtocolVersion = "0.3.0", default IO modes ["text/plain", "application/json"], fallback Skill.Tags so A2A 0.3.0's non-empty-tags requirement is always met. forge-core/runtime/agentcard_security.go (new) PopulateSecuritySchemes derives the card's securitySchemes + security from the auth chain — static_token / http_verifier -> http+bearer, oidc / azure_ad -> openIdConnect with discovery URL, gcp_iap -> apiKey in header, aws_sigv4 -> custom bearerFormat "forge-aws-v1". Additive: pre-existing schemes on the card are preserved. forge-core/runtime/agentcard_skills.go (new) AppendSkillsFromDescriptors bridges contract.SkillDescriptor -> a2a.Skill with category-first tag ordering, case-insensitive dedup, deterministic ID-sorted output so card bytes are stable across restarts (so the audit event's sha256 is meaningful). forge-core/runtime/audit.go New EventAgentCardPublished constant. forge-core/auth/middleware.go DefaultSkipPaths admits both the canonical /.well-known/agent-card.json and the legacy /.well-known/agent.json (GET + OPTIONS). forge-cli/build/agentspec_skills.go + agentspec_stage.go Build pipeline now writes spec.A2A.Skills from SKILL.md frontmatter — walking skills/*.md, skills/*/SKILL.md, and the main skill file (skills.path or default SKILL.md). Output sorted by ID for byte-stable agent.json across rebuilds. Skips files without name: frontmatter (no A2A-stable identity); dedupes first-wins. forge-cli/runtime/runner.go + runner_agentcard_skills.go (new) Runner mirrors the same walk at startup and on hot-reload via enrichAgentCardWith Skills. Additive: when the build artifact already populated spec.A2A.Skills, the runtime walk is a no-op via dedup. When there is no build artifact (forge dev, forge run from source), the runtime walk is the sole source. Card skill list is identical in dev and deployed modes. forge-cli/runtime/runner_agentcard_audit.go (new) emitAgentCardPublished writes the audit event after server start (and on each hot-reload) with name, version, protocol_ version, url, skill_count, capabilities, security_schemes (names only), card_size_bytes, card_sha256. Best- effort: serialization failure logs a warning, never blocks startup. NO card body bytes in the event (size + hash only) — same byte-payload discipline every other Forge audit event respects. forge-cli/server/a2a_server.go Registers GET /.well-known/agent-card.json as the canonical handler. Legacy /.well-known/agent.json routes to a new handleAgentCardLegacy that emits the same body plus a Deprecation: true response header per RFC 8594 and a Link header pointing at the successor path. Removable one release after this ships. forge-cli/runtime/runner.go banner Now shows the canonical path. Regression tests (29 total, all -race green): forge-core/runtime/agentcard_test.go (9 tests) Required-field population, fallback defaulting, JSON omitempty behavior, deterministic skill ordering, dedup against existing skills, SKILL.md frontmatter -> AgentSkill mapping fidelity. forge-core/runtime/agentcard_security_test.go (8 tests) Every supported provider type -> scheme mapping; no-op semantics when chain is empty or cfg is nil; Security array OR-ordering; additive-no-clobber behavior when caller pre-configured a scheme. forge-cli/server/a2a_server_agentcard_test.go (3 tests) HTTP end-to-end: canonical path returns card with NO Deprecation header; legacy path returns identical body WITH Deprecation + Link headers; byte-for-byte body parity across both paths. forge-cli/build/agentspec_skills_test.go (6 tests) Flat + subdir SKILL.md discovery; nameless files skipped; cross-path dedup; deterministic ID-sorted output; no-op when no SKILL.md exists; custom skills.path honored. forge-cli/runtime/runner_agentcard_skills_test.go (4 tests) Runtime enrichment adds parsed SKILL.md; preserves existing skills (no clobber); no-op when no SKILL.md present; skips nameless files. Documentation: docs/reference/a2a-agent-card.md (new) Full reference: card shape, SKILL.md -> AgentSkill mapping table, where the skill list comes from (build pipeline AND runtime), security-scheme mapping table, audit event shape, dev vs deployed parity guarantees. docs/security/audit-logging.md Adds agent_card_published to the event types table with field inventory + cross-link. docs/core-concepts/how-forge-works.md AgentSpecStage row updated to mention spec.A2A.Skills population. README.md Operations table links the new reference page. CHANGELOG.md Added entry under "Added"; legacy path noted under "Deprecated". go test -race -count=1 across forge-core/runtime, forge-core/auth, forge-cli/build, forge-cli/server, forge-cli/runtime — all green. golangci-lint and gofmt clean across forge-core + forge-cli. --- CHANGELOG.md | 20 ++ README.md | 1 + docs/core-concepts/how-forge-works.md | 2 +- docs/reference/a2a-agent-card.md | 109 ++++++++ docs/security/audit-logging.md | 1 + forge-cli/build/agentspec_skills.go | 125 ++++++++++ forge-cli/build/agentspec_skills_test.go | 167 +++++++++++++ forge-cli/build/agentspec_stage.go | 7 + forge-cli/runtime/runner.go | 21 +- forge-cli/runtime/runner_agentcard_audit.go | 64 +++++ forge-cli/runtime/runner_agentcard_skills.go | 81 ++++++ .../runtime/runner_agentcard_skills_test.go | 123 +++++++++ forge-cli/server/a2a_server.go | 20 +- forge-cli/server/a2a_server_agentcard_test.go | 186 ++++++++++++++ forge-core/a2a/types.go | 167 ++++++++++++- forge-core/auth/middleware.go | 23 +- forge-core/runtime/agentcard.go | 71 +++++- forge-core/runtime/agentcard_security.go | 132 ++++++++++ forge-core/runtime/agentcard_security_test.go | 170 +++++++++++++ forge-core/runtime/agentcard_skills.go | 103 ++++++++ forge-core/runtime/agentcard_test.go | 233 ++++++++++++++++++ forge-core/runtime/audit.go | 8 + 22 files changed, 1804 insertions(+), 30 deletions(-) create mode 100644 docs/reference/a2a-agent-card.md create mode 100644 forge-cli/build/agentspec_skills.go create mode 100644 forge-cli/build/agentspec_skills_test.go create mode 100644 forge-cli/runtime/runner_agentcard_audit.go create mode 100644 forge-cli/runtime/runner_agentcard_skills.go create mode 100644 forge-cli/runtime/runner_agentcard_skills_test.go create mode 100644 forge-cli/server/a2a_server_agentcard_test.go create mode 100644 forge-core/runtime/agentcard_security.go create mode 100644 forge-core/runtime/agentcard_security_test.go create mode 100644 forge-core/runtime/agentcard_skills.go create mode 100644 forge-core/runtime/agentcard_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0bd5b..efa5a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ### Added +- **A2A 0.3.0 Agent Card conformance (issue #85, FWS-1).** Forge now + serves a spec-conformant Agent Card at the A2A 0.3.0 canonical path + `/.well-known/agent-card.json`. The card carries every required A2A + 0.3.0 field — `version`, `protocolVersion` (pinned to `0.3.0`), + `defaultInputModes`, `defaultOutputModes` — plus `securitySchemes` + derived from the configured auth chain (`static_token` → HTTP + bearer, `oidc` → openIdConnect with discovery URL, `gcp_iap` → apiKey + in header, `aws_sigv4` → custom bearer format, etc.), and emits an + `agent_card_published` audit event on startup carrying the card's + identity + size + a sha256 hash so downstream consumers can detect + config drift. Identical card shape across `forge dev` and deployed + modes. See `docs/reference/a2a-agent-card.md`. - **Workspace-level skill-builder LLM config (issue #92).** The `forge ui` skill builder now reads its LLM configuration from `/.forge/ui.yaml` (or `~/.forge/ui.yaml` as a machine-wide @@ -40,6 +52,14 @@ credential stomping when switching agents in the UI. Credentials are now threaded as request-scoped values. +### Deprecated + +- **Legacy Agent Card path `/.well-known/agent.json` (issue #85).** Still + served and returns the same body as the canonical + `/.well-known/agent-card.json`, but now emits a `Deprecation: true` + response header per RFC 8594 plus a `Link` header pointing at the + successor path. Scheduled for removal in the release after next. + ### Fixed - **`forge init` Custom provider now produces a runnable agent (issue #83).** diff --git a/README.md b/README.md index 91de470..00c2bb2 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ You write a `SKILL.md`. Forge compiles it into a secure, runnable agent with egr | Document | Description | |----------|-------------| +| [A2A Agent Card](docs/reference/a2a-agent-card.md) | Spec-conformant Agent Card at `/.well-known/agent-card.json` (A2A 0.3.0) | | [Commands](docs/reference/cli-reference.md) | Full CLI reference | | [Configuration](docs/reference/forge-yaml-schema.md) | `forge.yaml` schema and environment variables | | [Dashboard](docs/reference/web-dashboard.md) | Web UI features and architecture | diff --git a/docs/core-concepts/how-forge-works.md b/docs/core-concepts/how-forge-works.md index 62bc067..a8562b2 100644 --- a/docs/core-concepts/how-forge-works.md +++ b/docs/core-concepts/how-forge-works.md @@ -265,7 +265,7 @@ The build pipeline executes stages sequentially. Each stage lives in `forge-cli/ | # | Stage | Produces | |---|-------|----------| | 1 | **FrameworkAdapterStage** | Detects framework (crewai/langchain/custom), extracts agent config, generates A2A wrapper | -| 2 | **AgentSpecStage** | `agent.json` — canonical AgentSpec from ForgeConfig | +| 2 | **AgentSpecStage** | `agent.json` — canonical AgentSpec from ForgeConfig, including `a2a.skills` populated from SKILL.md frontmatter so post-build A2A clients see the agent's skill surface (see [A2A Agent Card](../reference/a2a-agent-card.md)) | | 3 | **ToolsStage** | Tool schema files from discovered and configured tools | | 4 | **PolicyStage** | `policy-scaffold.json` — guardrail configuration | | 5 | **DockerfileStage** | `Dockerfile` — container image definition | diff --git a/docs/reference/a2a-agent-card.md b/docs/reference/a2a-agent-card.md new file mode 100644 index 0000000..4293721 --- /dev/null +++ b/docs/reference/a2a-agent-card.md @@ -0,0 +1,109 @@ +# A2A Agent Card + +Every Forge agent publishes an Agent Card per the [Agent2Agent (A2A) Protocol](https://github.com/google/a2a-spec) so peer agents, orchestrators (initializ platform, custom registries), and A2A-aware tooling can discover its identity, capabilities, and authentication shape via a single `GET`. + +The card is JSON, conforms to **A2A 0.3.0**, and lives at the spec-canonical path. + +``` +GET http://:/.well-known/agent-card.json +``` + +The legacy path `GET /.well-known/agent.json` is still served and returns the same body, but emits a `Deprecation: true` response header per [RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594) and a `Link` header pointing at the canonical path. The legacy alias will be removed one release after this change ships. + +Both paths are public — `DefaultSkipPaths` exempts them from the auth chain. + +## Card shape + +A Forge agent's card always contains every field A2A 0.3.0 marks as required: + +| Field | Source | Notes | +|---|---|---| +| `name` | `forge.yaml` `agent_id` (or `agentspec.Name`) | Required. | +| `description` | `agentspec.Description` | Optional but Forge populates. | +| `url` | `http://:` of the running A2A server | Required. | +| `version` | `forge.yaml` `version` (or `agentspec.Version`) | Required. Defaults to `0.0.0` when not set. | +| `protocolVersion` | Pinned at build time | Always `0.3.0`. Bumping is a deliberate PR. | +| `defaultInputModes` | Forge default | `["text/plain", "application/json"]`. | +| `defaultOutputModes` | Forge default | `["text/plain", "application/json"]`. | +| `skills` | `agentspec.A2A.Skills` (build-time SKILL.md mapping) + builtin tools | A2A `AgentSkill` objects; see below. | +| `capabilities` | `agentspec.A2A.Capabilities` | `streaming`, `pushNotifications`, `stateTransitionHistory`. | +| `securitySchemes` | Derived from `auth.providers` | See *Security* below. | +| `security` | Derived from `auth.providers` | First-match-wins → OR-list per A2A semantics. | + +Forge-internal fields (egress allowlist, denied tools, trust hints, guardrails) are intentionally **not** serialized into the Agent Card. The card is a public discovery surface; those fields are runtime contracts that stay inside Forge. + +## AgentSkill mapping + +The SKILL.md frontmatter maps to A2A `AgentSkill` objects with no information loss for spec-defined fields: + +| `SKILL.md` frontmatter | A2A `AgentSkill` field | +|---|---| +| `name` | `id` and `name` | +| (display name from frontmatter, if any) | `name` (overrides `id`-derived name) | +| `description` | `description` | +| `category` | `tags[0]` (so clients can group) | +| `tags` | `tags[]` (appended, case-insensitive dedup) | + +A2A 0.3.0 makes `tags` **required** — Forge falls back to `["skill"]` (or `["tool"]` for builtin tools surfaced as skills) when neither category nor tags are supplied, so the field is always non-empty. + +`examples`, `inputModes`, `outputModes` are spec-optional and currently not populated from SKILL.md. A future SKILL.md schema bump can add `examples:` and `modes:` blocks; the types already accept them. + +### Where the skill list comes from + +Forge walks SKILL.md frontmatter in **two places** — and both apply the same mapping rules above: + +1. **`forge build`** — the `generate-agentspec` stage discovers `skills/*.md`, `skills/*/SKILL.md`, and the main agent skill (default `SKILL.md`, or `skills.path` from `forge.yaml`), parses each, and writes the result into `agent.json` under `a2a.skills`. This is what initializ-side registries and any consumer reading the raw `agent.json` will see. +2. **`forge run` / `forge dev`** — the runner does the same walk at agent startup and appends discovered skills onto the card. Pre-existing skills (from `agent.json`'s `a2a.skills`) take precedence; the runtime enrichment only fills gaps. This means agents started directly from source — before `forge build` runs — still publish the right skill set. + +Both paths sort the discovered skills deterministically by ID so the resulting card bytes are stable across rebuilds + restarts (and so the `agent_card_published` audit event's sha256 hash is meaningful). + +The card is fixed at agent startup (matches the binary's embedded skills + build artifact + runtime SKILL.md walk). Hot-reload via the file watcher re-runs the walk, rebuilds the card, and re-emits the `agent_card_published` audit event. + +## Security schemes + +When `forge.yaml` declares an `auth:` chain, every provider becomes one entry in `securitySchemes` and one entry in `security`. The mapping mirrors the auth middleware's actual acceptance rules: + +| `auth.providers[].type` | A2A scheme | Notes | +|---|---|---| +| `static_token` | `http` + `bearer` | Shared-secret token in `Authorization`. | +| `http_verifier` | `http` + `bearer` | Opaque bearer; external verifier validates. | +| `oidc` | `openIdConnect` | `openIdConnectUrl` derived from `issuer`. | +| `azure_ad` | `openIdConnect` | `openIdConnectUrl` is the AAD per-tenant well-known. | +| `gcp_iap` | `apiKey` in `header` | `X-Goog-Iap-Jwt-Assertion`. | +| `aws_sigv4` | `http` + `bearer` (custom `bearerFormat: "forge-aws-v1"`) | Pre-signed STS URL wrapped in a Bearer. | + +Schemes Forge doesn't have a well-defined mapping for emit nothing in the card — the auth chain still enforces them; the card just doesn't advertise the credential shape. Operators with a hand-wired scheme set on the card before runtime invocation are preserved verbatim (the deriver is additive). + +The `security` array carries one OR-entry per scheme, matching Forge's first-match-wins chain semantics: presenting any one configured credential satisfies the requirement. + +## Audit event on publish + +Each time Forge finalizes an Agent Card (startup + file-watcher hot-reload), the runtime emits one `agent_card_published` audit event to the audit logger: + +```json +{ + "event": "agent_card_published", + "fields": { + "name": "weather-agent", + "version": "0.4.2", + "protocol_version": "0.3.0", + "url": "http://localhost:8080", + "skill_count": 7, + "capabilities": {"streaming": true, "push_notifications": false, "state_transition_history": false}, + "security_schemes": ["static_token", "oidc"], + "card_size_bytes": 3471, + "card_sha256": "3a8c…" + } +} +``` + +The event carries identity + size metadata + a sha256 of the JSON-encoded card so audit consumers can detect config drift across deploys. Full payload bytes are intentionally NOT emitted — the same discipline every other Forge audit event respects. + +## `forge dev` vs deployed parity + +The card builder uses the same code path in both environments: + +- `forge dev` / `forge run` from source — `AgentCardFromConfig(cfg, baseURL)` populates the core fields from `forge.yaml`; the runner then walks SKILL.md files in the workdir and appends discovered skills via `enrichAgentCardWithSkills`. +- After `forge build` produces `.forge-output/agent.json` — `AgentCardFromSpec(spec, baseURL)` populates from the spec (which already carries `a2a.skills` populated at build time); the runner's enrichment then appends any SKILL.md skills not already represented (no-op when the build artifact is complete). + +Both paths apply the same `PopulateSecuritySchemes` deriver and emit the same `agent_card_published` event. **The card's JSON shape and skill list are identical in both environments** — the only difference is whether `agent.json` exists on disk; the runtime guarantees parity by walking SKILL.md regardless. diff --git a/docs/security/audit-logging.md b/docs/security/audit-logging.md index e376751..bbe2981 100644 --- a/docs/security/audit-logging.md +++ b/docs/security/audit-logging.md @@ -21,6 +21,7 @@ All runtime security events are emitted as structured NDJSON to stderr with corr | `guardrail_check` | Guardrail evaluation result | | `auth_verify` | Inbound request authenticated successfully (with `provider`, `user_id`, `org_id`, `token_kind`) | | `auth_fail` | Inbound request rejected (with `reason`, `token_kind`) | +| `agent_card_published` | Agent Card finalized at startup or hot-reload (with `name`, `version`, `protocol_version`, `url`, `skill_count`, `capabilities`, `security_schemes`, `card_size_bytes`, `card_sha256`). See [Agent Card reference](../reference/a2a-agent-card.md). | ### Example diff --git a/forge-cli/build/agentspec_skills.go b/forge-cli/build/agentspec_skills.go new file mode 100644 index 0000000..cdba80c --- /dev/null +++ b/forge-cli/build/agentspec_skills.go @@ -0,0 +1,125 @@ +package build + +import ( + "os" + "path/filepath" + "sort" + "strings" + + cliskills "github.com/initializ/forge/forge-cli/skills" + "github.com/initializ/forge/forge-core/agentspec" +) + +// populateA2ASkillsFromSKILLmd walks the agent's SKILL.md files at +// build time, parses their frontmatter, and writes the discovered +// skills into `spec.A2A.Skills`. Mirrors the runtime enrichment in +// `forge-cli/runtime/runner_agentcard_skills.go` so the published +// AgentSpec (consumed by initializ-side registries and any A2A client +// that reads `agent.json` directly) carries the same skill list the +// runtime's `/.well-known/agent-card.json` advertises. +// +// File discovery mirrors `Runner.discoverSkillFiles`: +// +// skills/*.md — flat-format skill files +// skills/*/SKILL.md — subdirectory-format skill files +// — main agent skill (default "SKILL.md") +// +// Skills with no `metadata.name` (or `name`) frontmatter field are +// skipped — they have no A2A-stable identity to advertise. Parsing +// failures are tolerated silently; the runner's own startup pass +// surfaces parse errors with full context. +// +// Output is deterministically ordered by ID so `agent.json` is +// byte-stable across rebuilds. +func populateA2ASkillsFromSKILLmd(spec *agentspec.AgentSpec, workDir, skillsPath string) { + skills := discoverBuildTimeSkills(workDir, skillsPath) + if len(skills) == 0 { + return + } + if spec.A2A == nil { + spec.A2A = &agentspec.A2AConfig{} + } + spec.A2A.Skills = append(spec.A2A.Skills, skills...) + sort.SliceStable(spec.A2A.Skills, func(i, j int) bool { + return spec.A2A.Skills[i].ID < spec.A2A.Skills[j].ID + }) +} + +// discoverBuildTimeSkills is the file-walk + parse + map sequence +// extracted for testability. Returns deduplicated A2ASkill objects +// keyed by ID; the first occurrence of each ID wins. +func discoverBuildTimeSkills(workDir, skillsPath string) []agentspec.A2ASkill { + files := discoverSkillFilePaths(workDir, skillsPath) + if len(files) == 0 { + return nil + } + seen := map[string]struct{}{} + var out []agentspec.A2ASkill + for _, p := range files { + _, meta, err := cliskills.ParseFileWithMetadata(p) + if err != nil || meta == nil { + continue + } + id := strings.TrimSpace(meta.Name) + if id == "" { + continue + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + + // Tags: category first (so A2A clients can group skills), + // then any frontmatter tags. A2A 0.3.0 requires non-empty tags + // on AgentSkill — the runtime's a2a.AgentCard mapper defaults + // missing tags to ["skill"], so we don't have to here. + var tags []string + if cat := strings.TrimSpace(meta.Category); cat != "" { + tags = append(tags, cat) + } + tags = append(tags, meta.Tags...) + + out = append(out, agentspec.A2ASkill{ + ID: id, + Name: firstNonEmptyBuild(meta.Name, id), + Description: strings.TrimSpace(meta.Description), + Tags: tags, + }) + } + return out +} + +// discoverSkillFilePaths returns the same set of SKILL.md file paths +// the runner's `discoverSkillFiles` collects. Kept here (rather than +// shared with the runner) because the build pipeline runs without a +// Runner instance — duplicating ~15 lines beats a circular +// import or a new shared package. +func discoverSkillFilePaths(workDir, skillsPath string) []string { + skillsDir := filepath.Join(workDir, "skills") + + matches, _ := filepath.Glob(filepath.Join(skillsDir, "*.md")) + + subMatches, _ := filepath.Glob(filepath.Join(skillsDir, "*", "SKILL.md")) + matches = append(matches, subMatches...) + + main := skillsPath + if main == "" { + main = "SKILL.md" + } + if !filepath.IsAbs(main) { + main = filepath.Join(workDir, main) + } + if info, err := os.Stat(main); err == nil && !info.IsDir() { + matches = append(matches, main) + } + return matches +} + +func firstNonEmptyBuild(s ...string) string { + for _, v := range s { + if v != "" { + return v + } + } + return "" +} diff --git a/forge-cli/build/agentspec_skills_test.go b/forge-cli/build/agentspec_skills_test.go new file mode 100644 index 0000000..630fee1 --- /dev/null +++ b/forge-cli/build/agentspec_skills_test.go @@ -0,0 +1,167 @@ +package build + +import ( + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" +) + +// Regression test for issue #85: forge build must populate +// spec.A2A.Skills from SKILL.md frontmatter so the post-build +// AgentCard (and any consumer of agent.json) advertises the agent's +// skill surface. + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } +} + +func TestDiscoverBuildTimeSkills_PicksUpFlatAndSubdirFormats(t *testing.T) { + workDir := t.TempDir() + + writeFile(t, filepath.Join(workDir, "skills", "weather.md"), `--- +name: weather +description: Look up the current weather +category: info +tags: [web, json] +--- +# Weather skill body +`) + writeFile(t, filepath.Join(workDir, "skills", "github", "SKILL.md"), `--- +name: github +description: Open issues on GitHub +--- +# GitHub skill body +`) + + got := discoverBuildTimeSkills(workDir, "") + if len(got) != 2 { + t.Fatalf("expected 2 skills, got %d (%+v)", len(got), got) + } + + // Deterministically ordered by ID after populateA2ASkillsFromSKILLmd + // sorts; discoverBuildTimeSkills itself preserves discovery order + // minus dedup, so the test only checks set membership. + byID := map[string]agentspec.A2ASkill{} + for _, s := range got { + byID[s.ID] = s + } + if _, ok := byID["weather"]; !ok { + t.Errorf("flat-format skill weather.md not discovered") + } + if _, ok := byID["github"]; !ok { + t.Errorf("subdir-format skill github/SKILL.md not discovered") + } + // Frontmatter fields propagate. + w := byID["weather"] + if w.Description != "Look up the current weather" { + t.Errorf("description = %q, want frontmatter value", w.Description) + } + if len(w.Tags) == 0 || w.Tags[0] != "info" { + t.Errorf("tags = %v, want category-first then frontmatter tags", w.Tags) + } +} + +func TestDiscoverBuildTimeSkills_SkipsFilesWithoutName(t *testing.T) { + workDir := t.TempDir() + writeFile(t, filepath.Join(workDir, "skills", "noname.md"), `--- +description: Skill without a name field +--- +body +`) + got := discoverBuildTimeSkills(workDir, "") + if len(got) != 0 { + t.Errorf("expected 0 skills (no name → skipped), got %+v", got) + } +} + +func TestDiscoverBuildTimeSkills_DedupesAcrossDiscoveryPaths(t *testing.T) { + workDir := t.TempDir() + body := `--- +name: dup +description: First copy +--- +` + writeFile(t, filepath.Join(workDir, "skills", "dup.md"), body) + writeFile(t, filepath.Join(workDir, "skills", "dup", "SKILL.md"), `--- +name: dup +description: Second copy (should be skipped) +--- +`) + got := discoverBuildTimeSkills(workDir, "") + if len(got) != 1 { + t.Fatalf("expected 1 skill after dedup, got %d", len(got)) + } + if got[0].Description != "First copy" { + t.Errorf("first-wins dedup expected, got %q", got[0].Description) + } +} + +func TestPopulateA2ASkillsFromSKILLmd_SortsByID(t *testing.T) { + workDir := t.TempDir() + writeFile(t, filepath.Join(workDir, "skills", "zoo.md"), `--- +name: zoo +--- +`) + writeFile(t, filepath.Join(workDir, "skills", "alpha.md"), `--- +name: alpha +--- +`) + writeFile(t, filepath.Join(workDir, "skills", "mango.md"), `--- +name: mango +--- +`) + + spec := &agentspec.AgentSpec{AgentID: "test"} + populateA2ASkillsFromSKILLmd(spec, workDir, "") + + if spec.A2A == nil { + t.Fatalf("A2A block should be created when skills are discovered") + } + ids := []string{} + for _, s := range spec.A2A.Skills { + ids = append(ids, s.ID) + } + want := []string{"alpha", "mango", "zoo"} + for i, w := range want { + if i >= len(ids) || ids[i] != w { + t.Errorf("Skills order = %v, want %v (sorted by ID for stable agent.json bytes)", ids, want) + break + } + } +} + +func TestPopulateA2ASkillsFromSKILLmd_NoOpWhenNoSkillsFound(t *testing.T) { + workDir := t.TempDir() + spec := &agentspec.AgentSpec{AgentID: "test"} + populateA2ASkillsFromSKILLmd(spec, workDir, "") + if spec.A2A != nil { + t.Errorf("A2A should remain nil when no SKILL.md files exist, got %+v", spec.A2A) + } +} + +func TestPopulateA2ASkillsFromSKILLmd_HonorsCustomSkillsPath(t *testing.T) { + workDir := t.TempDir() + writeFile(t, filepath.Join(workDir, "AGENT.md"), `--- +name: main +description: Custom-named main skill +--- +`) + + spec := &agentspec.AgentSpec{AgentID: "test"} + populateA2ASkillsFromSKILLmd(spec, workDir, "AGENT.md") + + if spec.A2A == nil || len(spec.A2A.Skills) != 1 { + t.Fatalf("expected 1 skill from custom path, got %+v", spec.A2A) + } + if spec.A2A.Skills[0].Name != "main" { + t.Errorf("Name = %q, want main", spec.A2A.Skills[0].Name) + } +} diff --git a/forge-cli/build/agentspec_stage.go b/forge-cli/build/agentspec_stage.go index d724f34..a541b85 100644 --- a/forge-cli/build/agentspec_stage.go +++ b/forge-cli/build/agentspec_stage.go @@ -26,6 +26,13 @@ func (s *AgentSpecStage) Execute(ctx context.Context, bc *pipeline.BuildContext) spec.Runtime.Entrypoint = compiler.WrapperEntrypoint(bc.WrapperFile) } + // Populate spec.A2A.Skills from SKILL.md frontmatter so the published + // AgentSpec advertises the agent's skill surface (issue #85). Without + // this, consumers of agent.json (and the runner's AgentCardFromSpec + // path post-build) would only see builtin tools — the SKILL.md files + // would never reach the A2A Agent Card. + populateA2ASkillsFromSKILLmd(spec, bc.Opts.WorkDir, bc.Config.Skills.Path) + bc.Spec = spec data, err := json.MarshalIndent(spec, "", " ") diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index 7fb2561..bd20893 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -238,11 +238,17 @@ func (r *Runner) Run(ctx context.Context) error { scaffold = DefaultPolicyScaffold() } - // 3. Build agent card + // 3. Build agent card. Populate security schemes from the configured + // auth chain so the published card reflects what the middleware + // actually accepts, then enrich with SKILL.md frontmatter parsed + // at runtime so dev (no build artifact) and post-build deployments + // surface the same skill list. card, err := BuildAgentCard(r.cfg.WorkDir, r.cfg.Config, r.cfg.Port) if err != nil { return fmt.Errorf("building agent card: %w", err) } + coreruntime.PopulateSecuritySchemes(card, r.cfg.Config) + r.enrichAgentCardWithSkills(card) // 4. Create audit logger (used by hooks and handlers) auditLogger := coreruntime.NewAuditLogger(os.Stderr) @@ -751,8 +757,13 @@ func (r *Runner) Run(ctx context.Context) error { if err != nil { r.logger.Error("failed to reload agent card", map[string]any{"error": err.Error()}) } else { + coreruntime.PopulateSecuritySchemes(newCard, r.cfg.Config) + r.enrichAgentCardWithSkills(newCard) srv.UpdateAgentCard(newCard) r.logger.Info("agent card reloaded", nil) + // Re-emit agent_card_published so audit consumers see the + // new card hash — same event shape as the startup emit. + r.emitAgentCardPublished(auditLogger, newCard) } // Restart subprocess lifecycle (no-op if lifecycle is nil) @@ -767,6 +778,12 @@ func (r *Runner) Run(ctx context.Context) error { // 10. Print startup banner r.printBanner(proxyURL) + // 10b. Emit the agent_card_published audit event (issue #85). One + // per startup; carries identity + size + a sha256 of the JSON- + // encoded card so consumers can detect config drift. Hot-reload + // re-emits via the file watcher above (UpdateAgentCard path). + r.emitAgentCardPublished(auditLogger, card) + // 11. Start server (blocks) return srv.Start(ctx) } @@ -1810,7 +1827,7 @@ func (r *Runner) printBanner(proxyURL string) { fmt.Fprintf(os.Stderr, " Proxy: %s\n", proxyURL) } fmt.Fprintf(os.Stderr, " ────────────────────────────────────────\n") - fmt.Fprintf(os.Stderr, " Agent Card: http://localhost:%d/.well-known/agent.json\n", r.cfg.Port) + fmt.Fprintf(os.Stderr, " Agent Card: http://localhost:%d/.well-known/agent-card.json\n", r.cfg.Port) fmt.Fprintf(os.Stderr, " Health: http://localhost:%d/healthz\n", r.cfg.Port) fmt.Fprintf(os.Stderr, " REST: http://localhost:%d/tasks/send\n", r.cfg.Port) fmt.Fprintf(os.Stderr, " JSON-RPC: POST http://localhost:%d/\n", r.cfg.Port) diff --git a/forge-cli/runtime/runner_agentcard_audit.go b/forge-cli/runtime/runner_agentcard_audit.go new file mode 100644 index 0000000..0554563 --- /dev/null +++ b/forge-cli/runtime/runner_agentcard_audit.go @@ -0,0 +1,64 @@ +package runtime + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + + "github.com/initializ/forge/forge-core/a2a" + coreruntime "github.com/initializ/forge/forge-core/runtime" +) + +// emitAgentCardPublished writes the agent_card_published audit event +// described by issue #85 / FWS-1. The event carries the card's identity +// + size metadata + a sha256 of the JSON-encoded card so consumers can +// detect config drift across deploys. Full payload bytes are +// intentionally NOT emitted — sizes and the hash are enough for an +// audit trail and keep the event under the size budget every other +// Forge event respects. +// +// The emit is best-effort: serialization or audit failures log a +// warning and otherwise let the agent continue. Failing to emit an +// audit event must never block startup. +func (r *Runner) emitAgentCardPublished(audit *coreruntime.AuditLogger, card *a2a.AgentCard) { + if audit == nil || card == nil { + return + } + + raw, err := json.Marshal(card) + if err != nil { + r.logger.Warn("agent_card_published: marshal failed", map[string]any{"error": err.Error()}) + return + } + sum := sha256.Sum256(raw) + + // Collect security scheme names (not their full definitions — + // downstream consumers only need to know which schemes are + // advertised, not the OIDC URLs or bearer formats). + schemeNames := make([]string, 0, len(card.SecuritySchemes)) + for name := range card.SecuritySchemes { + schemeNames = append(schemeNames, name) + } + + caps := map[string]bool{} + if card.Capabilities != nil { + caps["streaming"] = card.Capabilities.Streaming + caps["push_notifications"] = card.Capabilities.PushNotifications + caps["state_transition_history"] = card.Capabilities.StateTransitionHistory + } + + audit.Emit(coreruntime.AuditEvent{ + Event: coreruntime.EventAgentCardPublished, + Fields: map[string]any{ + "name": card.Name, + "version": card.Version, + "protocol_version": card.ProtocolVersion, + "url": card.URL, + "skill_count": len(card.Skills), + "capabilities": caps, + "security_schemes": schemeNames, + "card_size_bytes": len(raw), + "card_sha256": hex.EncodeToString(sum[:]), + }, + }) +} diff --git a/forge-cli/runtime/runner_agentcard_skills.go b/forge-cli/runtime/runner_agentcard_skills.go new file mode 100644 index 0000000..430bbc8 --- /dev/null +++ b/forge-cli/runtime/runner_agentcard_skills.go @@ -0,0 +1,81 @@ +package runtime + +import ( + "strings" + + cliskills "github.com/initializ/forge/forge-cli/skills" + "github.com/initializ/forge/forge-core/a2a" + coreruntime "github.com/initializ/forge/forge-core/runtime" + "github.com/initializ/forge/forge-skills/contract" +) + +// enrichAgentCardWithSkills walks the agent's SKILL.md files +// (`discoverSkillFiles()`) at runtime, parses their frontmatter, and +// appends each parsed skill onto the published Agent Card. +// +// Why this lives in the runner rather than the build pipeline: +// +// - `forge build` calls `compiler.ConfigToAgentSpec` which produces a +// spec with `A2A: nil` — it never walks SKILL.md. So the +// `AgentCardFromSpec(spec, baseURL)` path that the runner uses +// post-build also wouldn't see skills without this enrichment. +// - The runner already discovers SKILL.md files for every other +// purpose (tool registration, skill-guardrail loading, etc.) — it +// has the parser, the workDir, and the frontmatter at hand. +// - Using the same enrichment at both `forge dev` (no build artifact) +// and `forge run` (post-build) means the card's skill list is +// identical in both environments. Issue #85 explicitly calls for +// "card endpoint behavior identical in forge dev (local) and +// deployed modes." +// +// Mapping rules + dedup semantics live in +// `coreruntime.AppendSkillsFromDescriptors`. Pre-existing skills on the +// card (from `agentspec.A2A.Skills` when that's ever populated, or +// builtin tools surfaced via `AgentCardFromSpec`/`AgentCardFromConfig`) +// take precedence — this function only adds skills that aren't already +// represented. +func (r *Runner) enrichAgentCardWithSkills(card *a2a.AgentCard) { + if card == nil { + return + } + descs := r.skillDescriptorsForCard() + if len(descs) == 0 { + return + } + coreruntime.AppendSkillsFromDescriptors(card, descs) +} + +// skillDescriptorsForCard walks every SKILL.md file the runner can +// see and converts each parsed frontmatter to a SkillDescriptor +// shaped for the agent-card mapping. SKILL.md files without a +// `metadata.name` (or top-level `name`) frontmatter field are skipped +// — they have no A2A-stable identity to advertise. +// +// Parsing errors are tolerated silently here: the agent card is a +// best-effort discovery surface; a malformed SKILL.md should not +// prevent agent startup. The same files get parsed elsewhere in the +// startup sequence with their own error reporting. +func (r *Runner) skillDescriptorsForCard() []contract.SkillDescriptor { + files := r.discoverSkillFiles() + if len(files) == 0 { + return nil + } + var out []contract.SkillDescriptor + for _, path := range files { + _, meta, err := cliskills.ParseFileWithMetadata(path) + if err != nil || meta == nil { + continue + } + name := strings.TrimSpace(meta.Name) + if name == "" { + continue + } + out = append(out, contract.SkillDescriptor{ + Name: name, + Description: strings.TrimSpace(meta.Description), + Category: strings.TrimSpace(meta.Category), + Tags: meta.Tags, + }) + } + return out +} diff --git a/forge-cli/runtime/runner_agentcard_skills_test.go b/forge-cli/runtime/runner_agentcard_skills_test.go new file mode 100644 index 0000000..eb9b354 --- /dev/null +++ b/forge-cli/runtime/runner_agentcard_skills_test.go @@ -0,0 +1,123 @@ +package runtime + +import ( + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/types" +) + +// Regression test for issue #85: at runtime (both forge dev with no +// build artifact AND forge run post-build), the Runner must walk +// SKILL.md frontmatter from the agent's workdir and append discovered +// skills to the published Agent Card. + +func writeSkillFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestEnrichAgentCardWithSkills_AddsParsedSKILLmd(t *testing.T) { + workDir := t.TempDir() + + writeSkillFile(t, filepath.Join(workDir, "skills", "weather.md"), `--- +name: weather +description: Look up the current weather +category: info-retrieval +tags: [web] +--- +# Weather + +## Tool: get_weather +Fetches the weather. +`) + + r := &Runner{cfg: RunnerConfig{WorkDir: workDir, Config: &types.ForgeConfig{AgentID: "test"}}} + card := &a2a.AgentCard{Name: "test", URL: "http://x", Version: "0.1.0", ProtocolVersion: "0.3.0"} + + r.enrichAgentCardWithSkills(card) + + if len(card.Skills) != 1 { + t.Fatalf("expected 1 skill on card, got %d (%+v)", len(card.Skills), card.Skills) + } + s := card.Skills[0] + if s.ID != "weather" || s.Name != "weather" { + t.Errorf("ID/Name = %q/%q, want weather/weather", s.ID, s.Name) + } + if s.Description != "Look up the current weather" { + t.Errorf("Description = %q, want frontmatter value", s.Description) + } + if len(s.Tags) == 0 || s.Tags[0] != "info-retrieval" { + t.Errorf("Tags = %v, want category-first then frontmatter tags", s.Tags) + } +} + +func TestEnrichAgentCardWithSkills_PreservesExistingSkills(t *testing.T) { + workDir := t.TempDir() + writeSkillFile(t, filepath.Join(workDir, "skills", "extra.md"), `--- +name: extra +description: A runtime-discovered skill +--- +body +`) + + r := &Runner{cfg: RunnerConfig{WorkDir: workDir, Config: &types.ForgeConfig{AgentID: "test"}}} + // Pre-existing skill on card (from AgentSpec.A2A.Skills typically). + // The enrichment must NOT clobber it. + card := &a2a.AgentCard{ + Name: "test", URL: "http://x", Version: "0.1.0", ProtocolVersion: "0.3.0", + Skills: []a2a.Skill{ + {ID: "extra", Name: "Custom Display Name", Description: "from spec", Tags: []string{"spec"}}, + {ID: "tool", Name: "tool", Tags: []string{"tool"}}, + }, + } + + r.enrichAgentCardWithSkills(card) + + // Should still have exactly the two original entries (extra was + // already present, dedup keeps the original). + if len(card.Skills) != 2 { + t.Fatalf("expected 2 skills (no new appends due to dedup), got %d", len(card.Skills)) + } + for _, s := range card.Skills { + if s.ID == "extra" && s.Description != "from spec" { + t.Errorf("existing skill clobbered: %+v", s) + } + } +} + +func TestEnrichAgentCardWithSkills_NoOpWhenNoSKILLmd(t *testing.T) { + workDir := t.TempDir() // empty + r := &Runner{cfg: RunnerConfig{WorkDir: workDir, Config: &types.ForgeConfig{AgentID: "test"}}} + card := &a2a.AgentCard{Name: "test", URL: "http://x", Version: "0.1.0", ProtocolVersion: "0.3.0"} + + r.enrichAgentCardWithSkills(card) + + if len(card.Skills) != 0 { + t.Errorf("expected empty skill list, got %+v", card.Skills) + } +} + +func TestEnrichAgentCardWithSkills_SkipsSKILLmdWithoutName(t *testing.T) { + workDir := t.TempDir() + writeSkillFile(t, filepath.Join(workDir, "skills", "noname.md"), `--- +description: No name field, no A2A identity +--- +body +`) + + r := &Runner{cfg: RunnerConfig{WorkDir: workDir, Config: &types.ForgeConfig{AgentID: "test"}}} + card := &a2a.AgentCard{Name: "test", URL: "http://x", Version: "0.1.0", ProtocolVersion: "0.3.0"} + + r.enrichAgentCardWithSkills(card) + if len(card.Skills) != 0 { + t.Errorf("nameless SKILL.md should not produce a card skill, got %+v", card.Skills) + } +} diff --git a/forge-cli/server/a2a_server.go b/forge-cli/server/a2a_server.go index 132dac4..81a9bc6 100644 --- a/forge-cli/server/a2a_server.go +++ b/forge-cli/server/a2a_server.go @@ -140,8 +140,13 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc(route.pattern, route.handler) } - // Register core A2A handlers - mux.HandleFunc("GET /.well-known/agent.json", s.handleAgentCard) + // Register core A2A handlers. The canonical Agent Card path is + // /.well-known/agent-card.json per A2A 0.3.0; the legacy + // /.well-known/agent.json path is also served (with a Deprecation + // response header) so existing clients keep working through one + // release cycle. + mux.HandleFunc("GET /.well-known/agent-card.json", s.handleAgentCard) + mux.HandleFunc("GET /.well-known/agent.json", s.handleAgentCardLegacy) mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("POST /", s.handleJSONRPC) mux.HandleFunc("GET /", s.handleAgentCard) @@ -214,6 +219,17 @@ func (s *Server) handleAgentCard(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(s.agentCard()) //nolint:errcheck } +// handleAgentCardLegacy serves the same Agent Card payload on the +// legacy /.well-known/agent.json path with a Deprecation response +// header per RFC 8594, pointing clients at the A2A 0.3.0 canonical +// path. Removable after one release cycle. +func (s *Server) handleAgentCardLegacy(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Deprecation", "true") + w.Header().Set("Link", `; rel="successor-version"`) + json.NewEncoder(w).Encode(s.agentCard()) //nolint:errcheck +} + func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) //nolint:errcheck diff --git a/forge-cli/server/a2a_server_agentcard_test.go b/forge-cli/server/a2a_server_agentcard_test.go new file mode 100644 index 0000000..b7a31bc --- /dev/null +++ b/forge-cli/server/a2a_server_agentcard_test.go @@ -0,0 +1,186 @@ +package server + +import ( + "context" + "encoding/json" + "net" + "net/http" + "testing" + "time" + + "github.com/initializ/forge/forge-core/a2a" +) + +// Regression tests for issue #85 / FWS-1 — HTTP behavior of the +// Agent Card endpoint. The canonical path is the A2A 0.3.0 +// /.well-known/agent-card.json; the legacy /.well-known/agent.json +// remains served with a Deprecation header for one release cycle. + +// startTestServer boots a Server on a random port and returns the base +// URL plus a cancel function. The test card is minimal but spec-shaped. +func startTestServer(t *testing.T) (string, context.CancelFunc) { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + port := lis.Addr().(*net.TCPAddr).Port + _ = lis.Close() + + card := &a2a.AgentCard{ + Name: "test-agent", + Description: "Test agent for issue #85", + URL: "http://127.0.0.1", + Version: "0.1.0", + ProtocolVersion: "0.3.0", + DefaultInputModes: []string{"text/plain"}, + DefaultOutputModes: []string{"text/plain"}, + Skills: []a2a.Skill{ + {ID: "echo", Name: "Echo", Description: "Echoes the input", Tags: []string{"test"}}, + }, + } + srv := NewServer(ServerConfig{ + Port: port, + Host: "127.0.0.1", + AgentCard: card, + ShutdownTimeout: 1 * time.Second, + }) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { _ = srv.Start(ctx) }() + + // Wait briefly for the listener to come up. We dial at the port we + // picked above — Server.Start performs its own port-conflict + // retry, but reading s.port concurrently with Start is racy so we + // trust the freshly-released port to be reused. + addr := "127.0.0.1:" + itoa(port) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if c, err := net.DialTimeout("tcp", addr, 50*time.Millisecond); err == nil { + _ = c.Close() + return "http://" + addr, cancel + } + time.Sleep(20 * time.Millisecond) + } + cancel() + t.Fatalf("server failed to come up") + return "", cancel +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [10]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +func TestAgentCard_CanonicalPathReturnsCard(t *testing.T) { + base, cancel := startTestServer(t) + defer cancel() + + resp, err := http.Get(base + "/.well-known/agent-card.json") + if err != nil { + t.Fatalf("GET: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + // Canonical path must NOT carry the Deprecation header. + if dep := resp.Header.Get("Deprecation"); dep != "" { + t.Errorf("canonical path should not be deprecated, got Deprecation=%q", dep) + } + + var card a2a.AgentCard + if err := json.NewDecoder(resp.Body).Decode(&card); err != nil { + t.Fatalf("decode: %v", err) + } + if card.Name != "test-agent" { + t.Errorf("Name = %q, want test-agent", card.Name) + } + if card.ProtocolVersion != "0.3.0" { + t.Errorf("ProtocolVersion = %q, want 0.3.0", card.ProtocolVersion) + } +} + +func TestAgentCard_LegacyPathReturnsCardWithDeprecationHeader(t *testing.T) { + base, cancel := startTestServer(t) + defer cancel() + + resp, err := http.Get(base + "/.well-known/agent.json") + if err != nil { + t.Fatalf("GET legacy: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if dep := resp.Header.Get("Deprecation"); dep != "true" { + t.Errorf("Deprecation header = %q, want %q", dep, "true") + } + if link := resp.Header.Get("Link"); link == "" { + t.Errorf("Link header should point at successor path; got empty") + } else if !contains(link, "/.well-known/agent-card.json") { + t.Errorf("Link header should reference canonical path, got %q", link) + } +} + +func TestAgentCard_BothPathsServeIdenticalBody(t *testing.T) { + base, cancel := startTestServer(t) + defer cancel() + + canonical := mustGetBody(t, base+"/.well-known/agent-card.json") + legacy := mustGetBody(t, base+"/.well-known/agent.json") + + if string(canonical) != string(legacy) { + t.Errorf("canonical and legacy paths should serve identical bytes:\ncanonical=%s\nlegacy=%s", + canonical, legacy) + } +} + +func mustGetBody(t *testing.T, url string) []byte { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer func() { _ = resp.Body.Close() }() + body := make([]byte, 0, 1024) + buf := make([]byte, 512) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if err != nil { + break + } + } + return body +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/forge-core/a2a/types.go b/forge-core/a2a/types.go index 179b81c..c3ae9fc 100644 --- a/forge-core/a2a/types.go +++ b/forge-core/a2a/types.go @@ -84,12 +84,85 @@ type Artifact struct { } // AgentCard describes an agent's capabilities for discovery. +// +// The serialized JSON shape conforms to the Agent2Agent (A2A) Protocol +// 0.3.0 Agent Card specification: +// +// https://github.com/google/a2a-spec +// +// Forge serves the card at /.well-known/agent-card.json (the spec's +// canonical path). The legacy /.well-known/agent.json path is also +// served for backward compatibility and emits a Deprecation response +// header — that alias will be removed in a future release. +// +// Forge-internal fields (egress, denied_tools, trust hints) live in +// agentspec.AgentSpec and are intentionally NOT serialized into the +// Agent Card. The card carries only what the A2A spec defines. type AgentCard struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - URL string `json:"url"` - Skills []Skill `json:"skills,omitempty"` + // Name is the human-readable agent name. Required. + Name string `json:"name"` + + // Description is the agent's one-line summary. Optional in the + // spec, but Forge always populates it. + Description string `json:"description,omitempty"` + + // URL is the agent's primary service endpoint (the base URL where + // the JSON-RPC and REST handlers live). Required. + URL string `json:"url"` + + // Version is the agent's semantic version string (e.g. "0.1.0"). + // Required by A2A 0.3.0. Forge sources this from forge.yaml's + // version field (or the build-time agent.json's version). + Version string `json:"version"` + + // ProtocolVersion pins the A2A protocol version this card claims + // to conform to. Forge always emits "0.3.0". + ProtocolVersion string `json:"protocolVersion"` + + // DefaultInputModes lists the MIME types the agent accepts on + // message parts when a skill doesn't override them. A2A 0.3.0 + // requires at least one entry. Forge defaults to text/plain + + // application/json. + DefaultInputModes []string `json:"defaultInputModes"` + + // DefaultOutputModes lists the MIME types the agent emits on + // message parts when a skill doesn't override them. Required. + DefaultOutputModes []string `json:"defaultOutputModes"` + + // Skills lists the agent's discoverable capabilities. Each entry + // maps to an A2A AgentSkill object. + Skills []Skill `json:"skills,omitempty"` + + // Capabilities declares optional A2A features the agent supports. Capabilities *AgentCapabilities `json:"capabilities,omitempty"` + + // SecuritySchemes maps a scheme name to its definition. Mirrors the + // OpenAPI 3.1 securitySchemes shape per A2A 0.3.0. Forge derives + // these from the configured auth chain (static_token → httpBearer, + // oidc → openIdConnect, etc.). + SecuritySchemes map[string]*SecurityScheme `json:"securitySchemes,omitempty"` + + // Security is the list of accepted security requirements. Each + // entry is a map of scheme name → required scopes (empty array for + // schemes that don't use scopes). Per OpenAPI semantics, the + // outer list is OR (any one entry suffices), the inner map is AND. + Security []map[string][]string `json:"security,omitempty"` + + // Provider identifies the organization publishing the agent. + // Optional. + Provider *AgentProvider `json:"provider,omitempty"` + + // DocumentationURL is a link to the agent's external docs. Optional. + DocumentationURL string `json:"documentationUrl,omitempty"` + + // IconURL is a link to an icon for UI display. Optional. + IconURL string `json:"iconUrl,omitempty"` +} + +// AgentProvider identifies the organization publishing the agent. +type AgentProvider struct { + Organization string `json:"organization,omitempty"` + URL string `json:"url,omitempty"` } // AgentCapabilities declares optional A2A features an agent supports. @@ -99,12 +172,88 @@ type AgentCapabilities struct { StateTransitionHistory bool `json:"stateTransitionHistory,omitempty"` } -// Skill describes a discrete capability an agent exposes. +// Skill describes a discrete capability an agent exposes — the A2A +// AgentSkill object. type Skill struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Tags []string `json:"tags,omitempty"` + // ID is a slug-format identifier unique within the agent. + ID string `json:"id"` + + // Name is the human-readable skill name. + Name string `json:"name"` + + // Description is the skill's one-line summary. + Description string `json:"description,omitempty"` + + // Tags is a free-form classification list (category, capability + // labels, etc.). A2A 0.3.0 makes this required; Forge always + // populates with at least one entry (derived from SKILL.md + // frontmatter's `category` or `tags` list). + Tags []string `json:"tags"` + + // Examples is an optional list of example prompts that exercise + // this skill. Used by A2A clients to surface the skill in a UI. + Examples []string `json:"examples,omitempty"` + + // InputModes overrides AgentCard.DefaultInputModes for this skill + // only. Optional. + InputModes []string `json:"inputModes,omitempty"` + + // OutputModes overrides AgentCard.DefaultOutputModes for this skill + // only. Optional. + OutputModes []string `json:"outputModes,omitempty"` +} + +// SecurityScheme describes one authentication mechanism advertised in +// the Agent Card. The shape mirrors the OpenAPI 3.1 Security Scheme +// object per A2A 0.3.0 §6.5. Only one of the type-specific groupings +// (Bearer, ApiKey, OpenIDConnect, OAuth2) is populated per instance. +type SecurityScheme struct { + // Type is one of: "http", "apiKey", "openIdConnect", "oauth2", + // "mutualTLS". + Type string `json:"type"` + + // Description is an optional human-readable explanation. + Description string `json:"description,omitempty"` + + // http: Scheme is the HTTP auth scheme — typically "bearer" or + // "basic". For "http" Type only. + Scheme string `json:"scheme,omitempty"` + + // http (bearer): BearerFormat is a hint about the token format + // (e.g. "JWT"). + BearerFormat string `json:"bearerFormat,omitempty"` + + // apiKey: In identifies where the API key is sent — "header", + // "query", or "cookie". + In string `json:"in,omitempty"` + + // apiKey: Name is the name of the header/query/cookie that carries + // the key. For "apiKey" Type only. + Name string `json:"name,omitempty"` + + // openIdConnect: OpenIDConnectURL is the OIDC discovery document + // URL (issuer + /.well-known/openid-configuration). + OpenIDConnectURL string `json:"openIdConnectUrl,omitempty"` + + // oauth2: Flows describes the supported OAuth 2.0 flows. + Flows *OAuthFlows `json:"flows,omitempty"` +} + +// OAuthFlows describes the OAuth 2.0 flows supported by an auth scheme. +// Each field describes one flow; at least one must be populated. +type OAuthFlows struct { + Implicit *OAuthFlow `json:"implicit,omitempty"` + Password *OAuthFlow `json:"password,omitempty"` + ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty"` + AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty"` +} + +// OAuthFlow describes one OAuth 2.0 flow. +type OAuthFlow struct { + AuthorizationURL string `json:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty"` + RefreshURL string `json:"refreshUrl,omitempty"` + Scopes map[string]string `json:"scopes,omitempty"` } // NewTextPart creates a Part containing text content. diff --git a/forge-core/auth/middleware.go b/forge-core/auth/middleware.go index 13af495..3c3e9cd 100644 --- a/forge-core/auth/middleware.go +++ b/forge-core/auth/middleware.go @@ -15,16 +15,23 @@ type errorResponse struct { // DefaultSkipPaths returns the default set of public endpoints // that do not require authentication (agent card, health checks). +// +// Both Agent Card paths are public: +// - /.well-known/agent-card.json — A2A 0.3.0 canonical path +// - /.well-known/agent.json — legacy alias (deprecated header +// emitted by the handler); removable after one release cycle func DefaultSkipPaths() map[string]bool { return map[string]bool{ - "GET /": true, - "GET /.well-known/agent.json": true, - "GET /healthz": true, - "GET /health": true, - "OPTIONS /": true, - "OPTIONS /.well-known/agent.json": true, - "OPTIONS /healthz": true, - "OPTIONS /health": true, + "GET /": true, + "GET /.well-known/agent-card.json": true, + "GET /.well-known/agent.json": true, + "GET /healthz": true, + "GET /health": true, + "OPTIONS /": true, + "OPTIONS /.well-known/agent-card.json": true, + "OPTIONS /.well-known/agent.json": true, + "OPTIONS /healthz": true, + "OPTIONS /health": true, } } diff --git a/forge-core/runtime/agentcard.go b/forge-core/runtime/agentcard.go index dd31423..4e19007 100644 --- a/forge-core/runtime/agentcard.go +++ b/forge-core/runtime/agentcard.go @@ -6,32 +6,67 @@ import ( "github.com/initializ/forge/forge-core/types" ) +// ProtocolVersion is the A2A protocol version every AgentCard claims to +// conform to. Pinned at build time. Bumping is a deliberate PR, not a +// runtime negotiation — same discipline Forge uses for the MCP protocol +// version pin. +const ProtocolVersion = "0.3.0" + +// defaultInputModes / defaultOutputModes are the MIME types Forge agents +// accept and emit when a skill doesn't override them. text/plain covers +// human-language messages; application/json covers structured tool args +// and tool results. +var ( + defaultInputModes = []string{"text/plain", "application/json"} + defaultOutputModes = []string{"text/plain", "application/json"} +) + // AgentCardFromSpec constructs an AgentCard from an AgentSpec and a base URL. // The baseURL should be a fully-formed URL (e.g. "http://localhost:8080"). +// +// Per A2A 0.3.0 the card requires `version`, `protocolVersion`, +// `defaultInputModes`, and `defaultOutputModes`. The function fills +// those from the spec / config defaults; callers can override after +// construction (e.g. the runner enriches with SecuritySchemes derived +// from the auth chain). func AgentCardFromSpec(spec *agentspec.AgentSpec, baseURL string) *a2a.AgentCard { card := &a2a.AgentCard{ - Name: spec.Name, - Description: spec.Description, - URL: baseURL, + Name: firstNonEmpty(spec.Name, spec.AgentID), + Description: spec.Description, + URL: baseURL, + Version: firstNonEmpty(spec.Version, "0.0.0"), + ProtocolVersion: ProtocolVersion, + DefaultInputModes: defaultInputModes, + DefaultOutputModes: defaultOutputModes, } - // Convert tools to skills + // Convert tools to skills. Tools surfaced as A2A skills carry the + // "tool" tag so downstream clients can distinguish tool-shaped + // skills from SKILL.md skills. for _, t := range spec.Tools { card.Skills = append(card.Skills, a2a.Skill{ ID: t.Name, Name: t.Name, Description: t.Description, + Tags: []string{"tool"}, }) } - // Copy A2A capabilities if present + // Copy A2A capabilities + skills from the spec's A2A block. if spec.A2A != nil { for _, s := range spec.A2A.Skills { + tags := s.Tags + if len(tags) == 0 { + // A2A 0.3.0 makes tags REQUIRED. If the spec doesn't + // supply any, fall back to "skill" so the field is + // always non-empty. + tags = []string{"skill"} + } card.Skills = append(card.Skills, a2a.Skill{ ID: s.ID, Name: s.Name, Description: s.Description, - Tags: s.Tags, + Tags: tags, }) } if spec.A2A.Capabilities != nil { @@ -48,18 +83,38 @@ func AgentCardFromSpec(spec *agentspec.AgentSpec, baseURL string) *a2a.AgentCard // AgentCardFromConfig constructs an AgentCard from a ForgeConfig and a base URL. // The baseURL should be a fully-formed URL (e.g. "http://localhost:8080"). +// +// Used when no build-time AgentSpec is available (e.g. local `forge dev` +// from a freshly-scaffolded project). Identical conformance to A2A 0.3.0 +// as the spec-derived path. func AgentCardFromConfig(cfg *types.ForgeConfig, baseURL string) *a2a.AgentCard { card := &a2a.AgentCard{ - Name: cfg.AgentID, - URL: baseURL, + Name: cfg.AgentID, + URL: baseURL, + Version: firstNonEmpty(cfg.Version, "0.0.0"), + ProtocolVersion: ProtocolVersion, + DefaultInputModes: defaultInputModes, + DefaultOutputModes: defaultOutputModes, } for _, t := range cfg.Tools { card.Skills = append(card.Skills, a2a.Skill{ ID: t.Name, Name: t.Name, + Tags: []string{"tool"}, }) } return card } + +// firstNonEmpty returns the first non-empty string from its arguments, +// or "" if all are empty. Used for required-field defaulting. +func firstNonEmpty(s ...string) string { + for _, v := range s { + if v != "" { + return v + } + } + return "" +} diff --git a/forge-core/runtime/agentcard_security.go b/forge-core/runtime/agentcard_security.go new file mode 100644 index 0000000..ed27822 --- /dev/null +++ b/forge-core/runtime/agentcard_security.go @@ -0,0 +1,132 @@ +package runtime + +import ( + "fmt" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/types" +) + +// PopulateSecuritySchemes derives the A2A SecuritySchemes + Security +// requirements from the agent's configured auth chain (forge.yaml +// auth: block) and writes them into the card. The function is +// additive: it preserves any schemes the caller has already set. +// +// The mapping mirrors what Forge's auth middleware actually accepts: +// +// static_token → http + bearer (opaque token) +// http_verifier → http + bearer (token validated by external endpoint) +// oidc → openIdConnect with issuer discovery URL +// azure_ad → openIdConnect (AAD exposes a standard OIDC discovery) +// gcp_iap → apiKey in header (X-Goog-Iap-Jwt-Assertion) +// aws_sigv4 → http + bearer with bearerFormat "forge-aws-v1" +// (forge-specific Sigv4-reflection-via-bearer pattern) +// +// Every chain entry produces one scheme name = entry's Name field (or +// Type when Name is empty). The Security array carries one map per +// scheme; the outer list is OR (any one suffices), matching Forge's +// first-match-wins chain semantics. +// +// When no auth is configured, no schemes are emitted — A2A 0.3.0 +// treats absence as "no auth required." +func PopulateSecuritySchemes(card *a2a.AgentCard, cfg *types.ForgeConfig) { + if cfg == nil || len(cfg.Auth.Providers) == 0 { + return + } + if card.SecuritySchemes == nil { + card.SecuritySchemes = map[string]*a2a.SecurityScheme{} + } + + for _, p := range cfg.Auth.Providers { + name := p.Name + if name == "" { + name = p.Type + } + if _, exists := card.SecuritySchemes[name]; exists { + // Caller already configured this scheme — don't clobber. + continue + } + scheme := schemeFromProvider(p) + if scheme == nil { + continue + } + card.SecuritySchemes[name] = scheme + + // Each provider becomes its own OR-entry in the Security array + // — Forge's chain is first-match-wins, so presenting any one of + // the configured credentials satisfies the requirement. + card.Security = append(card.Security, map[string][]string{ + name: {}, + }) + } +} + +// schemeFromProvider maps one AuthProvider entry to its A2A +// SecurityScheme. Returns nil for provider types we don't have a +// well-defined mapping for; callers should treat those as +// Forge-internal (the auth chain still enforces them, the card just +// doesn't advertise the credential shape). +func schemeFromProvider(p types.AuthProvider) *a2a.SecurityScheme { + switch p.Type { + case "static_token": + return &a2a.SecurityScheme{ + Type: "http", + Scheme: "bearer", + Description: "Static shared-secret token in the Authorization header.", + } + case "http_verifier": + return &a2a.SecurityScheme{ + Type: "http", + Scheme: "bearer", + Description: "Opaque bearer token verified by an external HTTP endpoint.", + } + case "oidc": + issuer, _ := p.Settings["issuer"].(string) + s := &a2a.SecurityScheme{ + Type: "openIdConnect", + Description: "JWT issued by an OIDC provider.", + } + if issuer != "" { + s.OpenIDConnectURL = trimTrailingSlash(issuer) + "/.well-known/openid-configuration" + } + return s + case "azure_ad": + tenant, _ := p.Settings["tenant_id"].(string) + s := &a2a.SecurityScheme{ + Type: "openIdConnect", + Description: "Microsoft Entra ID (Azure AD) token.", + } + if tenant != "" { + s.OpenIDConnectURL = fmt.Sprintf( + "https://login.microsoftonline.com/%s/v2.0/.well-known/openid-configuration", + tenant, + ) + } + return s + case "gcp_iap": + return &a2a.SecurityScheme{ + Type: "apiKey", + In: "header", + Name: "X-Goog-Iap-Jwt-Assertion", + Description: "GCP Identity-Aware Proxy assertion forwarded by the load balancer.", + } + case "aws_sigv4": + return &a2a.SecurityScheme{ + Type: "http", + Scheme: "bearer", + BearerFormat: "forge-aws-v1", + Description: "AWS Sigv4 pre-signed STS GetCallerIdentity URL wrapped as a Bearer token.", + } + } + return nil +} + +// trimTrailingSlash strips a single trailing "/" from s if present. +// Used so the discovery URL is well-formed regardless of how operators +// wrote the issuer in forge.yaml. +func trimTrailingSlash(s string) string { + if len(s) > 0 && s[len(s)-1] == '/' { + return s[:len(s)-1] + } + return s +} diff --git a/forge-core/runtime/agentcard_security_test.go b/forge-core/runtime/agentcard_security_test.go new file mode 100644 index 0000000..6f2f9cd --- /dev/null +++ b/forge-core/runtime/agentcard_security_test.go @@ -0,0 +1,170 @@ +package runtime + +import ( + "strings" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/types" +) + +// Regression tests for SecuritySchemes derivation from the configured +// auth chain (issue #85 / FWS-1 deliverable: "authentication schemes +// match A2A standard"). + +func TestPopulateSecuritySchemes_NoOpWhenAuthChainEmpty(t *testing.T) { + card := &a2a.AgentCard{Name: "agent", URL: "http://x", Version: "0.1.0", ProtocolVersion: "0.3.0"} + PopulateSecuritySchemes(card, &types.ForgeConfig{}) + if len(card.SecuritySchemes) != 0 { + t.Errorf("no auth chain should emit no schemes, got %v", card.SecuritySchemes) + } + if len(card.Security) != 0 { + t.Errorf("no auth chain should emit no Security entries, got %v", card.Security) + } +} + +func TestPopulateSecuritySchemes_NoOpWhenCfgNil(t *testing.T) { + card := &a2a.AgentCard{} + PopulateSecuritySchemes(card, nil) // must not panic + if len(card.SecuritySchemes) != 0 { + t.Errorf("nil cfg should emit no schemes") + } +} + +func TestPopulateSecuritySchemes_StaticTokenMapsToHTTPBearer(t *testing.T) { + card := &a2a.AgentCard{} + cfg := &types.ForgeConfig{ + Auth: types.AuthConfig{Providers: []types.AuthProvider{ + {Type: "static_token"}, + }}, + } + PopulateSecuritySchemes(card, cfg) + + s, ok := card.SecuritySchemes["static_token"] + if !ok { + t.Fatalf("expected scheme keyed by provider type") + } + if s.Type != "http" || s.Scheme != "bearer" { + t.Errorf("static_token = %+v, want http + bearer", s) + } +} + +func TestPopulateSecuritySchemes_OIDCEmitsDiscoveryURL(t *testing.T) { + card := &a2a.AgentCard{} + cfg := &types.ForgeConfig{ + Auth: types.AuthConfig{Providers: []types.AuthProvider{ + {Type: "oidc", Name: "okta", Settings: map[string]any{ + "issuer": "https://acme.okta.com/", + }}, + }}, + } + PopulateSecuritySchemes(card, cfg) + + s := card.SecuritySchemes["okta"] + if s == nil { + t.Fatalf("expected scheme keyed by Name=okta") + } + if s.Type != "openIdConnect" { + t.Errorf("Type = %q, want openIdConnect", s.Type) + } + if want := "https://acme.okta.com/.well-known/openid-configuration"; s.OpenIDConnectURL != want { + t.Errorf("OpenIDConnectURL = %q, want %q (trailing slash trimmed)", s.OpenIDConnectURL, want) + } +} + +func TestPopulateSecuritySchemes_AzureADUsesTenantInDiscovery(t *testing.T) { + card := &a2a.AgentCard{} + cfg := &types.ForgeConfig{ + Auth: types.AuthConfig{Providers: []types.AuthProvider{ + {Type: "azure_ad", Settings: map[string]any{ + "tenant_id": "11111111-2222-3333-4444-555555555555", + }}, + }}, + } + PopulateSecuritySchemes(card, cfg) + s := card.SecuritySchemes["azure_ad"] + if s == nil || s.OpenIDConnectURL == "" { + t.Fatalf("expected azure_ad openIdConnect scheme with discovery URL, got %+v", s) + } + if !strings.Contains(s.OpenIDConnectURL, "11111111-2222-3333-4444-555555555555") { + t.Errorf("OpenIDConnectURL %q should embed tenant id", s.OpenIDConnectURL) + } +} + +func TestPopulateSecuritySchemes_GCPIAPMapsToAPIKeyHeader(t *testing.T) { + card := &a2a.AgentCard{} + cfg := &types.ForgeConfig{ + Auth: types.AuthConfig{Providers: []types.AuthProvider{ + {Type: "gcp_iap"}, + }}, + } + PopulateSecuritySchemes(card, cfg) + s := card.SecuritySchemes["gcp_iap"] + if s == nil { + t.Fatal("expected gcp_iap scheme") + } + if s.Type != "apiKey" || s.In != "header" || s.Name != "X-Goog-Iap-Jwt-Assertion" { + t.Errorf("gcp_iap mapping = %+v, want apiKey in header X-Goog-Iap-Jwt-Assertion", s) + } +} + +func TestPopulateSecuritySchemes_AWSSigv4MapsToBearerWithCustomFormat(t *testing.T) { + card := &a2a.AgentCard{} + cfg := &types.ForgeConfig{ + Auth: types.AuthConfig{Providers: []types.AuthProvider{ + {Type: "aws_sigv4"}, + }}, + } + PopulateSecuritySchemes(card, cfg) + s := card.SecuritySchemes["aws_sigv4"] + if s == nil { + t.Fatal("expected aws_sigv4 scheme") + } + if s.Type != "http" || s.Scheme != "bearer" || s.BearerFormat != "forge-aws-v1" { + t.Errorf("aws_sigv4 mapping = %+v, want http+bearer+forge-aws-v1", s) + } +} + +func TestPopulateSecuritySchemes_ChainEmitsSecurityArrayInOrder(t *testing.T) { + card := &a2a.AgentCard{} + cfg := &types.ForgeConfig{ + Auth: types.AuthConfig{Providers: []types.AuthProvider{ + {Type: "static_token"}, + {Type: "oidc", Name: "okta", Settings: map[string]any{"issuer": "https://i.example.com"}}, + }}, + } + PopulateSecuritySchemes(card, cfg) + + if len(card.Security) != 2 { + t.Fatalf("expected one Security entry per provider, got %d", len(card.Security)) + } + // First-match-wins → OR semantics on the outer list per A2A 0.3.0. + first := card.Security[0] + if _, ok := first["static_token"]; !ok { + t.Errorf("Security[0] should reference static_token, got %v", first) + } + second := card.Security[1] + if _, ok := second["okta"]; !ok { + t.Errorf("Security[1] should reference okta, got %v", second) + } +} + +func TestPopulateSecuritySchemes_PreservesPreviouslyConfiguredScheme(t *testing.T) { + // Callers may have hand-wired a scheme before invoking the deriver. + // PopulateSecuritySchemes must be additive and not clobber. + card := &a2a.AgentCard{ + SecuritySchemes: map[string]*a2a.SecurityScheme{ + "static_token": {Type: "http", Scheme: "bearer", Description: "operator-supplied description"}, + }, + } + cfg := &types.ForgeConfig{ + Auth: types.AuthConfig{Providers: []types.AuthProvider{ + {Type: "static_token"}, + }}, + } + PopulateSecuritySchemes(card, cfg) + + if d := card.SecuritySchemes["static_token"].Description; d != "operator-supplied description" { + t.Errorf("scheme clobbered: Description = %q", d) + } +} diff --git a/forge-core/runtime/agentcard_skills.go b/forge-core/runtime/agentcard_skills.go new file mode 100644 index 0000000..4b267ee --- /dev/null +++ b/forge-core/runtime/agentcard_skills.go @@ -0,0 +1,103 @@ +package runtime + +import ( + "sort" + "strings" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-skills/contract" +) + +// AppendSkillsFromDescriptors maps the runtime's SkillDescriptor list +// (sourced from the embedded + local skill registries) into A2A +// AgentSkill objects and appends them to the card. Skill IDs already +// present on the card are skipped so this is safe to call after +// AgentCardFromSpec / AgentCardFromConfig — those populate the card +// from build-time artifacts; this fills in any runtime-registered +// skills the build artifact didn't cover. +// +// Mapping (Forge SKILL.md → A2A AgentSkill): +// +// SkillDescriptor.Name → Skill.ID + Skill.Name +// SkillDescriptor.DisplayName → Skill.Name (when present) +// SkillDescriptor.Description → Skill.Description +// SkillDescriptor.Category → Skill.Tags[0] (when present) +// SkillDescriptor.Tags → Skill.Tags (appended) +// +// A2A 0.3.0 requires Tags to be non-empty; when neither category nor +// tags are set, we fall back to ["skill"] so the field is always +// populated. +// +// Forge-internal fields (RequiredEnv, RequiredBins, EgressDomains, +// DeniedTools, TimeoutHint, Provenance) are intentionally NOT mapped +// into the card. The Agent Card is a public discovery surface; those +// fields are runtime contracts that stay inside Forge. +func AppendSkillsFromDescriptors(card *a2a.AgentCard, descs []contract.SkillDescriptor) { + if card == nil || len(descs) == 0 { + return + } + seen := map[string]struct{}{} + for _, s := range card.Skills { + seen[s.ID] = struct{}{} + } + + for _, d := range descs { + if d.Name == "" { + continue + } + if _, dup := seen[d.Name]; dup { + continue + } + card.Skills = append(card.Skills, skillFromDescriptor(d)) + seen[d.Name] = struct{}{} + } + + // Deterministic order: skills sort by ID. This keeps the + // /.well-known/agent-card.json bytes stable across restarts so + // downstream consumers can hash the card for change detection + // (and so the agent_card_published audit event's hash is + // deterministic per agent configuration). + sort.SliceStable(card.Skills, func(i, j int) bool { + return card.Skills[i].ID < card.Skills[j].ID + }) +} + +// skillFromDescriptor builds one A2A Skill from a Forge SkillDescriptor. +// Pure function — no I/O, easy to test. +func skillFromDescriptor(d contract.SkillDescriptor) a2a.Skill { + displayName := d.DisplayName + if displayName == "" { + displayName = d.Name + } + + // Tags: category first (so clients can group), then any extra tags + // from frontmatter. Dedup case-insensitively. + var tags []string + added := map[string]struct{}{} + add := func(t string) { + t = strings.TrimSpace(t) + if t == "" { + return + } + k := strings.ToLower(t) + if _, ok := added[k]; ok { + return + } + added[k] = struct{}{} + tags = append(tags, t) + } + add(d.Category) + for _, t := range d.Tags { + add(t) + } + if len(tags) == 0 { + tags = []string{"skill"} + } + + return a2a.Skill{ + ID: d.Name, + Name: displayName, + Description: d.Description, + Tags: tags, + } +} diff --git a/forge-core/runtime/agentcard_test.go b/forge-core/runtime/agentcard_test.go new file mode 100644 index 0000000..422260b --- /dev/null +++ b/forge-core/runtime/agentcard_test.go @@ -0,0 +1,233 @@ +package runtime + +import ( + "encoding/json" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/types" + "github.com/initializ/forge/forge-skills/contract" +) + +// Regression tests for issue #85 / FWS-1 — A2A 0.3.0 Agent Card +// conformance. These pin the JSON shape and the SKILL.md → AgentSkill +// mapping that downstream A2A clients (initializ platform, peer +// agents) will consume. + +func TestAgentCardFromConfig_RequiredFieldsPopulated(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.4.2", + } + card := AgentCardFromConfig(cfg, "http://localhost:8080") + + // A2A 0.3.0 requires these fields on every card. + if card.Name != "test-agent" { + t.Errorf("Name = %q, want %q", card.Name, "test-agent") + } + if card.URL != "http://localhost:8080" { + t.Errorf("URL = %q, want base URL", card.URL) + } + if card.Version != "0.4.2" { + t.Errorf("Version = %q, want forge.yaml version", card.Version) + } + if card.ProtocolVersion != "0.3.0" { + t.Errorf("ProtocolVersion = %q, want pinned 0.3.0", card.ProtocolVersion) + } + if len(card.DefaultInputModes) == 0 { + t.Errorf("DefaultInputModes must be non-empty per A2A 0.3.0") + } + if len(card.DefaultOutputModes) == 0 { + t.Errorf("DefaultOutputModes must be non-empty per A2A 0.3.0") + } +} + +func TestAgentCardFromConfig_ZeroValuesDefault(t *testing.T) { + // Pre-#85 forge.yaml files might not carry version. The card + // builder must still produce a spec-conformant non-empty version. + cfg := &types.ForgeConfig{AgentID: "noversion-agent"} + card := AgentCardFromConfig(cfg, "http://localhost:8080") + + if card.Version == "" { + t.Errorf("Version must default to non-empty for spec conformance") + } +} + +func TestAgentCardFromSpec_PreservesSpecVersion(t *testing.T) { + spec := &agentspec.AgentSpec{ + AgentID: "spec-agent", + Name: "Spec Agent", + Description: "Built from a spec", + Version: "1.2.3", + } + card := AgentCardFromSpec(spec, "http://example.com") + + if card.Version != "1.2.3" { + t.Errorf("Version = %q, want %q", card.Version, "1.2.3") + } + if card.Name != "Spec Agent" { + t.Errorf("Name = %q, want spec.Name", card.Name) + } + if card.ProtocolVersion != "0.3.0" { + t.Errorf("ProtocolVersion = %q, want pinned 0.3.0", card.ProtocolVersion) + } +} + +func TestAgentCardFromSpec_FallsBackToAgentIDWhenNameEmpty(t *testing.T) { + spec := &agentspec.AgentSpec{AgentID: "fallback-agent"} + card := AgentCardFromSpec(spec, "http://example.com") + if card.Name != "fallback-agent" { + t.Errorf("Name = %q, want %q (fallback to AgentID)", card.Name, "fallback-agent") + } +} + +func TestAgentCardFromSpec_SkillsCarryRequiredTags(t *testing.T) { + // A2A 0.3.0 requires Skill.Tags to be non-empty. The card builder + // must fill the field even when the spec doesn't supply tags. + spec := &agentspec.AgentSpec{ + AgentID: "agent", + A2A: &agentspec.A2AConfig{ + Skills: []agentspec.A2ASkill{ + {ID: "tagged", Name: "Tagged", Description: "has tags", Tags: []string{"a", "b"}}, + {ID: "untagged", Name: "Untagged", Description: "no tags"}, + }, + }, + } + card := AgentCardFromSpec(spec, "http://example.com") + + for _, s := range card.Skills { + if len(s.Tags) == 0 { + t.Errorf("skill %q has empty Tags; A2A 0.3.0 requires non-empty", s.ID) + } + } +} + +func TestSkillFromDescriptor_MapsSKILLmdFrontmatterFields(t *testing.T) { + // SKILL.md frontmatter -> A2A AgentSkill. Pinned per issue #85 + // "no information loss in either direction" for spec-defined fields. + d := contract.SkillDescriptor{ + Name: "weather", + DisplayName: "Weather Lookup", + Description: "Fetch the current weather for a city.", + Category: "info-retrieval", + Tags: []string{"web", "json"}, + } + s := skillFromDescriptor(d) + + if s.ID != "weather" { + t.Errorf("ID = %q, want %q", s.ID, "weather") + } + if s.Name != "Weather Lookup" { + t.Errorf("Name = %q, want DisplayName", s.Name) + } + if s.Description != d.Description { + t.Errorf("Description = %q, want %q", s.Description, d.Description) + } + // Tags: category first, then frontmatter tags, deduped. + if len(s.Tags) != 3 || s.Tags[0] != "info-retrieval" { + t.Errorf("Tags = %v, want category-first then frontmatter tags", s.Tags) + } +} + +func TestSkillFromDescriptor_DefaultsTagsWhenEmpty(t *testing.T) { + s := skillFromDescriptor(contract.SkillDescriptor{Name: "bare"}) + if len(s.Tags) == 0 { + t.Errorf("Tags must default to non-empty for A2A 0.3.0 conformance") + } +} + +func TestSkillFromDescriptor_NameFallsBackToID(t *testing.T) { + s := skillFromDescriptor(contract.SkillDescriptor{Name: "noDisplay"}) + if s.Name != "noDisplay" { + t.Errorf("Name = %q, want fallback to ID when DisplayName empty", s.Name) + } +} + +func TestAppendSkillsFromDescriptors_DeterministicOrdering(t *testing.T) { + // The /.well-known/agent-card.json bytes must be stable across + // restarts so consumers can hash for change detection. Skills + // always sort by ID after this helper runs. + card := &a2a.AgentCard{} + AppendSkillsFromDescriptors(card, []contract.SkillDescriptor{ + {Name: "zoo"}, + {Name: "alpha"}, + {Name: "mango"}, + }) + got := []string{card.Skills[0].ID, card.Skills[1].ID, card.Skills[2].ID} + want := []string{"alpha", "mango", "zoo"} + for i, w := range want { + if got[i] != w { + t.Errorf("Skills order %v, want %v", got, want) + break + } + } +} + +func TestAppendSkillsFromDescriptors_DedupesAgainstExistingSkills(t *testing.T) { + card := &a2a.AgentCard{ + Skills: []a2a.Skill{{ID: "existing", Name: "Existing", Tags: []string{"x"}}}, + } + AppendSkillsFromDescriptors(card, []contract.SkillDescriptor{ + {Name: "existing", Description: "duplicate, should be skipped"}, + {Name: "new", Description: "added"}, + }) + + if len(card.Skills) != 2 { + t.Fatalf("expected 2 skills (existing + new), got %d", len(card.Skills)) + } + // "existing" entry was preserved verbatim — not overwritten. + for _, s := range card.Skills { + if s.ID == "existing" && s.Description != "" { + t.Errorf("existing skill was clobbered: %+v", s) + } + } +} + +func TestAgentCardJSON_OmitsNilFields(t *testing.T) { + // A2A 0.3.0 prefers omission of optional fields over null. The + // type's json tags carry omitempty for the right ones. + cfg := &types.ForgeConfig{AgentID: "min", Version: "0.1.0"} + card := AgentCardFromConfig(cfg, "http://localhost:8080") + + raw, err := json.Marshal(card) + if err != nil { + t.Fatalf("marshal: %v", err) + } + js := string(raw) + + // Should NOT contain capabilities (nil pointer) or securitySchemes + // (nil map). Confirms omitempty is wired right. + for _, forbidden := range []string{`"capabilities"`, `"securitySchemes"`, `"provider"`, `"documentationUrl"`, `"iconUrl"`} { + if containsField(js, forbidden) { + t.Errorf("nil/empty optional field %s should be omitted, got:\n%s", forbidden, js) + } + } + // Should contain required ones. + for _, required := range []string{`"name"`, `"url"`, `"version"`, `"protocolVersion"`, `"defaultInputModes"`, `"defaultOutputModes"`} { + if !containsField(js, required) { + t.Errorf("required field %s missing from JSON:\n%s", required, js) + } + } +} + +// containsField checks whether the JSON contains a top-level key. +// Avoids false positives when the field name appears inside a value. +func containsField(js, field string) bool { + // Cheap: the marshaled card is single-line; the field always appears + // after `{` or `,` adjacent to no quote. Sufficient for these tests. + return jsonHas(js, field) +} + +func jsonHas(js, key string) bool { + for i := 0; i+len(key) <= len(js); i++ { + if js[i:i+len(key)] == key { + // Reject when preceded by `:` (means it's a value) + if i > 0 && (js[i-1] == ':') { + continue + } + return true + } + } + return false +} diff --git a/forge-core/runtime/audit.go b/forge-core/runtime/audit.go index 1786ba3..c7fc3c5 100644 --- a/forge-core/runtime/audit.go +++ b/forge-core/runtime/audit.go @@ -43,6 +43,14 @@ const ( EventMCPToolConflict = "mcp_tool_conflict" EventMCPTokenRefresh = "mcp_token_refresh" + // Agent Card events. Emitted once at agent startup with the + // finalized A2A Agent Card content for traceability. Carries the + // card's name, version, URL, protocolVersion, skill count, and a + // sha256 hash of the JSON-encoded card so consumers can detect + // config drift. See forge-cli/runtime/runner.go's startup pass + // and the A2A 0.3.0 spec. + EventAgentCardPublished = "agent_card_published" + // Deprecated: use EventAuthVerify. Kept as a string alias so any // audit-log consumer that grep'd for "auth_success" can be migrated. // Scheduled for removal in v0.11.0. From a5c87fbb619a6a3590fc5b940f3beac24a1076b1 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 4 Jun 2026 10:24:38 -0400 Subject: [PATCH 2/2] ci: retrigger after egress-proxy CONNECT test flake (unrelated to PR)