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.