@openwop/openwop-conformance 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +293 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +2 -2
  19. package/schemas/security-advisory.schema.json +109 -0
  20. package/src/lib/a2a-fake-peer.ts +143 -56
  21. package/src/lib/behavior-gate.ts +68 -0
  22. package/src/lib/env.ts +10 -0
  23. package/src/lib/grpc-framing.test.ts +96 -0
  24. package/src/lib/grpc-framing.ts +76 -0
  25. package/src/lib/oidc-issuer.test.ts +328 -0
  26. package/src/lib/oidc-issuer.ts +241 -0
  27. package/src/lib/otel-collector-grpc.test.ts +191 -0
  28. package/src/lib/otel-collector.test.ts +303 -0
  29. package/src/lib/otel-collector.ts +318 -14
  30. package/src/lib/otlp-protobuf.test.ts +461 -0
  31. package/src/lib/otlp-protobuf.ts +529 -0
  32. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  33. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  34. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  37. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  38. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  39. package/src/scenarios/agentMetadata.test.ts +1 -0
  40. package/src/scenarios/agentPackExport.test.ts +1 -0
  41. package/src/scenarios/agentPackInstall.test.ts +1 -0
  42. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  43. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  44. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  45. package/src/scenarios/auth-mtls.test.ts +274 -0
  46. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  47. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  48. package/src/scenarios/bulk-cancel.test.ts +111 -0
  49. package/src/scenarios/configurable-schema.test.ts +48 -0
  50. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  51. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  52. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  53. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  54. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  55. package/src/scenarios/discovery.test.ts +183 -0
  56. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  57. package/src/scenarios/idempotency.test.ts +6 -0
  58. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  59. package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
  60. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  61. package/src/scenarios/metric-emission.test.ts +113 -0
  62. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  63. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  64. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  65. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  66. package/src/scenarios/pause-resume.test.ts +119 -0
  67. package/src/scenarios/production-backpressure.test.ts +342 -0
  68. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  69. package/src/scenarios/registry-public.test.ts +131 -0
  70. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  71. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  72. package/src/scenarios/restart-during-run.test.ts +177 -0
  73. package/src/scenarios/spec-corpus-validity.test.ts +54 -26
  74. package/src/scenarios/staleClaim.test.ts +3 -0
  75. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  76. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  77. package/src/scenarios/webhook-negative.test.ts +90 -0
  78. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  79. package/src/setup.ts +25 -1
  80. package/vitest.config.ts +5 -1
@@ -0,0 +1,92 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/pack-lockfile.schema.json",
4
+ "title": "PackLockfile",
5
+ "description": "Reproducible-build lockfile pinning resolved versions of every pack a workspace depends on. Per spec/v1/node-packs.md §\"Dependency resolution + lockfile\". A workspace records its lockfile alongside its workflow definitions; on subsequent installs the registry resolver MUST honor the lockfile's exact versions rather than re-running semver resolution. This guarantees that a workflow that ran against `vendor.openwop.rust-hello@1.0.0` will still run against `1.0.0` even after `1.0.1` is published.",
6
+ "type": "object",
7
+ "required": ["lockfileVersion", "generatedAt", "packs"],
8
+ "properties": {
9
+ "lockfileVersion": {
10
+ "type": "integer",
11
+ "minimum": 1,
12
+ "description": "Lockfile schema version. Bumped when the lockfile structure changes incompatibly. v1.0 ships at lockfileVersion 1."
13
+ },
14
+ "generatedAt": {
15
+ "type": "string",
16
+ "format": "date-time",
17
+ "description": "ISO 8601 timestamp the lockfile was written. Used for audit + cache-bust diagnostics; resolvers MUST NOT use this for resolution decisions."
18
+ },
19
+ "registry": {
20
+ "type": "string",
21
+ "format": "uri",
22
+ "description": "Optional source-of-truth registry URL the resolved versions were fetched from (e.g., `https://packs.openwop.dev`). Multi-registry workspaces include this for audit. Single-registry workspaces MAY omit."
23
+ },
24
+ "packs": {
25
+ "type": "array",
26
+ "description": "Resolved pack records, one per pack referenced (transitively) by the workspace's workflow definitions. Order is informational only; resolvers MUST NOT rely on order. Empty arrays are allowed (a workspace with no packs).",
27
+ "items": { "$ref": "#/$defs/ResolvedPack" }
28
+ }
29
+ },
30
+ "additionalProperties": false,
31
+ "$defs": {
32
+ "ResolvedPack": {
33
+ "type": "object",
34
+ "required": ["name", "version", "integrity"],
35
+ "properties": {
36
+ "name": {
37
+ "type": "string",
38
+ "minLength": 3,
39
+ "maxLength": 256,
40
+ "pattern": "^(core|vendor|community|private|local)\\.[a-z0-9][a-z0-9.-]*$",
41
+ "description": "Pack name per node-packs.md §\"Manifest format\" `name`. Reverse-DNS-style; namespace prefix is one of the canonical tiers."
42
+ },
43
+ "version": {
44
+ "type": "string",
45
+ "minLength": 1,
46
+ "maxLength": 64,
47
+ "description": "Resolved exact version (no ranges). Semver MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]. The resolver MUST pin to this exact version on subsequent installs."
48
+ },
49
+ "resolved": {
50
+ "type": "string",
51
+ "format": "uri",
52
+ "description": "Optional canonical URL the tarball was fetched from. Audit aid; resolvers MUST verify the `integrity` field even if this URL is present."
53
+ },
54
+ "integrity": {
55
+ "type": "string",
56
+ "pattern": "^sha256-[A-Za-z0-9+/]{43}=$",
57
+ "description": "Subresource-integrity-style hash of the tarball: literal `sha256-` followed by 43-char base64-encoded SHA-256 digest with trailing `=` padding. Resolvers MUST verify the downloaded tarball's SHA-256 matches before extraction. Hash mismatch MUST fail the install with `pack_integrity_mismatch`."
58
+ },
59
+ "signature": {
60
+ "type": "object",
61
+ "description": "Optional pack signature material (Ed25519 per node-packs.md §Signing). When present, resolvers MUST verify the signature against the publisher's advertised public key.",
62
+ "required": ["algorithm", "publicKey", "value"],
63
+ "properties": {
64
+ "algorithm": { "type": "string", "enum": ["ed25519"] },
65
+ "publicKey": { "type": "string", "minLength": 1, "description": "Base64-encoded Ed25519 public key bytes." },
66
+ "value": { "type": "string", "minLength": 1, "description": "Base64-encoded Ed25519 signature over the tarball." }
67
+ },
68
+ "additionalProperties": false
69
+ },
70
+ "dependencies": {
71
+ "type": "object",
72
+ "additionalProperties": {
73
+ "type": "string",
74
+ "minLength": 1,
75
+ "maxLength": 64
76
+ },
77
+ "description": "Pinned versions for this pack's `dependencies` (per its manifest). Each key is a pack name; each value is the resolved exact version. Pinned recursively — a transitively-installed pack appears once in the top-level `packs[]` array AND as a value in each parent's `dependencies` map."
78
+ },
79
+ "peerDependencies": {
80
+ "type": "object",
81
+ "additionalProperties": {
82
+ "type": "string",
83
+ "minLength": 1,
84
+ "maxLength": 64
85
+ },
86
+ "description": "Echo of this pack's manifest `peerDependencies` for audit. Resolvers MUST verify that the host's `/.well-known/openwop` advertises every key listed here (per host-capabilities.md §\"Capability negotiation\"). Lockfile records the host-capability values the workspace committed to at lockfile-write time; mismatches at install time MAY warn (host advertises richer set) but MUST fail if a peer-dep is missing."
87
+ }
88
+ },
89
+ "additionalProperties": false
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,145 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/registry-version-manifest.schema.json",
4
+ "title": "RegistryVersionManifest",
5
+ "description": "Registry-augmented version manifest served at `/v1/packs/{name}/-/{version}.json`. Extends the bare pack manifest (`node-pack-manifest.schema.json`) with registry-side metadata (integrity hash, publishedAt timestamp, signed-blob URLs, lifecycle flags). This is the shape clients receive when they GET a specific pack version; the bare manifest is what publishers commit inside the tarball.\n\n**Why two schemas?** The bare manifest is the authoring contract — what a vendor writes in their pack source tree. The registry-augmented version is the SERVING contract — what `packs.openwop.dev` produces after `build-index.mjs` computes integrity hashes + adds URL templates. Validating both surfaces independently catches drift between authored content and served metadata.",
6
+ "type": "object",
7
+ "required": ["name", "version", "engines", "runtime", "integrity"],
8
+ "anyOf": [
9
+ { "properties": { "nodes": { "type": "array", "minItems": 1 } }, "required": ["nodes"] },
10
+ { "properties": { "agents": { "type": "array", "minItems": 1 } }, "required": ["agents"] }
11
+ ],
12
+ "properties": {
13
+ "name": {
14
+ "type": "string",
15
+ "description": "Reverse-DNS pack name. Mirrors `node-pack-manifest.schema.json` §name.",
16
+ "pattern": "^(core|vendor|community|private)\\.[a-z][a-z0-9_-]*(\\.[a-z][a-zA-Z0-9_-]*)+$",
17
+ "minLength": 1,
18
+ "maxLength": 256
19
+ },
20
+ "version": {
21
+ "type": "string",
22
+ "description": "Pack version per SemVer 2.0.0. Must match the URL path the manifest is served at.",
23
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
24
+ },
25
+ "description": { "type": "string", "maxLength": 1024 },
26
+ "author": { "type": "string" },
27
+ "license": { "type": "string" },
28
+ "homepage": { "type": "string", "format": "uri" },
29
+ "repository": { "type": "string", "format": "uri" },
30
+ "keywords": {
31
+ "type": "array",
32
+ "items": { "type": "string", "maxLength": 64 },
33
+ "maxItems": 50
34
+ },
35
+ "engines": {
36
+ "type": "object",
37
+ "required": ["openwop"],
38
+ "properties": {
39
+ "openwop": { "type": "string", "minLength": 1 }
40
+ },
41
+ "additionalProperties": { "type": "string" }
42
+ },
43
+ "runtime": {
44
+ "type": "object",
45
+ "required": ["language"],
46
+ "properties": {
47
+ "language": {
48
+ "type": "string",
49
+ "enum": ["javascript", "typescript", "wasm", "remote"],
50
+ "description": "Pack runtime. `javascript`/`typescript` for in-process Node executors; `wasm` for sandboxed WASM (RFC 0008 ABI); `remote` for agent-only packs that dispatch via remote LLM providers + don't ship local executor bytecode."
51
+ },
52
+ "entry": { "type": "string" },
53
+ "abiVersion": { "type": "string" }
54
+ },
55
+ "additionalProperties": true
56
+ },
57
+ "peerDependencies": {
58
+ "type": "object",
59
+ "description": "Host capabilities the pack consumes. Each key is a host-capability identifier (e.g., `host.canvas`); each value declares the support level the pack expects.",
60
+ "additionalProperties": { "type": "string" }
61
+ },
62
+ "dependencies": {
63
+ "type": "object",
64
+ "description": "Pack dependencies (other packs). Wire-locked at install time per `pack-lockfile.schema.json`.",
65
+ "additionalProperties": { "type": "string" }
66
+ },
67
+ "signing": {
68
+ "type": "object",
69
+ "description": "Embedded signing metadata. The pack's signing key MUST be one of the keys registered in `.well-known/openwop-registry.json` `signingKeys[]` AND MUST be authorized for the pack's namespace via `permittedNamespaces`.\n\nTwo identifier forms in the wild:\n - `keyId`: canonical (newer packs). Matches an entry in registry's `signingKeys[].keyId`.\n - `publicKeyRef`: legacy alias of `keyId` (used by `build-pack-tarball.mjs --key-id <id>`). Equivalent semantics.\n\nAt least one of `keyId` / `publicKeyRef` MUST be present; if both, they MUST be equal. The schema's `oneOf` block enforces this.",
70
+ "required": ["method"],
71
+ "oneOf": [
72
+ { "required": ["keyId"] },
73
+ { "required": ["publicKeyRef"] }
74
+ ],
75
+ "properties": {
76
+ "method": {
77
+ "type": "string",
78
+ "enum": ["manual", "ed25519", "sigstore"],
79
+ "description": "Signing method. `manual` and `ed25519` both denote a detached Ed25519 signature over canonical-JSON pack.json (synonymous in practice; both forms appear in published packs). `sigstore` reserved for the SLSA-friendly OIDC path."
80
+ },
81
+ "keyId": { "type": "string", "minLength": 1 },
82
+ "publicKeyRef": { "type": "string", "minLength": 1 },
83
+ "publicKeyUrl": { "type": "string", "format": "uri-reference" },
84
+ "signatureRef": { "type": "string" }
85
+ },
86
+ "additionalProperties": true
87
+ },
88
+ "nodes": {
89
+ "type": "array",
90
+ "items": {
91
+ "type": "object",
92
+ "required": ["typeId", "version", "category", "role"],
93
+ "additionalProperties": true
94
+ }
95
+ },
96
+ "agents": {
97
+ "type": "array",
98
+ "items": { "type": "object", "additionalProperties": true }
99
+ },
100
+ "integrity": {
101
+ "type": "string",
102
+ "description": "SRI-style integrity hash of the SIGNED tarball (`sha256-<base64(sha256)>`). Computed by `registry/scripts/build-index.mjs` from the on-disk `.tgz`. Consumers MUST verify the fetched tarball's hash matches this field before unpacking.",
103
+ "pattern": "^sha256-[A-Za-z0-9+/=]+$"
104
+ },
105
+ "publishedAt": {
106
+ "type": "string",
107
+ "description": "ISO-8601 UTC timestamp of first publish. Immutable per spec — set at first publish, never re-issued on subsequent index rebuilds.",
108
+ "format": "date-time"
109
+ },
110
+ "tarballUrl": {
111
+ "type": "string",
112
+ "description": "Registry-relative URL for the signed tarball. Always `/v1/packs/{name}/-/{version}.tgz`.",
113
+ "pattern": "^/v1/packs/[^/]+/-/[^/]+\\.tgz$"
114
+ },
115
+ "signatureUrl": {
116
+ "type": "string",
117
+ "description": "Registry-relative URL for the detached Ed25519 signature. Always `/v1/packs/{name}/-/{version}.sig`.",
118
+ "pattern": "^/v1/packs/[^/]+/-/[^/]+\\.sig$"
119
+ },
120
+ "deprecated": {
121
+ "type": "boolean",
122
+ "description": "Advisory: this version is deprecated. Consumers MAY refuse to install but registry continues to serve."
123
+ },
124
+ "yanked": {
125
+ "type": "boolean",
126
+ "description": "Hard refusal: this version was published in error. Registry MUST refuse to serve the tarball; consumers MUST refuse to dispatch nodes from yanked versions."
127
+ },
128
+ "yankedReason": {
129
+ "type": "string",
130
+ "maxLength": 512,
131
+ "description": "Optional one-line rationale shown by clients when a yanked install is attempted. Conventionally references the advisory id (`OPENWOP-YYYY-NNNN`) when one was issued."
132
+ },
133
+ "deprecationReason": {
134
+ "type": "string",
135
+ "maxLength": 512,
136
+ "description": "Optional one-line rationale shown by clients when an install of a deprecated version is attempted. Typically names the replacement version + migration link."
137
+ },
138
+ "advisoryUrl": {
139
+ "type": "string",
140
+ "format": "uri",
141
+ "description": "Optional URL with deprecation / yank rationale + remediation."
142
+ }
143
+ },
144
+ "additionalProperties": false
145
+ }
@@ -501,10 +501,10 @@
501
501
 
502
502
  "capBreached": {
503
503
  "type": "object",
504
- "description": "Emitted when a CapabilityLimit is exceeded (clarificationRounds / schemaRounds / envelopesPerTurn / maxNodeExecutions).",
504
+ "description": "Emitted when a CapabilityLimit is exceeded. Protocol-level limits use the four engine kinds (clarificationRounds / schemaRounds / envelopesPerTurn / maxNodeExecutions). RFC 0008 §K WASM-runtime caps use the wasm-* kinds (memory ceiling, fuel exhaustion, execution-time wall-clock).",
505
505
  "required": ["kind", "limit", "observed"],
506
506
  "properties": {
507
- "kind": { "type": "string", "enum": ["clarification", "schema", "envelopes", "node-executions"] },
507
+ "kind": { "type": "string", "enum": ["clarification", "schema", "envelopes", "node-executions", "wasm-memory", "wasm-fuel", "wasm-execution-time"] },
508
508
  "limit": { "type": "integer", "minimum": 0 },
509
509
  "observed": { "type": "integer", "minimum": 0 },
510
510
  "nodeId": { "type": "string" }
@@ -0,0 +1,109 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/security-advisory.schema.json",
4
+ "title": "SecurityAdvisory",
5
+ "description": "Registry-owned security advisory for a published OpenWOP pack version. Sourced from registry/security/advisories.json. Every entry MUST be reviewed + merged via maintainer PR; the CI check-advisories gate refuses to deploy if any active advisory targets a pack version that is not already yanked.",
6
+ "type": "object",
7
+ "required": ["id", "publishedAt", "severity", "summary", "affected"],
8
+ "properties": {
9
+ "id": {
10
+ "type": "string",
11
+ "description": "Stable advisory identifier. Format: `OPENWOP-YYYY-NNNN` (sequential, assigned by maintainers at PR merge time). MAY also include an aliased CVE ID via `cveIds[]`.",
12
+ "pattern": "^OPENWOP-[0-9]{4}-[0-9]{4}$"
13
+ },
14
+ "cveIds": {
15
+ "type": "array",
16
+ "description": "Aliased CVE identifiers if a CVE was reserved through Mitre or a CNA. Listed in cve.mitre.org format.",
17
+ "items": {
18
+ "type": "string",
19
+ "pattern": "^CVE-[0-9]{4}-[0-9]+$"
20
+ },
21
+ "uniqueItems": true
22
+ },
23
+ "publishedAt": {
24
+ "type": "string",
25
+ "format": "date",
26
+ "description": "Date the advisory was first published (ISO 8601 yyyy-mm-dd). Frozen at merge time; do not update on revision."
27
+ },
28
+ "updatedAt": {
29
+ "type": "string",
30
+ "format": "date",
31
+ "description": "Date of last advisory update. Bump when severity, affected, or fixedIn changes."
32
+ },
33
+ "severity": {
34
+ "type": "string",
35
+ "enum": ["critical", "high", "medium", "low"],
36
+ "description": "Severity per the rubric in docs/runbooks/INCIDENT-RESPONSE.md. critical=S0 (yank immediately), high=S1, medium=S2, low=S3."
37
+ },
38
+ "cvssV4Score": {
39
+ "type": "number",
40
+ "minimum": 0,
41
+ "maximum": 10,
42
+ "description": "Optional CVSS 4.0 base score. Informational; severity field is the authoritative classification."
43
+ },
44
+ "summary": {
45
+ "type": "string",
46
+ "description": "One-line human-readable summary of the vulnerability. Surfaces in tooling output.",
47
+ "minLength": 1,
48
+ "maxLength": 200
49
+ },
50
+ "details": {
51
+ "type": "string",
52
+ "description": "Long-form description: vulnerability mechanism, attack vector, impact, recommended consumer action. May reference external URLs via advisoryUrl."
53
+ },
54
+ "advisoryUrl": {
55
+ "type": "string",
56
+ "format": "uri",
57
+ "description": "External advisory link (e.g., GitHub Security Advisory, vendor security page, CVE detail page)."
58
+ },
59
+ "affected": {
60
+ "type": "array",
61
+ "description": "Pack versions impacted by this advisory. Each entry pins a pack name + a SemVer range. The check-advisories CI gate cross-references this list against every published version and fails if any non-yanked version matches.",
62
+ "minItems": 1,
63
+ "items": {
64
+ "type": "object",
65
+ "required": ["packName", "versions"],
66
+ "properties": {
67
+ "packName": {
68
+ "type": "string",
69
+ "description": "Pack name (e.g., `vendor.myndhyve.ai`). MUST exist under registry/v1/packs/.",
70
+ "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
71
+ },
72
+ "versions": {
73
+ "type": "string",
74
+ "description": "SemVer range using node-semver syntax (`<1.0.0`, `>=1.0.0 <1.0.3`, `1.0.0 || 1.0.1`). Matched against published version numbers via the check-advisories gate."
75
+ },
76
+ "fixedIn": {
77
+ "type": "string",
78
+ "description": "Earliest version that contains the fix. Consumers MUST upgrade to >= this version. Absent if no fix is available yet (defense-only).",
79
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
80
+ },
81
+ "fixedInRef": {
82
+ "type": "string",
83
+ "description": "PR or commit reference where the fix landed (e.g., `openwop/openwop#42`). Helps consumers audit the patch."
84
+ }
85
+ },
86
+ "additionalProperties": false
87
+ }
88
+ },
89
+ "credits": {
90
+ "type": "array",
91
+ "description": "Acknowledged reporters or researchers.",
92
+ "items": {
93
+ "type": "object",
94
+ "required": ["name"],
95
+ "properties": {
96
+ "name": { "type": "string", "minLength": 1 },
97
+ "url": { "type": "string", "format": "uri" },
98
+ "role": {
99
+ "type": "string",
100
+ "enum": ["finder", "reporter", "analyst", "coordinator", "remediation-developer"],
101
+ "description": "Per CSAF role vocabulary. Defaults to `finder` when not specified."
102
+ }
103
+ },
104
+ "additionalProperties": false
105
+ }
106
+ }
107
+ },
108
+ "additionalProperties": false
109
+ }
@@ -3,24 +3,29 @@
3
3
  *
4
4
  * The A2A protocol (https://a2a-protocol.org/) defines an `AgentCard` for
5
5
  * discovery plus a Task lifecycle whose `TaskState` enum drives most of
6
- * the conformance burden. We expose just enough of the HTTP+JSON
6
+ * the conformance burden. We expose just enough of A2A v0.3 JSON-RPC
7
7
  * transport to let conformance scenarios drive the host through the
8
8
  * four documented drift points from `spec/v1/a2a-integration.md`
9
9
  * §"State projection".
10
10
  *
11
- * Endpoints (minimal):
12
- * GET /agent.json — AgentCard
13
- * POST /tasks — create a task; returns { taskId, state: 'SUBMITTED' }
14
- * GET /tasks/{taskId} — poll task state + last message
15
- * POST /tasks/{taskId}/messages append a message (used by host to resume an INPUT_REQUIRED task)
11
+ * Endpoints (A2A v0.3 wire shape):
12
+ * GET /.well-known/agent-card.json — AgentCard at the spec-mandated
13
+ * well-known path (per
14
+ * @a2a-js/sdk@0.3.13 `AGENT_CARD_PATH`)
15
+ * POST /a2a/jsonrpc JSON-RPC dispatch:
16
+ * - method `message/send` →
17
+ * creates a Task, returns Task obj
18
+ * - method `tasks/get` → fetches Task
16
19
  *
17
20
  * Test fixtures set the *next* state transition via `setNextState(...)`
18
21
  * so a single scenario can walk the peer through SUBMITTED → WORKING →
19
22
  * INPUT_REQUIRED → COMPLETED (or AUTH_REQUIRED, or REJECTED) without
20
- * implementing a real agent.
23
+ * implementing a real agent. The internal API uses the UPPERCASE enum
24
+ * from openwop's a2a-integration.md (which references the gRPC enum);
25
+ * wire responses use the v0.3 JSON-RPC lowercase-hyphen form.
21
26
  *
22
27
  * @see spec/v1/a2a-integration.md §"State projection"
23
- * @see https://a2a-protocol.org/latest/specification/
28
+ * @see https://a2a-protocol.org/v0.3.0/specification
24
29
  */
25
30
 
26
31
  import { createServer, type Server } from 'node:http';
@@ -37,16 +42,32 @@ export type A2ATaskState =
37
42
  | 'CANCELED'
38
43
  | 'REJECTED';
39
44
 
45
+ /** Translate internal UPPERCASE state to A2A v0.3 wire form (lowercase + hyphen). */
46
+ function wireState(s: A2ATaskState): string {
47
+ switch (s) {
48
+ case 'UNSPECIFIED': return 'unknown';
49
+ case 'SUBMITTED': return 'submitted';
50
+ case 'WORKING': return 'working';
51
+ case 'INPUT_REQUIRED': return 'input-required';
52
+ case 'AUTH_REQUIRED': return 'auth-required';
53
+ case 'COMPLETED': return 'completed';
54
+ case 'FAILED': return 'failed';
55
+ case 'CANCELED': return 'canceled';
56
+ case 'REJECTED': return 'rejected';
57
+ }
58
+ }
59
+
40
60
  interface A2ATask {
41
- taskId: string;
61
+ id: string;
62
+ contextId: string;
42
63
  state: A2ATaskState;
43
- messages: Array<{ role: 'user' | 'agent'; content: unknown }>;
44
- metadata?: Record<string, unknown>;
64
+ history: Array<{ role: 'user' | 'agent'; parts: unknown[] }>;
45
65
  }
46
66
 
47
67
  export interface A2APeerInvocation {
48
68
  readonly method: string;
49
69
  readonly path: string;
70
+ readonly rpcMethod: string | null;
50
71
  readonly body: unknown;
51
72
  readonly timestamp: number;
52
73
  }
@@ -61,7 +82,14 @@ export class A2AFakePeer {
61
82
 
62
83
  async start(port: number = 0): Promise<void> {
63
84
  return new Promise((resolve, reject) => {
64
- const server = createServer((req, res) => this._handle(req, res));
85
+ const server = createServer((req, res) => {
86
+ this._handle(req, res).catch((err) => {
87
+ if (!res.headersSent) {
88
+ res.writeHead(500, { 'Content-Type': 'application/json' });
89
+ res.end(JSON.stringify({ error: String(err) }));
90
+ }
91
+ });
92
+ });
65
93
  server.on('error', reject);
66
94
  server.listen(port, '127.0.0.1', () => {
67
95
  const addr = server.address() as AddressInfo;
@@ -121,6 +149,21 @@ export class A2AFakePeer {
121
149
  return this._tasks.get(taskId);
122
150
  }
123
151
 
152
+ private _taskToWire(t: A2ATask): Record<string, unknown> {
153
+ return {
154
+ id: t.id,
155
+ kind: 'task',
156
+ contextId: t.contextId,
157
+ status: { state: wireState(t.state) },
158
+ history: t.history.map((m) => ({
159
+ kind: 'message',
160
+ role: m.role,
161
+ parts: m.parts,
162
+ messageId: `msg-${t.id}-${t.history.indexOf(m)}`,
163
+ })),
164
+ };
165
+ }
166
+
124
167
  private async _handle(
125
168
  req: import('node:http').IncomingMessage,
126
169
  res: import('node:http').ServerResponse,
@@ -138,82 +181,126 @@ export class A2AFakePeer {
138
181
  }
139
182
  }
140
183
 
184
+ const rpcMethod =
185
+ body && typeof body === 'object' && body !== null && 'method' in body
186
+ ? String((body as { method: unknown }).method)
187
+ : null;
188
+
141
189
  this._invocations.push({
142
190
  method: req.method ?? 'GET',
143
191
  path: url,
192
+ rpcMethod,
144
193
  body,
145
194
  timestamp: Date.now(),
146
195
  });
147
196
 
148
- // GET /agent.json
149
- if (req.method === 'GET' && url.startsWith('/agent.json')) {
197
+ // GET /.well-known/agent-card.json — A2A v0.3 well-known path
198
+ if (req.method === 'GET' && url.startsWith('/.well-known/agent-card.json')) {
150
199
  const card = {
151
200
  protocolVersion: '0.3.0',
152
201
  name: 'openwop-conformance-fake-a2a',
153
202
  description: 'Synthetic A2A peer for openwop conformance suite',
154
- url: this.endpoint(),
203
+ url: `${this.endpoint()}/a2a/jsonrpc`,
155
204
  version: '1.0.0',
156
- capabilities: { streaming: false },
205
+ capabilities: { streaming: false, pushNotifications: false },
157
206
  skills: [
158
207
  {
159
208
  id: 'echo',
160
209
  name: 'echo',
161
210
  description: 'Returns input verbatim',
211
+ tags: ['echo'],
162
212
  },
163
213
  ],
214
+ defaultInputModes: ['text'],
215
+ defaultOutputModes: ['text'],
216
+ additionalInterfaces: [
217
+ { url: `${this.endpoint()}/a2a/jsonrpc`, transport: 'JSONRPC' },
218
+ ],
164
219
  };
165
220
  res.writeHead(200, { 'Content-Type': 'application/json' });
166
221
  res.end(JSON.stringify(card));
167
222
  return;
168
223
  }
169
224
 
170
- // POST /tasks create task
171
- if (req.method === 'POST' && url === '/tasks') {
172
- const taskId = `task-${++this._taskIdCounter}`;
173
- const initial: A2ATaskState = this._nextStateOverride ?? 'SUBMITTED';
174
- this._nextStateOverride = null;
175
- const task: A2ATask = {
176
- taskId,
177
- state: initial,
178
- messages: body && typeof body === 'object' ? [{ role: 'user', content: body }] : [],
179
- };
180
- this._tasks.set(taskId, task);
181
- res.writeHead(200, { 'Content-Type': 'application/json' });
182
- res.end(JSON.stringify({ taskId, state: task.state }));
183
- return;
184
- }
225
+ // POST /a2a/jsonrpc A2A v0.3 JSON-RPC transport
226
+ if (req.method === 'POST' && url === '/a2a/jsonrpc') {
227
+ if (
228
+ !body ||
229
+ typeof body !== 'object' ||
230
+ (body as { jsonrpc?: unknown }).jsonrpc !== '2.0'
231
+ ) {
232
+ res.writeHead(200, { 'Content-Type': 'application/json' });
233
+ res.end(
234
+ JSON.stringify({
235
+ jsonrpc: '2.0',
236
+ id: null,
237
+ error: { code: -32600, message: 'Invalid JSON-RPC envelope' },
238
+ }),
239
+ );
240
+ return;
241
+ }
242
+ const rpc = body as { id?: string | number; method?: string; params?: unknown };
185
243
 
186
- // GET /tasks/{taskId}
187
- const getMatch = url.match(/^\/tasks\/([^/?]+)$/);
188
- if (req.method === 'GET' && getMatch) {
189
- const task = this._tasks.get(decodeURIComponent(getMatch[1]));
190
- if (!task) {
191
- res.writeHead(404, { 'Content-Type': 'application/json' });
192
- res.end(JSON.stringify({ error: 'not_found' }));
244
+ if (rpc.method === 'message/send') {
245
+ const taskId = `task-${++this._taskIdCounter}`;
246
+ const contextId = `ctx-${taskId}`;
247
+ const initial: A2ATaskState = this._nextStateOverride ?? 'SUBMITTED';
248
+ this._nextStateOverride = null;
249
+ const userMessage = (rpc.params as { message?: { parts?: unknown[] } } | undefined)
250
+ ?.message;
251
+ const task: A2ATask = {
252
+ id: taskId,
253
+ contextId,
254
+ state: initial,
255
+ history: userMessage
256
+ ? [{ role: 'user', parts: userMessage.parts ?? [] }]
257
+ : [],
258
+ };
259
+ this._tasks.set(taskId, task);
260
+ res.writeHead(200, { 'Content-Type': 'application/json' });
261
+ res.end(
262
+ JSON.stringify({
263
+ jsonrpc: '2.0',
264
+ id: rpc.id ?? null,
265
+ result: this._taskToWire(task),
266
+ }),
267
+ );
193
268
  return;
194
269
  }
195
- res.writeHead(200, { 'Content-Type': 'application/json' });
196
- res.end(JSON.stringify(task));
197
- return;
198
- }
199
270
 
200
- // POST /tasks/{taskId}/messages — host resumes an INPUT_REQUIRED task
201
- const msgMatch = url.match(/^\/tasks\/([^/?]+)\/messages$/);
202
- if (req.method === 'POST' && msgMatch) {
203
- const task = this._tasks.get(decodeURIComponent(msgMatch[1]));
204
- if (!task) {
205
- res.writeHead(404, { 'Content-Type': 'application/json' });
206
- res.end(JSON.stringify({ error: 'not_found' }));
271
+ if (rpc.method === 'tasks/get') {
272
+ const params = (rpc.params ?? {}) as { id?: string };
273
+ const task = params.id ? this._tasks.get(params.id) : undefined;
274
+ if (!task) {
275
+ res.writeHead(200, { 'Content-Type': 'application/json' });
276
+ res.end(
277
+ JSON.stringify({
278
+ jsonrpc: '2.0',
279
+ id: rpc.id ?? null,
280
+ error: { code: -32001, message: 'Task not found' },
281
+ }),
282
+ );
283
+ return;
284
+ }
285
+ res.writeHead(200, { 'Content-Type': 'application/json' });
286
+ res.end(
287
+ JSON.stringify({
288
+ jsonrpc: '2.0',
289
+ id: rpc.id ?? null,
290
+ result: this._taskToWire(task),
291
+ }),
292
+ );
207
293
  return;
208
294
  }
209
- task.messages.push({ role: 'user', content: body });
210
- // Move from INPUT_REQUIRED back to WORKING then to COMPLETED for the
211
- // simple roundtrip. Tests that need a different next-state set it
212
- // via setNextState() before posting the message.
213
- task.state = this._nextStateOverride ?? 'COMPLETED';
214
- this._nextStateOverride = null;
295
+
215
296
  res.writeHead(200, { 'Content-Type': 'application/json' });
216
- res.end(JSON.stringify({ taskId: task.taskId, state: task.state }));
297
+ res.end(
298
+ JSON.stringify({
299
+ jsonrpc: '2.0',
300
+ id: rpc.id ?? null,
301
+ error: { code: -32601, message: `Method not found: ${rpc.method}` },
302
+ }),
303
+ );
217
304
  return;
218
305
  }
219
306