@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.
- package/CHANGELOG.md +34 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +70 -0
- package/api/openapi.yaml +268 -1
- package/coverage.md +30 -2
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +10 -0
- package/package.json +1 -1
- package/schemas/README.md +12 -0
- package/schemas/agent-deployment-transition.schema.json +49 -0
- package/schemas/agent-deployment.schema.json +54 -0
- package/schemas/agent-eval-suite.schema.json +140 -0
- package/schemas/agent-inventory-response.schema.json +25 -0
- package/schemas/agent-manifest.schema.json +5 -0
- package/schemas/agent-org-chart.schema.json +82 -0
- package/schemas/agent-ref.schema.json +12 -2
- package/schemas/agent-roster-entry.schema.json +81 -0
- package/schemas/agent-roster-response.schema.json +21 -0
- package/schemas/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +277 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/node-pack-manifest.schema.json +17 -0
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +286 -3
- package/schemas/run-event.schema.json +19 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/profiles.ts +157 -0
- package/src/lib/runtimeRequires.ts +38 -0
- package/src/lib/safeFetch.ts +87 -0
- package/src/scenarios/agent-deployment-shape.test.ts +139 -0
- package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
- package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
- package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
- package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
- package/src/scenarios/agent-live-structured-output.test.ts +58 -0
- package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
- package/src/scenarios/agent-platform-profile.test.ts +158 -0
- package/src/scenarios/agent-roster-attribution.test.ts +179 -0
- package/src/scenarios/agent-roster-shape.test.ts +146 -0
- package/src/scenarios/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
- package/src/scenarios/runtime-requires-shape.test.ts +134 -0
- package/src/scenarios/safefetch-behavior.test.ts +99 -0
- package/src/scenarios/safefetch-live-audit.test.ts +175 -0
- package/src/scenarios/spec-corpus-validity.test.ts +19 -3
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- 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
|
+
});
|