@openwop/openwop-conformance 2.45.22 → 2.45.24
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 +22 -0
- package/README.md +227 -608
- package/coverage.md +118 -90
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-multi-party-council-oversize.json +244 -0
- package/fixtures/conformance-multi-party-council.json +61 -0
- package/fixtures/conformance-nondeterminism.json +23 -0
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -5
- package/fixtures/frontend-plugin-packs/ui/narrow.html +4 -0
- package/fixtures/frontend-plugin-packs/ui-plugin-pack-narrow.json +16 -0
- package/fixtures/wasm-packs/build.mjs +43 -0
- package/fixtures/wasm-packs/misbehaving-abi/module.wasm +0 -0
- package/fixtures/wasm-packs/misbehaving-abi/module.wat +24 -0
- package/fixtures/wasm-packs/misbehaving-abi/pack.json +27 -0
- package/fixtures/wasm-packs/misbehaving-memory/module.wasm +0 -0
- package/fixtures/wasm-packs/misbehaving-memory/module.wat +30 -0
- package/fixtures/wasm-packs/misbehaving-memory/pack.json +27 -0
- package/fixtures.md +109 -55
- package/package.json +3 -3
- package/requirements.json +319 -75
- package/scenario-majors.json +17 -2
- package/schemas/CORPUS-STAMP.json +32 -30
- package/src/lib/council-roster-witness.ts +144 -0
- package/src/lib/effect-receiver.ts +12 -1
- package/src/lib/ui-plugin-boundary-witness.ts +180 -0
- package/src/lib/wasm-memory-cap-witness.ts +46 -0
- package/src/scenarios/v2-bound-id-path-projection.test.ts +20 -0
- package/src/scenarios/v2-envelope-contracts-advertisement.test.ts +8 -0
- package/src/scenarios/v2-multi-party-council.test.ts +56 -0
- package/src/scenarios/v2-nondeterminism-policy-advertisement.test.ts +11 -1
- package/src/scenarios/v2-nondeterminism-sources.test.ts +83 -0
- package/src/scenarios/v2-purpose-propagation-advertisement.test.ts +1 -1
- package/src/scenarios/v2-purpose-propagation-onward.test.ts +119 -0
- package/src/scenarios/v2-ui-plugin-boundary.test.ts +68 -0
- package/src/scenarios/v2-wasm-memory-cap.test.ts +34 -0
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
* harness (`conformance/run.ts`) overwrites `OPENWOP_API_KEYS` with a `:*` key, so
|
|
39
39
|
* moving the tenant needs that line edited, not just the env var.
|
|
40
40
|
*
|
|
41
|
+
* Since 2.45.23 the leg also mints one run under `OPENWOP_TEST_TENANT_B_API_KEY`
|
|
42
|
+
* when it is set, so a host can bind tenant B to a non-clean tenant and make the
|
|
43
|
+
* leg bite in a production cut without moving the primary key (asked by
|
|
44
|
+
* openwop-app, whose primary production tenant is clean).
|
|
45
|
+
*
|
|
41
46
|
* The codec's own edge cases (marker escaping, UTF-8 vs UTF-16, malformed
|
|
42
47
|
* decode) are a unit concern and live in `src/lib/bound-id.test.ts`, which is
|
|
43
48
|
* sabotage-checked. This file asserts only what needs a HOST to answer.
|
|
@@ -166,5 +171,20 @@ describe('v2 bound-id path projection (identity.md §5)', () => {
|
|
|
166
171
|
runId.includes('~'),
|
|
167
172
|
req(ID_MINT, DOC, `a host MUST NOT mint a tenant-bound id containing ~ (RFC 0184 §A.2) — the created runId is ${runId}. A literal ~ is ambiguous with the path projection's escape marker; map the tenant key to a clean segment instead of projecting it`),
|
|
168
173
|
).toBe(false);
|
|
174
|
+
|
|
175
|
+
// The second-tenant credential, when configured, mints one more run, so a
|
|
176
|
+
// host can put tenant B in a non-clean tenant without moving the primary key
|
|
177
|
+
// every other scenario depends on. A refused create here skips only this half.
|
|
178
|
+
const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
|
|
179
|
+
if (other && other !== process.env['OPENWOP_API_KEY']?.trim()) {
|
|
180
|
+
const createdB = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }, { headers: { Authorization: `Bearer ${other}` } }));
|
|
181
|
+
const runIdB = (createdB?.json as { runId?: unknown } | null | undefined)?.runId;
|
|
182
|
+
if (createdB?.status === 201 && typeof runIdB === 'string') {
|
|
183
|
+
expect(
|
|
184
|
+
runIdB.includes('~'),
|
|
185
|
+
req(ID_MINT, DOC, `a host MUST NOT mint a tenant-bound id containing ~ (RFC 0184 §A.2) — the runId created under the second-tenant credential is ${runIdB}`),
|
|
186
|
+
).toBe(false);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
169
189
|
});
|
|
170
190
|
});
|
|
@@ -37,4 +37,12 @@ describe('v2 envelopeContracts advertisement (events.md §Envelope contracts)',
|
|
|
37
37
|
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
38
38
|
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
it('envelopeContracts.advertised is not false: a host that does not offer the family omits it', async () => {
|
|
42
|
+
const doc = await discovery();
|
|
43
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
44
|
+
const rec = await familyAdvertised('envelopeContracts');
|
|
45
|
+
if (!rec) return softSkip('inapplicable', 'the host does not advertise envelopeContracts');
|
|
46
|
+
expect(rec['advertised'], req('openwop.requirement.0237.no-false-advertisement', 'RFC 0237 §C; capabilities.md §2', 'envelopeContracts.advertised: false is not a v2 state; a host that does not offer the family MUST omit it')).not.toBe(false);
|
|
47
|
+
});
|
|
40
48
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — a council's roster has an input seat and its refusals have codes
|
|
3
|
+
* (RFC 0239; `spec/v2/core/conversation.md` §multiPartyConversation). Gated on
|
|
4
|
+
* `multiPartyConversation` and the advertised council fixtures.
|
|
5
|
+
*
|
|
6
|
+
* The three roster MUSTs were witnessed only by the v1 seam
|
|
7
|
+
* `/v1/host/sample/conversation/multi-party/*`. Here every leg uses normative
|
|
8
|
+
* operations: run creation, the snapshot and event poll, and `resolveInterrupt`.
|
|
9
|
+
*
|
|
10
|
+
* roster-carried `conversation.opened.participants` equals the
|
|
11
|
+
* `core.conversationGate` roster of `conformance-multi-party-council`;
|
|
12
|
+
* speaker-refused resuming the exchange with a non-member `speakerId` is
|
|
13
|
+
* `422 conversation_speaker_not_participant`, unconsumed (the run
|
|
14
|
+
* still waits, no `conversation.exchanged`); a member's turn then
|
|
15
|
+
* resolves it (the control);
|
|
16
|
+
* roster-exceeded with `maxParticipants` below 64, creating a run of
|
|
17
|
+
* `conformance-multi-party-council-oversize` is
|
|
18
|
+
* `422 conversation_roster_exceeded`.
|
|
19
|
+
*
|
|
20
|
+
* The legs live in `lib/council-roster-witness.ts`, proven against a double in
|
|
21
|
+
* `lib/council-roster-witness.test.ts`.
|
|
22
|
+
*
|
|
23
|
+
* Dispositions: no `multiPartyConversation`, or the fixture not advertised ⇒
|
|
24
|
+
* `inapplicable`. roster-exceeded is `inapplicable` when `maxParticipants` is
|
|
25
|
+
* absent or 64 or more (RFC 0239 §Decisions 4).
|
|
26
|
+
*
|
|
27
|
+
* @see RFCS/0239-council-roster-input.md §A–§F
|
|
28
|
+
* @see spec/v2/core/conversation.md §multiPartyConversation
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
33
|
+
import { req } from '../lib/requirement-ids.js';
|
|
34
|
+
import { oversizeLeg, rosterLeg, speakerLeg } from '../lib/council-roster-witness.js';
|
|
35
|
+
|
|
36
|
+
const DOC = 'spec/v2/core/conversation.md §multiPartyConversation';
|
|
37
|
+
|
|
38
|
+
describe('v2 multi-party council (RFC 0239)', () => {
|
|
39
|
+
it('the configured roster is carried on conversation.opened', async () => {
|
|
40
|
+
const out = await rosterLeg();
|
|
41
|
+
if (out.kind === 'skip') return softSkip(out.skip, out.reason);
|
|
42
|
+
expect(out.findings.join('; '), req('openwop.requirement.0239.council.roster-carried', DOC, 'a host MUST accept participants in core.conversationGate config and carry them on conversation.opened')).toBe('');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('a turn from outside the roster is refused and left unconsumed', async () => {
|
|
46
|
+
const out = await speakerLeg();
|
|
47
|
+
if (out.kind === 'skip') return softSkip(out.skip, out.reason);
|
|
48
|
+
expect(out.findings.join('; '), req('openwop.requirement.0239.council.speaker-refused', DOC, 'a host MUST refuse a resumed turn whose speakerId is off the roster with conversation_speaker_not_participant, leaving the interrupt open')).toBe('');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('an oversized roster is refused at run creation', async () => {
|
|
52
|
+
const out = await oversizeLeg();
|
|
53
|
+
if (out.kind === 'skip') return softSkip(out.skip, out.reason);
|
|
54
|
+
expect(out.findings.join('; '), req('openwop.requirement.0239.council.roster-exceeded', DOC, 'a host MUST refuse at run creation, never truncate, a roster over maxParticipants, with conversation_roster_exceeded')).toBe('');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* record the record validates against its capabilities-schema seat;
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* no-false `declared: false` is not a v2 state (RFC 0237 §C);
|
|
9
|
+
*
|
|
10
|
+
* Not here: the replay of each declared source is `v2-nondeterminism-sources`.
|
|
9
11
|
*
|
|
10
12
|
* Dispositions: discovery unreadable ⇒ `blocked`; `nondeterminismPolicy` absent, or the
|
|
11
13
|
* facet a leg reads absent ⇒ `inapplicable`.
|
|
@@ -38,4 +40,12 @@ describe('v2 nondeterminismPolicy advertisement (replay.md §Declared nondetermi
|
|
|
38
40
|
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
39
41
|
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
40
42
|
});
|
|
43
|
+
|
|
44
|
+
it('nondeterminismPolicy.declared is not false: a host that does not offer the family omits it', async () => {
|
|
45
|
+
const doc = await discovery();
|
|
46
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
47
|
+
const rec = await familyAdvertised('nondeterminismPolicy');
|
|
48
|
+
if (!rec) return softSkip('inapplicable', 'the host does not advertise nondeterminismPolicy');
|
|
49
|
+
expect(rec['declared'], req('openwop.requirement.0237.no-false-advertisement', 'RFC 0237 §C; capabilities.md §2', 'nondeterminismPolicy.declared: false is not a v2 state; a host that does not offer the family MUST omit it')).not.toBe(false);
|
|
50
|
+
});
|
|
41
51
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — declared nondeterminism replays its recorded values (RFC 0237 §B;
|
|
3
|
+
* `spec/v2/core/replay.md` §Declared nondeterminism; suite 2.45.23, target
|
|
4
|
+
* major 2; fixture-gated).
|
|
5
|
+
*
|
|
6
|
+
* A host listing `nondeterminismPolicy.sources` MUST record each value drawn
|
|
7
|
+
* from a listed source where it is read, and a `replay` fork MUST reproduce it
|
|
8
|
+
* rather than draw again. The log entry itself is host-internal; what a client
|
|
9
|
+
* sees is the fork's output. So the leg runs `conformance-nondeterminism` (one
|
|
10
|
+
* conformance-reserved node that writes one value per listed source to the run
|
|
11
|
+
* outputs), forks it in `replay` mode from `fromSeq: 0`, and requires each listed
|
|
12
|
+
* source's output to be byte-equal.
|
|
13
|
+
*
|
|
14
|
+
* 0237.declared-source-replays for every listed source, the fork's output
|
|
15
|
+
* equals the source run's.
|
|
16
|
+
*
|
|
17
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `nondeterminismPolicy.sources`
|
|
18
|
+
* absent, the fixture not advertised, or `replay` mode not served ⇒
|
|
19
|
+
* `inapplicable`; a create or fork the host refuses ⇒ `blocked`.
|
|
20
|
+
*
|
|
21
|
+
* @see RFCS/0237-nondeterminism-sources.md §B, §D
|
|
22
|
+
* @see spec/v2/core/replay.md §Declared nondeterminism
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
27
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
28
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
29
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
30
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
31
|
+
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
33
|
+
|
|
34
|
+
const DOC = 'spec/v2/core/replay.md §Declared nondeterminism';
|
|
35
|
+
const ID = 'openwop.requirement.0237.declared-source-replays';
|
|
36
|
+
const FIXTURE = 'conformance-nondeterminism';
|
|
37
|
+
|
|
38
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
39
|
+
try { return await fn(); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `run.completed.outputs`, read from the run's event log. */
|
|
43
|
+
async function outputsOf(runId: string): Promise<Record<string, unknown> | null> {
|
|
44
|
+
const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
45
|
+
const events = (r?.json as { events?: Array<{ type?: string; payload?: { outputs?: unknown } }> } | null)?.events ?? [];
|
|
46
|
+
const done = events.find((e) => e.type === 'run.completed');
|
|
47
|
+
const out = done?.payload?.outputs;
|
|
48
|
+
return out !== null && typeof out === 'object' ? (out as Record<string, unknown>) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('v2 declared nondeterminism replays (RFC 0237 §B — fixture-gated)', () => {
|
|
52
|
+
it('a replay fork reproduces every value drawn from a listed source', async () => {
|
|
53
|
+
let doc: Record<string, unknown> | null;
|
|
54
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
55
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
56
|
+
const policy = await familyAdvertised('nondeterminismPolicy');
|
|
57
|
+
const sources = Array.isArray(policy?.['sources']) ? (policy['sources'] as unknown[]).filter((s): s is string => typeof s === 'string') : [];
|
|
58
|
+
if (sources.length === 0) return softSkip('inapplicable', 'the host lists no nondeterminismPolicy.sources');
|
|
59
|
+
if (!isFixtureAdvertised(FIXTURE)) return softSkip('inapplicable', `the host does not advertise the ${FIXTURE} fixture`);
|
|
60
|
+
const modes = (await familyAdvertised('replay'))?.['modes'];
|
|
61
|
+
if (!Array.isArray(modes) || !modes.includes('replay')) return softSkip('inapplicable', 'the host does not serve replay-mode forks');
|
|
62
|
+
|
|
63
|
+
const created = await http(() => driver.post('/runs', { workflowId: FIXTURE }));
|
|
64
|
+
const runId = (created?.json as { runId?: unknown } | null | undefined)?.runId;
|
|
65
|
+
if (created?.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs {workflowId: ${FIXTURE}} answered ${created?.status ?? 'no response'} ${readErrorCode(created?.json) ?? ''}`.trim());
|
|
66
|
+
const source = await pollUntilTerminal(runId, { timeoutMs: 20_000 });
|
|
67
|
+
if (source.status !== 'completed') return softSkip('blocked', `the ${FIXTURE} run ended ${source.status}, not completed`);
|
|
68
|
+
const first = await outputsOf(runId);
|
|
69
|
+
expect(first !== null, req(ID, DOC, `run.completed MUST carry outputs for ${FIXTURE}`)).toBe(true);
|
|
70
|
+
|
|
71
|
+
const fork = await http(() => driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq: 0 }));
|
|
72
|
+
const forkId = (fork?.json as { runId?: unknown } | null | undefined)?.runId;
|
|
73
|
+
if (fork === null || fork.status >= 300 || typeof forkId !== 'string') return softSkip('blocked', `the replay fork answered ${fork?.status ?? 'no response'} ${readErrorCode(fork?.json) ?? ''}`.trim());
|
|
74
|
+
const forked = await pollUntilTerminal(forkId, { timeoutMs: 20_000 });
|
|
75
|
+
if (forked.status !== 'completed') return softSkip('blocked', `the replay fork ended ${forked.status}, not completed`);
|
|
76
|
+
const second = await outputsOf(forkId);
|
|
77
|
+
|
|
78
|
+
for (const s of sources) {
|
|
79
|
+
expect(typeof first?.[s], req(ID, DOC, `the ${FIXTURE} outputs MUST carry a value for the listed source ${s} (got ${JSON.stringify(first)})`)).toBe('string');
|
|
80
|
+
expect(second?.[s], req(ID, DOC, `a replay fork MUST reproduce the value drawn from the listed source ${s}: the source run drew ${JSON.stringify(first?.[s])}, the fork ${JSON.stringify(second?.[s])}`)).toBe(first?.[s]);
|
|
81
|
+
}
|
|
82
|
+
}, 60_000);
|
|
83
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* record the record validates against its capabilities-schema seat;
|
|
7
7
|
* propagates-onward-boolean purposePropagation.propagatesOnward is a boolean;
|
|
8
8
|
*
|
|
9
|
-
* Not here: propagation itself
|
|
9
|
+
* Not here: propagation itself is `v2-purpose-propagation-onward`, which observes the host's onward A2A hop through the §22 seam.
|
|
10
10
|
*
|
|
11
11
|
* Dispositions: discovery unreadable ⇒ `blocked`; `purposePropagation` absent, or the
|
|
12
12
|
* facet a leg reads absent ⇒ `inapplicable`.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — purpose labels on the onward hop (`spec/v2/core/security-defaults.md`
|
|
3
|
+
* §Onward hops; suite 2.45.23, target major 2; seam-gated).
|
|
4
|
+
*
|
|
5
|
+
* A host advertising `purposePropagation` MUST re-emit a `permittedPurposes`
|
|
6
|
+
* label it received on every onward hop of the same data, narrowing and never
|
|
7
|
+
* widening, and MUST treat `[]` as no onward use. The v1 twin read a
|
|
8
|
+
* self-reported forward seam; this file observes a real onward hop instead.
|
|
9
|
+
* The suite plays both ends: it hands the host a label through the §22 `invoke`
|
|
10
|
+
* seam (`permittedPurposes`), and its fake A2A peer receives what the host's
|
|
11
|
+
* production A2A client actually sends.
|
|
12
|
+
*
|
|
13
|
+
* onward-reemit-no-widen the onward message carries
|
|
14
|
+
* `metadata.openwop.permittedPurposes`, a subset of
|
|
15
|
+
* the label sent;
|
|
16
|
+
* empty-no-onward with `[]` the host makes no onward call, while an
|
|
17
|
+
* unlabelled control call through the same seam
|
|
18
|
+
* reaches the peer (non-arrival is evidence, not a
|
|
19
|
+
* dead pipe).
|
|
20
|
+
*
|
|
21
|
+
* Not here: the derived-output rule (a merge of labelled inputs carries their
|
|
22
|
+
* intersection) needs two inputs on one hop; the seam forwards one.
|
|
23
|
+
*
|
|
24
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `purposePropagation` absent,
|
|
25
|
+
* `propagatesOnward` false, `a2a` absent or no seams profile ⇒ `inapplicable`;
|
|
26
|
+
* seam unwired ⇒ `seamAbsent`.
|
|
27
|
+
*
|
|
28
|
+
* @see spec/v2/core/security-defaults.md §Onward hops
|
|
29
|
+
* @see spec/v1/host-sample-test-seams.md §22
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
34
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
35
|
+
import { SEAMS_PREFIX, seamsProfileAdvertised } from '../lib/seams.js';
|
|
36
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
37
|
+
import { req } from '../lib/requirement-ids.js';
|
|
38
|
+
import { A2AFakePeer, type A2APeerInvocation } from '../lib/a2a-fake-peer.js';
|
|
39
|
+
|
|
40
|
+
export const REQUIRES_HOST_CALLBACK = "the host's A2A client calls the suite's fake A2A peer";
|
|
41
|
+
|
|
42
|
+
const DOC = 'spec/v2/core/security-defaults.md §Onward hops';
|
|
43
|
+
const ID_REEMIT = 'openwop.requirement.purpose-propagation.onward-reemit-no-widen';
|
|
44
|
+
const ID_EMPTY = 'openwop.requirement.purpose-propagation.empty-no-onward';
|
|
45
|
+
const SEAM = `${SEAMS_PREFIX}/sample/a2a/invoke`;
|
|
46
|
+
const LABEL = ['openwop-conformance-analytics', 'openwop-conformance-support'];
|
|
47
|
+
|
|
48
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
49
|
+
try { return await fn(); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Skip = { kind: 'blocked' | 'inapplicable' | 'seam'; reason: string };
|
|
53
|
+
const skip = (s: Skip): undefined => (s.kind === 'seam' ? seamAbsent(s.reason) : softSkip(s.kind, s.reason));
|
|
54
|
+
|
|
55
|
+
async function gate(): Promise<Skip | null> {
|
|
56
|
+
let doc: Record<string, unknown> | null;
|
|
57
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
58
|
+
if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
59
|
+
const pp = await familyAdvertised('purposePropagation');
|
|
60
|
+
if (pp === null) return { kind: 'inapplicable', reason: 'the host does not advertise purposePropagation' };
|
|
61
|
+
if (pp['propagatesOnward'] === false) return { kind: 'inapplicable', reason: 'purposePropagation.propagatesOnward is false — the host has no onward hop' };
|
|
62
|
+
if (!(await familyAdvertised('a2a'))) return { kind: 'inapplicable', reason: 'a2a not advertised — the onward hop observed here is the host A2A client' };
|
|
63
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'the onward hop is driven through the §22 invoke seam — the seams profile is not advertised' };
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The message sends the peer received. */
|
|
68
|
+
const sends = (peer: A2AFakePeer): A2APeerInvocation[] => peer.invocations().filter((i) => i.rpcMethod === 'SendMessage' || i.rpcMethod === 'message/send');
|
|
69
|
+
|
|
70
|
+
function labelOf(inv: A2APeerInvocation): unknown {
|
|
71
|
+
const body = inv.body as { params?: { message?: { metadata?: { openwop?: { permittedPurposes?: unknown } } } } } | undefined;
|
|
72
|
+
return body?.params?.message?.metadata?.openwop?.permittedPurposes;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function invoke(peer: A2AFakePeer, permittedPurposes?: string[]): Promise<OpenWOPResponse | Skip> {
|
|
76
|
+
const res = await http(() => driver.post(SEAM, { peerUrl: peer.hostFacingEndpoint(), ...(permittedPurposes ? { permittedPurposes } : {}) }));
|
|
77
|
+
if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
|
|
78
|
+
if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — host-sample-test-seams.md §22` };
|
|
79
|
+
return res;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('v2 purpose labels on the onward hop (security-defaults.md §Onward hops — seam-gated)', () => {
|
|
83
|
+
it('a received label is re-emitted on the onward message, never widened', async () => {
|
|
84
|
+
const g = await gate();
|
|
85
|
+
if (g) return skip(g);
|
|
86
|
+
const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
|
|
87
|
+
await peer.start();
|
|
88
|
+
try {
|
|
89
|
+
const res = await invoke(peer, LABEL);
|
|
90
|
+
if (!('status' in res)) return skip(res);
|
|
91
|
+
const sent = sends(peer)[0];
|
|
92
|
+
if (sent === undefined) return softSkip('blocked', `the seam answered ${res.status} and the suite peer received no message — the host's A2A client never reached it`);
|
|
93
|
+
const label = labelOf(sent);
|
|
94
|
+
expect(Array.isArray(label), req(ID_REEMIT, DOC, `the onward message MUST carry the received label as metadata.openwop.permittedPurposes (got ${JSON.stringify(label)})`)).toBe(true);
|
|
95
|
+
const widened = (Array.isArray(label) ? label : []).filter((p) => !LABEL.includes(String(p)));
|
|
96
|
+
expect(widened, req(ID_REEMIT, DOC, `the onward label MUST NOT widen the received one (${JSON.stringify(LABEL)}); it added ${JSON.stringify(widened)}`)).toEqual([]);
|
|
97
|
+
} finally {
|
|
98
|
+
await peer.stop();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('an empty label means no onward call, while an unlabelled control call goes through', async () => {
|
|
103
|
+
const g = await gate();
|
|
104
|
+
if (g) return skip(g);
|
|
105
|
+
const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
|
|
106
|
+
await peer.start();
|
|
107
|
+
try {
|
|
108
|
+
const control = await invoke(peer);
|
|
109
|
+
if (!('status' in control)) return skip(control);
|
|
110
|
+
const before = sends(peer).length;
|
|
111
|
+
if (before === 0) return softSkip('blocked', `the unlabelled control call answered ${control.status} but reached no peer, so a missing call under [] would prove nothing`);
|
|
112
|
+
const res = await invoke(peer, []);
|
|
113
|
+
if (!('status' in res)) return skip(res);
|
|
114
|
+
expect(sends(peer).length - before, req(ID_EMPTY, DOC, `a host MUST treat [] as no onward use: the seam answered ${res.status}, and the peer received ${sends(peer).length - before} message(s) for the []-labelled data`)).toBe(0);
|
|
115
|
+
} finally {
|
|
116
|
+
await peer.stop();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the front-end plugin boundary is observable (RFC 0238; `spec/v2/core/packs.md`
|
|
3
|
+
* §Front-end plugin packs). Gated on `uiPlugins.served`.
|
|
4
|
+
*
|
|
5
|
+
* The four `frontend-plugin-*` SECURITY invariants were witnessed only by the v1
|
|
6
|
+
* sample seam `POST /v1/host/sample/ui-plugin/rpc`; v2 mounts no plugin seam. This
|
|
7
|
+
* scenario reads the two normative operations instead, against the operator-installed
|
|
8
|
+
* `ui-plugin-pack-narrow` fixture (one plugin, `narrow`, declaring only
|
|
9
|
+
* `artifact.read` and no `connectSrc`):
|
|
10
|
+
*
|
|
11
|
+
* frame-isolated the frame's Content-Security-Policy `sandbox` admits
|
|
12
|
+
* `allow-scripts` and never `allow-same-origin`
|
|
13
|
+
* (`cross-origin-iframe` hosts only);
|
|
14
|
+
* frame-deny-egress the same policy sets `default-src 'none'` and admits no
|
|
15
|
+
* `connect-src` source;
|
|
16
|
+
* rpc-allowlist `artifact.write` and `host.navigate` (in the enum, not
|
|
17
|
+
* declared) return `method_not_allowed`; `host.exec` (outside
|
|
18
|
+
* the enum) is not executed; `artifact.read` succeeds (control);
|
|
19
|
+
* no-byok no dispatch response carries canary material.
|
|
20
|
+
*
|
|
21
|
+
* The legs live in `lib/ui-plugin-boundary-witness.ts`, proven against a double in
|
|
22
|
+
* `lib/ui-plugin-boundary-witness.test.ts`.
|
|
23
|
+
*
|
|
24
|
+
* Dispositions: no `uiPlugins.served` ⇒ every leg `inapplicable`. The fixture not
|
|
25
|
+
* installed (its frame or dispatch answers 404) ⇒ `inapplicable`: installing it is the
|
|
26
|
+
* operator's choice (RFC 0238 §D). An isolation other than `cross-origin-iframe` ⇒ the
|
|
27
|
+
* isolation leg `inapplicable` (RFC 0238 §Decisions 3).
|
|
28
|
+
*
|
|
29
|
+
* @see RFCS/0238-ui-plugin-observation-path.md §B, §C, §F
|
|
30
|
+
* @see spec/v2/core/packs.md §Front-end plugin packs
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
+
import { req } from '../lib/requirement-ids.js';
|
|
36
|
+
import { allowlistLeg, egressLeg, isolationLeg, noByokLeg } from '../lib/ui-plugin-boundary-witness.js';
|
|
37
|
+
|
|
38
|
+
const DOC = 'spec/v2/core/packs.md §Front-end plugin packs';
|
|
39
|
+
const ID_ISOLATED = 'openwop.requirement.0238.ui-plugin.frame-isolated';
|
|
40
|
+
const ID_EGRESS = 'openwop.requirement.0238.ui-plugin.frame-deny-egress';
|
|
41
|
+
const ID_ALLOWLIST = 'openwop.requirement.0238.ui-plugin.rpc-allowlist';
|
|
42
|
+
const ID_NO_BYOK = 'openwop.requirement.0238.ui-plugin.no-byok';
|
|
43
|
+
|
|
44
|
+
describe('v2 ui-plugin boundary (RFC 0238)', () => {
|
|
45
|
+
it('the frame is sandboxed without same-origin', async () => {
|
|
46
|
+
const out = await isolationLeg();
|
|
47
|
+
if (out.kind === 'skip') return softSkip(out.skip, out.reason);
|
|
48
|
+
expect(out.findings.join('; '), req(ID_ISOLATED, DOC, 'a cross-origin-iframe plugin frame MUST carry a sandbox directive admitting allow-scripts and never allow-same-origin')).toBe('');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('the frame denies egress', async () => {
|
|
52
|
+
const out = await egressLeg();
|
|
53
|
+
if (out.kind === 'skip') return softSkip(out.skip, out.reason);
|
|
54
|
+
expect(out.findings.join('; '), req(ID_EGRESS, DOC, "the frame MUST set default-src 'none' and admit no connect-src source the plugin did not declare")).toBe('');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('the dispatch refuses methods outside the allowlists and runs the declared one', async () => {
|
|
58
|
+
const out = await allowlistLeg();
|
|
59
|
+
if (out.kind === 'skip') return softSkip(out.skip, out.reason);
|
|
60
|
+
expect(out.findings.join('; '), req(ID_ALLOWLIST, DOC, "a method outside the plugin's declared hostApi or the advertised uiPlugins.hostApi MUST return method_not_allowed and MUST NOT execute")).toBe('');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('no dispatch response carries credential material', async () => {
|
|
64
|
+
const out = await noByokLeg();
|
|
65
|
+
if (out.kind === 'skip') return softSkip(out.skip, out.reason);
|
|
66
|
+
expect(out.findings.join('; '), req(ID_NO_BYOK, DOC, 'a ui-plugin/1 response MUST NOT carry credential material across the boundary')).toBe('');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — a host enforcing its advertised WASM memory ceiling
|
|
3
|
+
* (`spec/v2/core/node-pack-runtimes.md` §WASM: "When it advertises
|
|
4
|
+
* `maxMemoryBytes` it MUST enforce it and emit `cap.breached` with
|
|
5
|
+
* `kind: "wasm-memory"` on a breach").
|
|
6
|
+
*
|
|
7
|
+
* The v1 twin, `wasm-pack-memory-cap`, runs at major 1 only and needed a
|
|
8
|
+
* Rust-built pack. Here the operator installs the hand-written fixture pack at
|
|
9
|
+
* `conformance/fixtures/wasm-packs/misbehaving-memory/` (`vendor.openwop.misbehaving`,
|
|
10
|
+
* node `vendor.openwop.misbehaving.memory-bomb`, which grows memory until the
|
|
11
|
+
* host refuses) and advertises `conformance-wasm-pack-memory-cap-breach`.
|
|
12
|
+
*
|
|
13
|
+
* The leg lives in `lib/wasm-memory-cap-witness.ts`, proven against a double in
|
|
14
|
+
* `lib/wasm-memory-cap-witness.test.ts`.
|
|
15
|
+
*
|
|
16
|
+
* Dispositions: no `nodePackRuntimes.wasm`, no `maxMemoryBytes`, or the fixture
|
|
17
|
+
* not advertised ⇒ `inapplicable`.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/v2/core/node-pack-runtimes.md §WASM
|
|
20
|
+
* @see RFCS/0008-wasm-abi.md §K
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
import { memoryCapLeg } from '../lib/wasm-memory-cap-witness.js';
|
|
27
|
+
|
|
28
|
+
describe('v2 WASM memory ceiling (node-pack-runtimes.md §WASM)', () => {
|
|
29
|
+
it('a module exceeding maxMemoryBytes produces cap.breached wasm-memory and fails the run', async () => {
|
|
30
|
+
const out = await memoryCapLeg();
|
|
31
|
+
if (out.kind === 'skip') return softSkip(out.skip, out.reason);
|
|
32
|
+
expect(out.findings.join('; '), req('openwop.requirement.node-pack-runtimes.wasm-memory-cap', 'spec/v2/core/node-pack-runtimes.md §WASM', 'a host advertising maxMemoryBytes MUST enforce it and emit cap.breached with kind wasm-memory on a breach')).toBe('');
|
|
33
|
+
});
|
|
34
|
+
});
|