@openwop/openwop-conformance 1.10.0 → 1.11.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 (55) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -2
  3. package/api/asyncapi.yaml +70 -0
  4. package/api/openapi.yaml +268 -1
  5. package/coverage.md +30 -2
  6. package/fixtures/oauth-providers/synthetic.json +38 -0
  7. package/fixtures.md +10 -0
  8. package/package.json +1 -1
  9. package/schemas/README.md +12 -0
  10. package/schemas/agent-deployment-transition.schema.json +49 -0
  11. package/schemas/agent-deployment.schema.json +54 -0
  12. package/schemas/agent-eval-suite.schema.json +140 -0
  13. package/schemas/agent-inventory-response.schema.json +25 -0
  14. package/schemas/agent-manifest.schema.json +5 -0
  15. package/schemas/agent-org-chart.schema.json +82 -0
  16. package/schemas/agent-ref.schema.json +12 -2
  17. package/schemas/agent-roster-entry.schema.json +81 -0
  18. package/schemas/agent-roster-response.schema.json +21 -0
  19. package/schemas/budget-policy.schema.json +18 -0
  20. package/schemas/capabilities.schema.json +277 -0
  21. package/schemas/credential-provenance.schema.json +18 -0
  22. package/schemas/eval-summary.schema.json +92 -0
  23. package/schemas/node-pack-manifest.schema.json +17 -0
  24. package/schemas/org-chart-responsibility-view.schema.json +26 -0
  25. package/schemas/run-event-payloads.schema.json +286 -3
  26. package/schemas/run-event.schema.json +19 -0
  27. package/schemas/tool-descriptor.schema.json +63 -0
  28. package/schemas/trigger-subscription.schema.json +26 -0
  29. package/src/lib/agentRoster.ts +76 -0
  30. package/src/lib/liveRuntime.ts +59 -0
  31. package/src/lib/profiles.ts +157 -0
  32. package/src/lib/runtimeRequires.ts +38 -0
  33. package/src/lib/safeFetch.ts +87 -0
  34. package/src/scenarios/agent-deployment-shape.test.ts +139 -0
  35. package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
  36. package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
  37. package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
  38. package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
  39. package/src/scenarios/agent-live-structured-output.test.ts +58 -0
  40. package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
  41. package/src/scenarios/agent-platform-profile.test.ts +158 -0
  42. package/src/scenarios/agent-roster-attribution.test.ts +179 -0
  43. package/src/scenarios/agent-roster-shape.test.ts +146 -0
  44. package/src/scenarios/budget-policy-shape.test.ts +136 -0
  45. package/src/scenarios/egress-provenance-shape.test.ts +137 -0
  46. package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
  47. package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
  48. package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
  49. package/src/scenarios/runtime-requires-shape.test.ts +134 -0
  50. package/src/scenarios/safefetch-behavior.test.ts +99 -0
  51. package/src/scenarios/safefetch-live-audit.test.ts +175 -0
  52. package/src/scenarios/spec-corpus-validity.test.ts +19 -3
  53. package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
  54. package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
  55. package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Credential provenance + egress policy — descriptor + event + capability shapes (RFC 0079).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies that:
5
+ * - `credential-provenance.schema.json` compiles and round-trips a conforming
6
+ * `CredentialProvenance`, and rejects the malformed (`audiences: []` —
7
+ * `minItems:1`, a credential with no audience can't be bound to anything;
8
+ * a missing REQUIRED `credentialId`; an unknown property under
9
+ * `additionalProperties:false`).
10
+ * - the descriptor + the `egress.decided` payload are CONTENT-FREE OF THE
11
+ * SECRET: neither schema declares a secret-value property (the public test
12
+ * for the protocol-tier SECURITY invariant `egress-decision-no-secret-leak`).
13
+ * - the `egress.decided` payload $def validates a conforming content-free
14
+ * record and rejects an out-of-enum `decision` (`ok` is a `reason`, not a
15
+ * `decision` — the canonical allow value is `allowed`), and requires
16
+ * `decision` + `destination`.
17
+ * - `egress.decided` appears in the RunEventType enum.
18
+ * - `capabilities.httpClient.egressPolicy` is declared with `supported` /
19
+ * `decisions`.
20
+ *
21
+ * Behavioral assertions (the §C audience-binding MUST — a credential bound to
22
+ * audience A on an egress to B → denied/downgraded, never allowed-with-credential;
23
+ * fail-closed on unevaluable provenance) are gated on
24
+ * `capabilities.httpClient.egressPolicy.supported` and land in
25
+ * `egress-audience-binding.test.ts` + `egress-decision-content-free.test.ts`
26
+ * (deferred per RFC 0079 §Conformance — reference host deferred). That binding is
27
+ * tracked as the reference-impl-tier `egress-credential-audience-bound` invariant
28
+ * until a host wires it (RFC 0035 precedent). This scenario asserts the wire
29
+ * contract, not host behavior.
30
+ *
31
+ * Spec references:
32
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§"Credential provenance + egress policy")
33
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0079-credential-provenance-and-egress-policy.md
34
+ * - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (egress-decision-no-secret-leak)
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { readFileSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import Ajv2020 from 'ajv/dist/2020.js';
41
+ import addFormats from 'ajv-formats';
42
+ import { SCHEMAS_DIR } from '../lib/paths.js';
43
+
44
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
45
+
46
+ function loadSchema(name: string): Record<string, unknown> {
47
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
48
+ }
49
+
50
+ /** Property names that would betray a secret value leaking onto the wire. */
51
+ const SECRET_PROP_NAMES = ['secret', 'value', 'token', 'apiKey', 'authorization', 'password', 'credential'];
52
+
53
+ describe('egress-provenance-shape: CredentialProvenance (RFC 0079 §A, server-free)', () => {
54
+ const ajv = addFormats(new Ajv2020({ strict: false }));
55
+ const provenance = loadSchema('credential-provenance.schema.json');
56
+ const validate = ajv.compile(provenance);
57
+
58
+ it('a conforming descriptor validates', () => {
59
+ expect(
60
+ validate({ credentialId: 'cred-stripe-1', issuer: 'host', audiences: ['api.stripe.com'], expiresAt: '2026-12-01T00:00:00Z', scopes: ['egress:stripe:charge'], redactionPolicy: 'always' }),
61
+ why('host-capabilities.md §"Credential provenance + egress policy"', 'a conforming CredentialProvenance MUST validate'),
62
+ ).toBe(true);
63
+ });
64
+
65
+ it('audiences: [] is rejected (minItems:1 — a credential needs ≥1 audience to bind)', () => {
66
+ expect(validate({ credentialId: 'c', issuer: 'host', audiences: [] }), why('RFC 0079 §A', 'audiences MUST have ≥1 entry')).toBe(false);
67
+ });
68
+
69
+ it('a missing REQUIRED credentialId is rejected', () => {
70
+ expect(validate({ issuer: 'host', audiences: ['a'] }), why('RFC 0079 §A', 'credentialId is REQUIRED')).toBe(false);
71
+ });
72
+
73
+ it('an unknown property is rejected (additionalProperties:false)', () => {
74
+ expect(validate({ credentialId: 'c', issuer: 'host', audiences: ['a'], secretValue: 'sk-live-xxx' }), why('RFC 0079 §A', 'CredentialProvenance MUST be additionalProperties:false')).toBe(false);
75
+ });
76
+
77
+ it('declares no secret-value property (egress-decision-no-secret-leak)', () => {
78
+ const props = Object.keys((provenance.properties ?? {}) as Record<string, unknown>);
79
+ for (const p of props) {
80
+ expect(
81
+ SECRET_PROP_NAMES.includes(p.toLowerCase()),
82
+ why('SECURITY invariant egress-decision-no-secret-leak', `CredentialProvenance MUST NOT declare a secret-bearing property (${p})`),
83
+ ).toBe(false);
84
+ }
85
+ });
86
+ });
87
+
88
+ describe('egress-provenance-shape: egress.decided event (RFC 0079 §B, server-free)', () => {
89
+ const payloads = loadSchema('run-event-payloads.schema.json');
90
+ const ajv = addFormats(new Ajv2020({ strict: false }));
91
+ const validate = ajv.compile({
92
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
93
+ $defs: (payloads as { $defs: Record<string, unknown> }).$defs,
94
+ $ref: '#/$defs/egressDecided',
95
+ } as Record<string, unknown>);
96
+
97
+ it('validates a content-free decision and enforces the decision enum + required fields', () => {
98
+ expect(validate({ decision: 'allowed', destination: 'api.stripe.com', credentialId: 'cred-stripe-1', reason: 'ok' }), why('RFC 0079 §B', 'a conforming egress.decided MUST validate')).toBe(true);
99
+ expect(validate({ decision: 'denied', destination: 'attacker.example', credentialId: 'cred-stripe-1', reason: 'out-of-audience' }), why('RFC 0079 §B', 'a denied decision MUST validate')).toBe(true);
100
+ expect(validate({ decision: 'ok', destination: 'a' }), why('RFC 0079 §B', 'the canonical allow value is "allowed", not "ok"')).toBe(false);
101
+ expect(validate({ destination: 'a' }), why('RFC 0079 §B', 'decision is REQUIRED')).toBe(false);
102
+ expect(validate({ decision: 'allowed' }), why('RFC 0079 §B', 'destination is REQUIRED')).toBe(false);
103
+ });
104
+
105
+ it('reason is a CLOSED enum — a free-form / URL-bearing reason is rejected (egress-decision-no-secret-leak)', () => {
106
+ expect(validate({ decision: 'denied', destination: 'attacker.example', reason: 'out-of-audience' }), why('egress-decision-no-secret-leak', 'a conventional reason code MUST validate')).toBe(true);
107
+ expect(
108
+ validate({ decision: 'denied', destination: 'attacker.example', reason: 'https://attacker.example/leak?token=sk-live-xxx' }),
109
+ why('egress-decision-no-secret-leak', 'a free-form / URL-bearing reason MUST be rejected (it would defeat the content-free guarantee)'),
110
+ ).toBe(false);
111
+ });
112
+
113
+ it('the egressDecided $def declares no secret-value property', () => {
114
+ const def = ((payloads.$defs as Record<string, { properties?: Record<string, unknown> }>).egressDecided.properties) ?? {};
115
+ for (const p of Object.keys(def)) {
116
+ expect(SECRET_PROP_NAMES.includes(p.toLowerCase()), why('egress-decision-no-secret-leak', `egress.decided MUST NOT declare a secret-bearing property (${p})`)).toBe(false);
117
+ }
118
+ });
119
+
120
+ it('egress.decided is in the RunEventType enum', () => {
121
+ const runEvent = loadSchema('run-event.schema.json');
122
+ const enumVals = ((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? [];
123
+ expect(enumVals.includes('egress.decided'), why('run-event.schema.json', 'egress.decided MUST be in the RunEventType enum')).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe('egress-provenance-shape: capability advertisement (RFC 0079 §D, server-free)', () => {
128
+ it('capabilities.httpClient.egressPolicy is declared with its sub-flags', () => {
129
+ const caps = loadSchema('capabilities.schema.json');
130
+ const httpClient = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown> }> }>).httpClient;
131
+ const egressPolicy = httpClient?.properties?.egressPolicy;
132
+ expect(egressPolicy, why('capabilities.md §httpClient', 'httpClient.egressPolicy MUST be declared')).toBeDefined();
133
+ for (const flag of ['supported', 'decisions']) {
134
+ expect(egressPolicy?.properties?.[flag], why('host-capabilities.md §"Credential provenance + egress policy"', `egressPolicy.${flag} MUST be declared`)).toBeDefined();
135
+ }
136
+ });
137
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Memory capability model — reconciled dimensions + degraded-projection shapes (RFC 0080).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies that:
5
+ * - `capabilities.memory` declares the additive `writable` / `search` / `retention`
6
+ * dimensions (RFC 0080 §A), without disturbing the existing
7
+ * `supported` / `compaction` / `distillation` / `attribution` fields.
8
+ * - the `memory.search` / `memory.retention` sub-blocks validate conforming
9
+ * instances and reject malformed ones (`retention.ttl` non-boolean; an
10
+ * unknown `search.modes` enum value; an unknown property under
11
+ * `additionalProperties:false`).
12
+ * - `agent-inventory-response` declares the `memoryDegraded` (bool) +
13
+ * `degradedMemoryDimensions` (closed enum of the eight §A dimension names)
14
+ * inventory fields (RFC 0080 §C), and rejects an out-of-enum dimension.
15
+ * - the eight §A dimension names are stable (the `degradedMemoryDimensions` enum).
16
+ * - `deriveProfiles` surfaces `openwop-memory` for a read/write + long-term
17
+ * payload and withholds it for a `writable:false` payload (the §D predicate).
18
+ *
19
+ * Behavioral assertions (a live `GET /v1/agents` stamping `memoryDegraded` when an
20
+ * agent's `memoryShape` exceeds the host's reconciled model) are gated on
21
+ * `capabilities.agents.manifestRuntime` + `memory` and land in
22
+ * `memory-degraded-projection.test.ts` (deferred per RFC 0080 §Conformance — the
23
+ * degraded projection soft-skips until a reference host computes it). This scenario
24
+ * asserts the wire contract, not host behavior.
25
+ *
26
+ * Spec references:
27
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/agent-memory.md (§"Memory capability model")
28
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/profiles.md (§`openwop-memory`)
29
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0080-agent-memory-capability-reconciliation.md
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import Ajv2020 from 'ajv/dist/2020.js';
36
+ import addFormats from 'ajv-formats';
37
+ import { SCHEMAS_DIR } from '../lib/paths.js';
38
+ import { deriveProfiles } from '../lib/profiles.js';
39
+
40
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
41
+
42
+ function loadSchema(name: string): Record<string, unknown> {
43
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
44
+ }
45
+
46
+ /** The canonical eight RFC 0080 §A dimension names, in table order. */
47
+ const DIMENSIONS = [
48
+ 'read',
49
+ 'write',
50
+ 'search',
51
+ 'long-term-durability',
52
+ 'compaction',
53
+ 'attribution',
54
+ 'replay-snapshot',
55
+ 'retention',
56
+ ] as const;
57
+
58
+ describe('memory-capability-model-shape: reconciled dimensions (RFC 0080 §A, server-free)', () => {
59
+ const caps = loadSchema('capabilities.schema.json');
60
+ const memory = (caps.properties as Record<string, { properties?: Record<string, unknown> }>).memory;
61
+
62
+ it('capabilities.memory declares the additive writable / search / retention dimensions', () => {
63
+ for (const dim of ['writable', 'search', 'retention']) {
64
+ expect(
65
+ memory?.properties?.[dim],
66
+ why('agent-memory.md §"Memory capability model"', `capabilities.memory.${dim} MUST be declared (RFC 0080 §A)`),
67
+ ).toBeDefined();
68
+ }
69
+ });
70
+
71
+ it('the pre-existing memory fields are untouched (additive, no relocation)', () => {
72
+ for (const dim of ['supported', 'compaction', 'distillation', 'attribution']) {
73
+ expect(
74
+ memory?.properties?.[dim],
75
+ why('COMPATIBILITY.md §2.1', `capabilities.memory.${dim} MUST remain (RFC 0080 is additive)`),
76
+ ).toBeDefined();
77
+ }
78
+ });
79
+
80
+ it('memory.search / memory.retention validate conforming instances and reject malformed ones', () => {
81
+ const ajv = addFormats(new Ajv2020({ strict: false }));
82
+ // Wrap the extracted sub-block in a standalone schema (no external $refs in the block).
83
+ const validate = ajv.compile({
84
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
85
+ type: 'object',
86
+ additionalProperties: false,
87
+ properties: { memory },
88
+ } as Record<string, unknown>);
89
+
90
+ expect(
91
+ validate({ memory: { supported: true, writable: false, search: { supported: true, modes: ['semantic', 'filter'] }, retention: { ttl: true, forget: true } } }),
92
+ why('capabilities.md §memory', 'a full reconciled-memory advertisement MUST validate'),
93
+ ).toBe(true);
94
+
95
+ expect(
96
+ validate({ memory: { retention: { ttl: 'yes' } } }),
97
+ why('RFC 0080 §A', 'retention.ttl MUST be boolean'),
98
+ ).toBe(false);
99
+
100
+ expect(
101
+ validate({ memory: { search: { supported: true, modes: ['fuzzy'] } } }),
102
+ why('RFC 0080 §A', 'search.modes MUST be the closed enum [semantic, filter]'),
103
+ ).toBe(false);
104
+
105
+ expect(
106
+ validate({ memory: { search: { supported: true, unknownField: 1 } } }),
107
+ why('RFC 0080 §A', 'memory.search MUST be additionalProperties:false'),
108
+ ).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe('memory-capability-model-shape: degraded projection (RFC 0080 §C, server-free)', () => {
113
+ const inventory = loadSchema('agent-inventory-response.schema.json');
114
+
115
+ it('agent-inventory-response declares memoryDegraded + degradedMemoryDimensions', () => {
116
+ const entry = ((inventory.$defs as Record<string, { properties?: Record<string, unknown> }>)
117
+ .AgentInventoryEntry).properties;
118
+ expect(
119
+ entry?.memoryDegraded,
120
+ why('agent-memory.md §C-1', 'memoryDegraded MUST be declared on the inventory entry'),
121
+ ).toBeDefined();
122
+ expect(
123
+ entry?.degradedMemoryDimensions,
124
+ why('agent-memory.md §C-1', 'degradedMemoryDimensions MUST be declared on the inventory entry'),
125
+ ).toBeDefined();
126
+ });
127
+
128
+ it('degradedMemoryDimensions enumerates exactly the eight §A dimension names', () => {
129
+ const entry = ((inventory.$defs as Record<string, { properties?: Record<string, { items?: { enum?: string[] } }> }>)
130
+ .AgentInventoryEntry).properties;
131
+ const enumVals = entry?.degradedMemoryDimensions?.items?.enum ?? [];
132
+ expect(
133
+ [...enumVals].sort(),
134
+ why('agent-memory.md §A', 'the degraded-dimension enum MUST be the eight reconciled dimensions'),
135
+ ).toEqual([...DIMENSIONS].sort());
136
+ });
137
+
138
+ it('the inventory schema round-trips a degraded entry and rejects an out-of-enum dimension', () => {
139
+ const ajv = addFormats(new Ajv2020({ strict: false }));
140
+ const validate = ajv.compile(inventory);
141
+ const base = {
142
+ agentId: 'a', persona: 'A', label: 'A', modelClass: 'standard',
143
+ packName: 'p', packVersion: '1.0.0', toolAllowlist: [], hasHandoffSchemas: false,
144
+ };
145
+ expect(
146
+ validate({ total: 1, agents: [{ ...base, memoryDegraded: true, degradedMemoryDimensions: ['write', 'long-term-durability'] }] }),
147
+ why('agent-memory.md §C-1', 'a degraded inventory entry MUST validate'),
148
+ ).toBe(true);
149
+ expect(
150
+ validate({ total: 1, agents: [{ ...base, memoryDegraded: true, degradedMemoryDimensions: ['telepathy'] }] }),
151
+ why('agent-memory.md §C-1', 'an out-of-enum degraded dimension MUST be rejected'),
152
+ ).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('memory-capability-model-shape: openwop-memory derivation (RFC 0080 §D, server-free)', () => {
157
+ it('deriveProfiles surfaces openwop-memory for a read/write + long-term host', () => {
158
+ const c = {
159
+ protocolVersion: '1.0',
160
+ supportedEnvelopes: ['clarification.request'],
161
+ schemaVersions: {},
162
+ limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
163
+ memory: { supported: true },
164
+ agents: { memoryBackends: ['long-term'] },
165
+ } as Record<string, unknown>;
166
+ expect(
167
+ deriveProfiles(c).includes('openwop-memory'),
168
+ why('profiles.md §openwop-memory', 'a read/write + long-term host MUST derive openwop-memory'),
169
+ ).toBe(true);
170
+ });
171
+
172
+ it('deriveProfiles withholds openwop-memory from a read-only (writable:false) host', () => {
173
+ const c = {
174
+ protocolVersion: '1.0',
175
+ supportedEnvelopes: ['clarification.request'],
176
+ schemaVersions: {},
177
+ limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
178
+ memory: { supported: true, writable: false },
179
+ agents: { memoryBackends: ['long-term'] },
180
+ } as Record<string, unknown>;
181
+ expect(
182
+ deriveProfiles(c).includes('openwop-memory'),
183
+ why('profiles.md §openwop-memory', 'a read-only host MUST NOT derive openwop-memory'),
184
+ ).toBe(false);
185
+ });
186
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * oauth-authorization-code-roundtrip — RFC 0047 §C (the authorization-code grant
3
+ * end-to-end) + §C.2 / `credential-payload-redaction`.
4
+ *
5
+ * Closes the RFC 0047 Tier-2 gap: `oauth-capability-shape` proves the discovery
6
+ * block is well-formed and `oauth-connector-redaction` proves an already-acquired
7
+ * token doesn't leak — but nothing exercised the actual authorization-code DANCE
8
+ * (redirect → callback → token exchange) against a known provider. This scenario
9
+ * drives that roundtrip against ONE canonical synthetic provider whose endpoints a
10
+ * conformance test double serves, so a host can prove the grant without a live IdP.
11
+ *
12
+ * The synthetic provider + its canned exchange are defined in
13
+ * `fixtures/oauth-providers/synthetic.json`; the constants below mirror it (kept
14
+ * inline so the scenario runs from the published tarball without fixture-path
15
+ * resolution, exactly like `oauth-connector-redaction`'s TOKEN_CANARY).
16
+ *
17
+ * Capability-gated: skips unless the host advertises
18
+ * `capabilities.oauth.supported = true` AND lists `authorization_code` in
19
+ * `capabilities.oauth.grants`. Behavioral probe drives the optional host seam
20
+ * `POST /v1/host/sample/oauth/authorize-code-roundtrip`; a 404 (seam not wired)
21
+ * is a soft-skip — this is a Tier-2 host-pending scenario.
22
+ *
23
+ * Asserts, when the seam is present:
24
+ * 1. The roundtrip succeeds and returns a credential REFERENCE (the token was
25
+ * acquired + persisted as a host.credentials entry), never the token itself.
26
+ * 2. `connector.authorized` carries `{ provider, credentialRef, scopes }` and
27
+ * none of the token / refresh / code / state / redirectUri / codeVerifier.
28
+ * 3. RFC 0047 §C — the authorization code, redirect URI, state, and PKCE
29
+ * verifier MUST NOT appear on ANY run-visible surface; §C.2 — neither MUST
30
+ * the access/refresh token (the canaries are absent from the whole response).
31
+ *
32
+ * @see RFCS/0047-host-oauth-connector-flows.md §C
33
+ * @see conformance/fixtures/oauth-providers/synthetic.json
34
+ * @see SECURITY/invariants.yaml id: credential-payload-redaction
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { driver } from '../lib/driver.js';
39
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
40
+
41
+ interface DiscoveryOAuth {
42
+ supported?: boolean;
43
+ grants?: string[];
44
+ }
45
+
46
+ // Mirrors fixtures/oauth-providers/synthetic.json — keep in sync.
47
+ const SYNTHETIC = {
48
+ provider: 'synthetic',
49
+ authUrl: 'https://oauth.synthetic.openwop.test/authorize',
50
+ tokenUrl: 'https://oauth.synthetic.openwop.test/token',
51
+ scopes: ['openwop.read', 'openwop.write'],
52
+ authorizationCode: 'openwop-synthetic-auth-code-1f4b9e',
53
+ state: 'openwop-synthetic-state-7c2a8d',
54
+ redirectUri: 'https://host.example/openwop/oauth/callback',
55
+ codeVerifier: 'openwop-synthetic-pkce-verifier-3e9f1b2c5a7d4e8f0a1b2c3d4e5f6a7b',
56
+ accessTokenCanary: 'OPENWOP_OAUTH_TOKEN_CANARY_9d4c1f7a',
57
+ refreshTokenCanary: 'OPENWOP_OAUTH_REFRESH_CANARY_2b8e6a3f',
58
+ } as const;
59
+
60
+ // Values that MUST NOT appear on any run-visible surface (RFC 0047 §C + §C.2).
61
+ const SECRET_VALUES: readonly string[] = [
62
+ SYNTHETIC.accessTokenCanary,
63
+ SYNTHETIC.refreshTokenCanary,
64
+ SYNTHETIC.authorizationCode,
65
+ SYNTHETIC.state,
66
+ SYNTHETIC.codeVerifier,
67
+ ];
68
+
69
+ async function readOAuth(): Promise<DiscoveryOAuth | null> {
70
+ const res = await driver.get('/.well-known/openwop');
71
+ return capabilityFamily<DiscoveryOAuth>(res.json, 'oauth') ?? null;
72
+ }
73
+
74
+ describe('oauth-authorization-code-roundtrip: the grant dance (RFC 0047 §C)', () => {
75
+ it('acquires a token via authorization_code and returns a reference, never the token', async () => {
76
+ const oauth = await readOAuth();
77
+ if (!oauth?.supported) return; // capability-gated
78
+ if (!Array.isArray(oauth.grants) || !oauth.grants.includes('authorization_code')) return; // grant-gated
79
+
80
+ // Seam contract: the host performs the full authorization-code roundtrip
81
+ // against the synthetic provider's authUrl/tokenUrl, persists the acquired
82
+ // token as a host.credentials entry, and returns the run-observable surfaces
83
+ // (events incl. connector.authorized + snapshot + any debug bundle) plus the
84
+ // resulting credentialRef.
85
+ const res = await driver.post('/v1/host/sample/oauth/authorize-code-roundtrip', {
86
+ provider: SYNTHETIC.provider,
87
+ authUrl: SYNTHETIC.authUrl,
88
+ tokenUrl: SYNTHETIC.tokenUrl,
89
+ scopes: SYNTHETIC.scopes,
90
+ authorizationCode: SYNTHETIC.authorizationCode,
91
+ state: SYNTHETIC.state,
92
+ redirectUri: SYNTHETIC.redirectUri,
93
+ codeVerifier: SYNTHETIC.codeVerifier,
94
+ accessTokenCanary: SYNTHETIC.accessTokenCanary,
95
+ refreshTokenCanary: SYNTHETIC.refreshTokenCanary,
96
+ });
97
+ // A host that hasn't wired the seam soft-skips (Tier-2, host-pending).
98
+ if (res.status === 404) return;
99
+
100
+ expect(
101
+ res.status,
102
+ driver.describe(
103
+ 'RFC 0047 §C',
104
+ 'the authorize-code-roundtrip seam MUST perform the authorization_code grant against the synthetic provider and return the run observable surfaces',
105
+ ),
106
+ ).toBeLessThan(400);
107
+
108
+ const body = (res.json ?? {}) as { credentialRef?: unknown };
109
+ expect(
110
+ typeof body.credentialRef === 'string' && body.credentialRef.length > 0,
111
+ driver.describe(
112
+ 'RFC 0047 §C',
113
+ 'a successful roundtrip MUST resolve to a credential REFERENCE (token persisted as a host.credentials entry), not the raw token',
114
+ ),
115
+ ).toBe(true);
116
+
117
+ // §C + §C.2 — no secret material anywhere in the observable response.
118
+ const serialized = JSON.stringify(res.json ?? {});
119
+ for (const secret of SECRET_VALUES) {
120
+ expect(
121
+ serialized.includes(secret),
122
+ driver.describe(
123
+ 'RFC 0047 §C / SECURITY/invariants.yaml credential-payload-redaction',
124
+ `the authorization code, state, PKCE verifier, and acquired token material MUST NOT appear on any run-visible surface — leaked: ${secret.slice(0, 16)}…`,
125
+ ),
126
+ ).toBe(false);
127
+ }
128
+
129
+ // §C — connector.authorized carries the reference + scopes, never the token.
130
+ const events = (res.json as { events?: Array<{ type?: string; payload?: Record<string, unknown> }> })?.events;
131
+ if (Array.isArray(events)) {
132
+ const authorized = events.find((e) => e?.type === 'connector.authorized');
133
+ if (authorized?.payload) {
134
+ const keys = Object.keys(authorized.payload);
135
+ expect(
136
+ keys.includes('credentialRef') && !keys.includes('access_token') && !keys.includes('refresh_token'),
137
+ driver.describe(
138
+ 'RFC 0047 §C',
139
+ 'connector.authorized MUST carry { provider, credentialRef, scopes } and MUST NOT carry token material',
140
+ ),
141
+ ).toBe(true);
142
+ }
143
+ }
144
+ });
145
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Pack runtime-requirements install gate — `registry-operations.md`
3
+ * §"Runtime-requirement install gate" + `node-packs.md` §"Runtime platform
4
+ * requirements" (RFC 0076 §A).
5
+ *
6
+ * Seam-gated behavioral scenarios for the install-time gate. A sandbox host MUST
7
+ * evaluate a pack's `runtime.requires[]` against the primitives it will grant
8
+ * and refuse install (`pack_runtime_requirement_unmet`) for any it won't grant —
9
+ * rather than silently installing and failing at first invocation (the
10
+ * `node:dns/promises` trial-load failure that motivated RFC 0076). A non-gating
11
+ * host SHOULD instead project `runtime.requires[]` onto the pack's inventory
12
+ * entry for operator visibility.
13
+ *
14
+ * 1. install-grant — requires ⊆ grant-set ⇒ install succeeds.
15
+ * 2. install-refuse — a required primitive the host won't grant ⇒
16
+ * `pack_runtime_requirement_unmet { unmet, manifest, advice? }`, reusing the
17
+ * `capability_not_provided` envelope shape.
18
+ * 3. non-sandbox projection — a host that does NOT gate platform access
19
+ * installs and projects the declared requires[] for visibility (the §A SHOULD).
20
+ *
21
+ * All three drive `POST /v1/host/sample/packs/install-gate` and soft-skip when
22
+ * the host doesn't wire the seam (404). Behavior grade is `host-pending` until a
23
+ * runtime-requires-gating host (MyndHyve is the first adopter) lights it up.
24
+ *
25
+ * @see spec/v1/registry-operations.md §"Runtime-requirement install gate"
26
+ * @see spec/v1/host-sample-test-seams.md §"Open seams"
27
+ * @see RFCS/0076-pack-runtime-requirements-and-host-safe-fetch.md §A
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { driver } from '../lib/driver.js';
32
+ import { installGate } from '../lib/runtimeRequires.js';
33
+
34
+ function manifest(requires: string[]) {
35
+ return {
36
+ name: 'vendor.example.http',
37
+ version: '1.0.0',
38
+ engines: { openwop: '>=1.1 <2.0.0' },
39
+ runtime: { language: 'javascript', entry: 'index.mjs', requires },
40
+ nodes: [{ typeId: 'vendor.example.http.fetch', version: '1.0.0', category: 'integration', role: 'side-effect' }],
41
+ };
42
+ }
43
+
44
+ describe('runtime-requires install gate (RFC 0076 §A)', () => {
45
+ it('install-grant: requires ⊆ grant-set ⇒ install succeeds', async () => {
46
+ const res = await installGate({ manifest: manifest(['net.dns']), grantSet: ['net.dns', 'net.outbound'] });
47
+ if (res === null) return; // seam absent — soft-skip
48
+ expect(
49
+ res.status,
50
+ driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'a pack whose runtime.requires are all grantable MUST install (no refusal)'),
51
+ ).toBe(200);
52
+ expect(
53
+ res.body.outcome,
54
+ driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'a granted install reports outcome:"installed"'),
55
+ ).toBe('installed');
56
+ });
57
+
58
+ it('install-refuse: an ungrantable primitive ⇒ pack_runtime_requirement_unmet', async () => {
59
+ const res = await installGate({ manifest: manifest(['net.dns']), grantSet: [] });
60
+ if (res === null) return; // seam absent — soft-skip
61
+ expect(
62
+ res.status,
63
+ driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'a pack requiring an ungranted primitive MUST be refused at install (not at first invocation)'),
64
+ ).toBe(400);
65
+ expect(
66
+ res.body.error,
67
+ driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'the refusal MUST carry error code pack_runtime_requirement_unmet'),
68
+ ).toBe('pack_runtime_requirement_unmet');
69
+ expect(
70
+ Array.isArray(res.body.unmet) && (res.body.unmet as unknown[]).includes('net.dns'),
71
+ driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'unmet[] MUST list the ungranted primitive(s) (capability_not_provided envelope)'),
72
+ ).toBe(true);
73
+ expect(
74
+ typeof res.body.manifest === 'string' && (res.body.manifest as string).includes('vendor.example.http'),
75
+ driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'the refusal MUST name the offending manifest (name@version)'),
76
+ ).toBe(true);
77
+ });
78
+
79
+ it('non-sandbox projection: a non-gating host installs and projects requires[] (§A SHOULD)', async () => {
80
+ const res = await installGate({ manifest: manifest(['net.dns', 'net.outbound']), gating: false });
81
+ if (res === null) return; // seam absent — soft-skip
82
+ // A non-gating host installs unconditionally; the SHOULD is the projection.
83
+ // If the host gates anyway (returns 400) the projection SHOULD does not apply — tolerate either install shape.
84
+ if (res.status !== 200) return;
85
+ if (res.body.requiresProjected === undefined) return; // SHOULD, not MUST — a non-projecting host is conformant
86
+ const projected = res.body.requiresProjected as unknown;
87
+ expect(
88
+ Array.isArray(projected) && ['net.dns', 'net.outbound'].every((t) => (projected as unknown[]).includes(t)),
89
+ driver.describe('node-packs.md §"Runtime platform requirements"', 'a non-gating host that projects SHOULD surface the declared runtime.requires[] on the inventory entry verbatim'),
90
+ ).toBe(true);
91
+ });
92
+ });