@openwop/openwop-conformance 2.42.9 → 2.43.1
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 +29 -0
- package/README.md +3 -3
- package/coverage.md +1 -1
- package/dist/cli.js +15 -12
- package/dist/lib/certification-bundle-v3.js +19 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-approval-reject-loopback.json +33 -0
- package/fixtures/conformance-approval-reject-routed.json +32 -0
- package/fixtures/conformance-approval-timeout-approve.json +46 -0
- package/fixtures/conformance-approval-timeout.json +28 -0
- package/fixtures.md +59 -2
- package/package.json +2 -2
- package/requirements.json +535 -38
- package/scenario-majors.json +20 -5
- package/schemas/CORPUS-STAMP.json +55 -54
- package/src/cli.ts +15 -12
- package/src/lib/auditIntegrity.ts +45 -0
- package/src/lib/certification-bundle-v3.ts +25 -0
- package/src/lib/standard-webhooks.ts +53 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +63 -0
- package/src/scenarios/audit-anomaly-shape.test.ts +100 -0
- package/src/scenarios/audit-checkpoint-signature.test.ts +14 -15
- package/src/scenarios/audit-log-integrity.test.ts +102 -46
- package/src/scenarios/byok-roundtrip.test.ts +21 -7
- package/src/scenarios/v2-agent-org-chart-served-shape.test.ts +63 -0
- package/src/scenarios/v2-approval-reject-disposition.test.ts +262 -0
- package/src/scenarios/v2-error-registry.test.ts +59 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +21 -9
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +23 -7
- package/src/scenarios/v2-pack-isolation.test.ts +10 -6
- package/src/scenarios/v2-peer-dependency-declared.test.ts +18 -6
- package/src/scenarios/v2-registry-lifecycle.test.ts +232 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +28 -11
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0223 — a rejected approval gate fails closed, and the failure is
|
|
3
|
+
* routable (`spec/v2/core/interrupt.md` §Rejection; suite 2.43.0, target
|
|
4
|
+
* major 2; gated on the `interrupt` family).
|
|
5
|
+
*
|
|
6
|
+
* Legs:
|
|
7
|
+
* 1. `conformance-approval` (one gate, no edges) resolved `reject` ⇒ the run
|
|
8
|
+
* terminates `failed`, and both the snapshot `error.code` and
|
|
9
|
+
* `run.failed.error.code` are `approval_rejected`;
|
|
10
|
+
* 2. the same run's `interrupt.resolved` records `action: "reject"` and
|
|
11
|
+
* `decision: "rejected"` (the `action` half is interrupt.md §Events, RFC
|
|
12
|
+
* 0183; RFC 0223 names both for a reject);
|
|
13
|
+
* 3. the gate's `node.failed` carries `approval_rejected` with
|
|
14
|
+
* `retryable: false`, and `run.failed.failedNodeId` names the gate;
|
|
15
|
+
* 4. `conformance-interrupt-quorum` (requiredApprovals 3, `majority`): one
|
|
16
|
+
* reject leaves the run `waiting-approval`; a second decides the gate and
|
|
17
|
+
* the run terminates `failed` with `approval_rejected`;
|
|
18
|
+
* 5. `conformance-approval-reject-routed` (gap G1, suite 2.43.1): rejected, the
|
|
19
|
+
* gate fails with `approval_rejected` / `retryable: false`, its
|
|
20
|
+
* `any_failed` target runs, its `all_success` target never runs, and the
|
|
21
|
+
* run completes;
|
|
22
|
+
* 6. `conformance-approval-timeout` (gap G2, suite 2.43.1): left unresolved
|
|
23
|
+
* past its 1500 ms `timeoutMs` with no `onTimeout`, the host resolves the
|
|
24
|
+
* gate itself (`action: timeout`, `decision: rejected`, `reason: timeout`),
|
|
25
|
+
* the gate fails with `approval_rejected` and `run.failed` names it;
|
|
26
|
+
* 7. `conformance-approval-timeout-approve` (openwop#1696, suite 2.43.1): the
|
|
27
|
+
* same gate with `onTimeout: "approve"` still resolves rejected — a
|
|
28
|
+
* timeout never grants — and the run fails, never completes;
|
|
29
|
+
* 8. `conformance-approval-reject-loopback` (openwop#1697, suite 2.43.1): a
|
|
30
|
+
* rejected gate routed back to itself through `revise` is asked again —
|
|
31
|
+
* a second `interrupt.requested` with a different `key`, the run back in
|
|
32
|
+
* `waiting-approval`, and no second `interrupt.resolved` (the first
|
|
33
|
+
* rejection is not replayed). `inapplicable` on a host that does not
|
|
34
|
+
* advertise the fixture, i.e. one that does not run cycles.
|
|
35
|
+
*
|
|
36
|
+
* The legs are separate rows so a host that misses one field fails that row
|
|
37
|
+
* only. Legs 5 and 6 each need their own fixture, because `conformance-approval`
|
|
38
|
+
* has no edges and no timer and widening a fixture every host serves would force
|
|
39
|
+
* a re-registration (fixtures.md §conformance-approval-refine); a host that
|
|
40
|
+
* does not advertise one records `inapplicable`. Leg 5 does not require
|
|
41
|
+
* `node.skipped`: interrupt.md says the `all_success` edge is not satisfied, not
|
|
42
|
+
* how a host records that, so the leg asserts only that the target never ran.
|
|
43
|
+
*
|
|
44
|
+
* The quorum leg presents distinct votes on one bearer through the resume
|
|
45
|
+
* value's `voter` member, as `interrupt-quorum-resolution` does at major 1. A
|
|
46
|
+
* host that counts both votes as one principal cannot be driven to a majority
|
|
47
|
+
* this way; that records `blocked`, not a failure.
|
|
48
|
+
*
|
|
49
|
+
* @see spec/v2/core/interrupt.md §Rejection
|
|
50
|
+
* @see spec/v2/errors.json approval_rejected
|
|
51
|
+
* @see RFCS/0223-approval-reject-disposition.md
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { describe, it, expect } from 'vitest';
|
|
55
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
56
|
+
import { gateFamily } from '../lib/v2.js';
|
|
57
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
58
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
59
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
60
|
+
import { req } from '../lib/requirement-ids.js';
|
|
61
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
62
|
+
|
|
63
|
+
const DOC = 'spec/v2/core/interrupt.md §Rejection (RFC 0223)';
|
|
64
|
+
const R = (slug: string): string => `openwop.requirement.0223.${slug}`;
|
|
65
|
+
const FIXTURE = 'conformance-approval';
|
|
66
|
+
const QUORUM = 'conformance-interrupt-quorum';
|
|
67
|
+
const ROUTED = 'conformance-approval-reject-routed';
|
|
68
|
+
const TIMEOUT = 'conformance-approval-timeout';
|
|
69
|
+
const TIMEOUT_APPROVE = 'conformance-approval-timeout-approve';
|
|
70
|
+
const LOOPBACK = 'conformance-approval-reject-loopback';
|
|
71
|
+
const NODE_ID = 'gate';
|
|
72
|
+
const CODE = 'approval_rejected';
|
|
73
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
74
|
+
|
|
75
|
+
interface Ev { type?: string; nodeId?: string; payload?: Record<string, unknown> }
|
|
76
|
+
|
|
77
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
78
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
79
|
+
|
|
80
|
+
async function snapshot(runId: string): Promise<Record<string, unknown> | null> {
|
|
81
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
82
|
+
return res?.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : null;
|
|
83
|
+
}
|
|
84
|
+
async function waitStatus(runId: string, wanted: ReadonlySet<string>, timeoutMs: number): Promise<Record<string, unknown> | null> {
|
|
85
|
+
const deadline = Date.now() + timeoutMs;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const s = await snapshot(runId);
|
|
88
|
+
if (s !== null && wanted.has(String(s['status']))) return s;
|
|
89
|
+
if (Date.now() > deadline) return s;
|
|
90
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function events(runId: string): Promise<Ev[]> {
|
|
94
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
|
|
95
|
+
return ((res?.json as { events?: Ev[] } | null)?.events ?? []);
|
|
96
|
+
}
|
|
97
|
+
const errorCode = (o: unknown): string | undefined => (o as { code?: unknown } | null | undefined)?.code as string | undefined;
|
|
98
|
+
const resolve = (runId: string, resumeValue: unknown): Promise<OpenWOPResponse | null> => http(() => driver.post(`/runs/${enc(runId)}/interrupts/${enc(NODE_ID)}`, { resumeValue }));
|
|
99
|
+
|
|
100
|
+
/** Create a run of `fixture` and wait for it to suspend on its gate. */
|
|
101
|
+
async function suspended(fixture: string): Promise<{ runId: string } | { reason: string }> {
|
|
102
|
+
const res = await http(() => driver.post('/runs', { workflowId: fixture }));
|
|
103
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
104
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
105
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${fixture}) answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
|
|
106
|
+
const s = await waitStatus(runId, new Set(['waiting-approval', ...TERMINAL]), 10_000);
|
|
107
|
+
if (s?.['status'] !== 'waiting-approval') return { reason: `the ${fixture} run did not suspend on its approval gate (status ${String(s?.['status'])})` };
|
|
108
|
+
return { runId };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One rejected `conformance-approval` run per file: legs 1–3 read the same run. */
|
|
112
|
+
let rejected: Promise<{ runId: string; terminal: Record<string, unknown> | null; evs: Ev[] } | { reason: string }> | undefined;
|
|
113
|
+
function rejectedRun(): Promise<{ runId: string; terminal: Record<string, unknown> | null; evs: Ev[] } | { reason: string }> {
|
|
114
|
+
rejected ??= (async () => {
|
|
115
|
+
const s = await suspended(FIXTURE);
|
|
116
|
+
if ('reason' in s) return s;
|
|
117
|
+
const res = await resolve(s.runId, { action: 'reject' });
|
|
118
|
+
if (res === null || res.status < 200 || res.status >= 300) return { reason: `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract` };
|
|
119
|
+
const terminal = await waitStatus(s.runId, TERMINAL, 10_000);
|
|
120
|
+
return { runId: s.runId, terminal, evs: await events(s.runId) };
|
|
121
|
+
})();
|
|
122
|
+
return rejected;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('RFC 0223 — v2-approval-reject-disposition (gated on interrupt + conformance-approval)', () => {
|
|
126
|
+
it('a rejected gate with no routing edge fails the run with approval_rejected', async () => {
|
|
127
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
128
|
+
const r = await rejectedRun();
|
|
129
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
130
|
+
const id = R('reject-fails-run');
|
|
131
|
+
expect(r.terminal?.['status'], req(id, DOC, `with no edge admitting a failed source the run MUST terminate failed (got ${String(r.terminal?.['status'])})`)).toBe('failed');
|
|
132
|
+
expect(errorCode(r.terminal?.['error']), req(id, `${DOC}; runs.md §Snapshot`, 'the terminal snapshot error.code MUST be approval_rejected')).toBe(CODE);
|
|
133
|
+
const failed = r.evs.find((e) => e.type === 'run.failed');
|
|
134
|
+
expect(errorCode(failed?.payload?.['error']), req(id, DOC, 'run.failed.error.code MUST be approval_rejected')).toBe(CODE);
|
|
135
|
+
}, 45_000);
|
|
136
|
+
|
|
137
|
+
it('interrupt.resolved records action reject and decision rejected', async () => {
|
|
138
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
139
|
+
const r = await rejectedRun();
|
|
140
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
141
|
+
const id = R('reject-recorded');
|
|
142
|
+
const resolved = r.evs.find((e) => e.type === 'interrupt.resolved');
|
|
143
|
+
expect(resolved, req(id, `${DOC}; interrupt.md §Events`, 'the reject MUST be recorded as interrupt.resolved')).toBeDefined();
|
|
144
|
+
expect(
|
|
145
|
+
{ action: resolved?.payload?.['action'], decision: resolved?.payload?.['decision'] },
|
|
146
|
+
req(id, `${DOC}; interrupt.md §Events (RFC 0183)`, 'interrupt.resolved MUST carry action "reject" and decision "rejected"'),
|
|
147
|
+
).toEqual({ action: 'reject', decision: 'rejected' });
|
|
148
|
+
}, 45_000);
|
|
149
|
+
|
|
150
|
+
it('the gate node fails with approval_rejected, not retryable, and run.failed names it', async () => {
|
|
151
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
152
|
+
const r = await rejectedRun();
|
|
153
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
154
|
+
const id = R('reject-fails-node');
|
|
155
|
+
const nodeFailed = r.evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
|
|
156
|
+
expect(nodeFailed, req(id, DOC, 'the gate that does not turn the rejection into an output MUST emit node.failed')).toBeDefined();
|
|
157
|
+
const err = nodeFailed?.payload?.['error'] as { code?: unknown; retryable?: unknown } | undefined;
|
|
158
|
+
expect({ code: err?.code, retryable: err?.retryable }, req(id, DOC, 'node.failed.error MUST carry code approval_rejected and retryable false')).toEqual({ code: CODE, retryable: false });
|
|
159
|
+
const runFailed = r.evs.find((e) => e.type === 'run.failed');
|
|
160
|
+
expect(runFailed?.payload?.['failedNodeId'], req(id, DOC, 'run.failed.failedNodeId MUST name the gate')).toBe(NODE_ID);
|
|
161
|
+
}, 45_000);
|
|
162
|
+
|
|
163
|
+
it('a majority reject decides the quorum gate on the deciding vote and fails the run with approval_rejected', async () => {
|
|
164
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
165
|
+
if (!isFixtureAdvertised(QUORUM)) return softSkip('inapplicable', `fixture ${QUORUM} is not advertised — the host does not claim the quorum fixture`);
|
|
166
|
+
const s = await suspended(QUORUM);
|
|
167
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
168
|
+
const id = R('quorum-reject-fails-run');
|
|
169
|
+
const first = await resolve(s.runId, { action: 'reject', voter: 'approver-1' });
|
|
170
|
+
if (first === null || first.status < 200 || first.status >= 300) return softSkip('blocked', `the first reject vote answered ${first?.status ?? 'nothing'} ${readErrorCode(first?.json) ?? ''}`);
|
|
171
|
+
const between = (await snapshot(s.runId))?.['status'];
|
|
172
|
+
const resolvedEarly = (await events(s.runId)).some((e) => e.type === 'interrupt.resolved');
|
|
173
|
+
const second = await resolve(s.runId, { action: 'reject', voter: 'approver-2' });
|
|
174
|
+
if (second === null || second.status < 200 || second.status >= 300) return softSkip('blocked', `the second reject vote answered ${second?.status ?? 'nothing'} ${readErrorCode(second?.json) ?? ''}`);
|
|
175
|
+
const terminal = await waitStatus(s.runId, TERMINAL, 10_000);
|
|
176
|
+
if (between === 'waiting-approval' && terminal?.['status'] === 'waiting-approval') {
|
|
177
|
+
await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
|
|
178
|
+
return softSkip('blocked', 'the host counted both votes as one approver (one bearer; the resume value\'s voter was not honoured), so a majority could not be caused');
|
|
179
|
+
}
|
|
180
|
+
expect(between, req(id, DOC, 'one reject of three under majority does not decide the gate: the run MUST stay waiting-approval')).toBe('waiting-approval');
|
|
181
|
+
expect(resolvedEarly, req(id, DOC, 'a vote that does not decide the gate MUST NOT emit interrupt.resolved')).toBe(false);
|
|
182
|
+
expect(terminal?.['status'], req(id, DOC, `two rejects of three under majority MUST resolve the gate rejected and fail the run (got ${String(terminal?.['status'])})`)).toBe('failed');
|
|
183
|
+
expect(errorCode(terminal?.['error']), req(id, DOC, 'the terminal snapshot error.code MUST be approval_rejected')).toBe(CODE);
|
|
184
|
+
}, 60_000);
|
|
185
|
+
|
|
186
|
+
it('a rejected gate routed over an any_failed edge fails as a node, its all_success sibling never runs, and the run completes', async () => {
|
|
187
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
188
|
+
if (!isFixtureAdvertised(ROUTED)) return softSkip('inapplicable', `fixture ${ROUTED} is not advertised — the host does not claim the routed-reject fixture`);
|
|
189
|
+
const s = await suspended(ROUTED);
|
|
190
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
191
|
+
const res = await resolve(s.runId, { action: 'reject' });
|
|
192
|
+
if (res === null || res.status < 200 || res.status >= 300) return softSkip('blocked', `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract`);
|
|
193
|
+
const id = R('reject-routed');
|
|
194
|
+
const terminal = await waitStatus(s.runId, TERMINAL, 10_000);
|
|
195
|
+
const evs = await events(s.runId);
|
|
196
|
+
const ran = (nodeId: string, type: string): boolean => evs.some((e) => e.type === type && (e.payload?.['nodeId'] ?? e.nodeId) === nodeId);
|
|
197
|
+
const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
|
|
198
|
+
const err = gateFailed?.payload?.['error'] as { code?: unknown; retryable?: unknown } | undefined;
|
|
199
|
+
expect({ code: err?.code, retryable: err?.retryable }, req(id, DOC, 'the routed gate still fails as a node: node.failed.error MUST carry approval_rejected and retryable false')).toEqual({ code: CODE, retryable: false });
|
|
200
|
+
expect(ran('on-reject', 'node.completed'), req(id, DOC, 'the any_failed edge admits the failed gate: on-reject MUST run to node.completed')).toBe(true);
|
|
201
|
+
expect(ran('on-accept', 'node.started') || ran('on-accept', 'node.completed'), req(id, DOC, 'a rejected gate MUST NOT satisfy an all_success edge: on-accept MUST NOT run')).toBe(false);
|
|
202
|
+
expect(terminal?.['status'], req(id, DOC, `a routed rejection does not fail the run: it MUST complete (got ${String(terminal?.['status'])})`)).toBe('completed');
|
|
203
|
+
}, 45_000);
|
|
204
|
+
|
|
205
|
+
it('an unresolved gate past its timeoutMs with no onTimeout is resolved rejected by the host and fails the run naming the gate', async () => {
|
|
206
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
207
|
+
if (!isFixtureAdvertised(TIMEOUT)) return softSkip('inapplicable', `fixture ${TIMEOUT} is not advertised — the host does not claim the timeout fixture`);
|
|
208
|
+
const s = await suspended(TIMEOUT);
|
|
209
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
210
|
+
const id = R('timeout-rejects');
|
|
211
|
+
const terminal = await waitStatus(s.runId, TERMINAL, scaledTimeoutMs(20_000));
|
|
212
|
+
const evs = await events(s.runId);
|
|
213
|
+
const resolved = evs.find((e) => e.type === 'interrupt.resolved');
|
|
214
|
+
expect(
|
|
215
|
+
{ action: resolved?.payload?.['action'], decision: resolved?.payload?.['decision'], reason: resolved?.payload?.['reason'] },
|
|
216
|
+
req(id, DOC, 'the host MUST resolve the timed-out gate itself, recording action "timeout", decision "rejected" and reason "timeout"'),
|
|
217
|
+
).toEqual({ action: 'timeout', decision: 'rejected', reason: 'timeout' });
|
|
218
|
+
const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
|
|
219
|
+
expect(errorCode(gateFailed?.payload?.['error']), req(id, DOC, 'the timed-out gate MUST fail with approval_rejected')).toBe(CODE);
|
|
220
|
+
const runFailed = evs.find((e) => e.type === 'run.failed');
|
|
221
|
+
expect({ status: terminal?.['status'], code: errorCode(runFailed?.payload?.['error']), failedNodeId: runFailed?.payload?.['failedNodeId'] }, req(id, DOC, 'with no routing edge the run MUST terminate failed, run.failed carrying approval_rejected and failedNodeId naming the gate')).toEqual({ status: 'failed', code: CODE, failedNodeId: NODE_ID });
|
|
222
|
+
}, 60_000);
|
|
223
|
+
|
|
224
|
+
it('a gate whose onTimeout is approve still resolves rejected on timeout and fails the run: a timeout never grants', async () => {
|
|
225
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
226
|
+
if (!isFixtureAdvertised(TIMEOUT_APPROVE)) return softSkip('inapplicable', `fixture ${TIMEOUT_APPROVE} is not advertised — the host does not claim the timeout-approve fixture`);
|
|
227
|
+
const s = await suspended(TIMEOUT_APPROVE);
|
|
228
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
229
|
+
const id = R('timeout-never-grants');
|
|
230
|
+
const terminal = await waitStatus(s.runId, TERMINAL, scaledTimeoutMs(20_000));
|
|
231
|
+
const evs = await events(s.runId);
|
|
232
|
+
const resolved = evs.find((e) => e.type === 'interrupt.resolved');
|
|
233
|
+
expect(
|
|
234
|
+
{ action: resolved?.payload?.['action'], decision: resolved?.payload?.['decision'] },
|
|
235
|
+
req(id, DOC, 'whatever onTimeout holds, the host MUST resolve the timed-out gate rejected (action "timeout", decision "rejected") — onTimeout "approve" is treated as reject'),
|
|
236
|
+
).toEqual({ action: 'timeout', decision: 'rejected' });
|
|
237
|
+
const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
|
|
238
|
+
expect(errorCode(gateFailed?.payload?.['error']), req(id, DOC, 'the timed-out gate MUST fail with approval_rejected')).toBe(CODE);
|
|
239
|
+
expect(terminal?.['status'], req(id, DOC, `a timeout MUST NOT grant a gate: with no routing edge the run MUST terminate failed, never completed (got ${String(terminal?.['status'])})`)).toBe('failed');
|
|
240
|
+
}, 60_000);
|
|
241
|
+
|
|
242
|
+
it('a rejected gate looped back to itself is asked again under a new key, and the first rejection is not replayed', async () => {
|
|
243
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
244
|
+
if (!isFixtureAdvertised(LOOPBACK)) return softSkip('inapplicable', `fixture ${LOOPBACK} is not advertised — the host does not run cycles`);
|
|
245
|
+
const s = await suspended(LOOPBACK);
|
|
246
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
247
|
+
const res = await resolve(s.runId, { action: 'reject' });
|
|
248
|
+
if (res === null || res.status < 200 || res.status >= 300) return softSkip('blocked', `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract`);
|
|
249
|
+
const id = R('reject-loopback-reasks');
|
|
250
|
+
const gateRequests = (evs: Ev[]): Ev[] => evs.filter((e) => e.type === 'interrupt.requested' && (e.nodeId ?? e.payload?.['nodeId']) === NODE_ID);
|
|
251
|
+
const deadline = Date.now() + scaledTimeoutMs(10_000);
|
|
252
|
+
let evs = await events(s.runId);
|
|
253
|
+
while (gateRequests(evs).length < 2 && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 250)); evs = await events(s.runId); }
|
|
254
|
+
const snap = await snapshot(s.runId);
|
|
255
|
+
const [first, second] = gateRequests(evs);
|
|
256
|
+
expect(second, req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'the second visit of the gate MUST raise a new interrupt.requested')).toBeDefined();
|
|
257
|
+
expect(second?.payload?.['key'] !== undefined && second?.payload?.['key'] !== first?.payload?.['key'], req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', `a later execution of the node MUST derive a different key (got ${String(first?.payload?.['key'])} then ${String(second?.payload?.['key'])})`)).toBe(true);
|
|
258
|
+
expect(snap?.['status'], req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'the second visit MUST wait for a new decision: the run is waiting-approval again')).toBe('waiting-approval');
|
|
259
|
+
expect(evs.filter((e) => e.type === 'interrupt.resolved').length, req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'an earlier visit\'s resumeValue MUST NOT be returned: only the one caller resolve is recorded')).toBe(1);
|
|
260
|
+
await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
|
|
261
|
+
}, 45_000);
|
|
262
|
+
});
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* lives in `Retry-After` only, which a `429 rate_limited` MUST set. The
|
|
11
11
|
* deliberately bad request is `GET /runs/does-not-exist` (outside the runId
|
|
12
12
|
* grammar, so a host may answer `400 validation_error` or `404 not_found`).
|
|
13
|
+
*
|
|
14
|
+
* `event-code-registered` (openwop#1698, suite 2.43.1): errors.md §The
|
|
15
|
+
* registry binds `error.code` on `run.failed`, `node.failed` and the snapshot's
|
|
16
|
+
* `error` exactly as it binds an error response. The leg runs
|
|
17
|
+
* `conformance-failure` and checks every such code against the registry and the
|
|
18
|
+
* vendor pattern (first segment an org registered in `spec/v2/declaration.json`).
|
|
19
|
+
* ADVISORY for suite 2.43.1: a violation is recorded as a partial witness naming
|
|
20
|
+
* the codes, not a failure, while hosts remap (per-host rows in gaps.json).
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
import { describe, it, expect } from 'vitest';
|
|
@@ -25,6 +33,8 @@ import { req } from '../lib/requirement-ids.js';
|
|
|
25
33
|
const DOC = 'spec/v2/core/errors.md §The envelope';
|
|
26
34
|
const ERRORS_PATH = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'errors.json');
|
|
27
35
|
const RETRY_SPELLINGS = ['retryAfter', 'retryAfterMs', 'retryAfterSeconds'];
|
|
36
|
+
const DECLARATION_PATH = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'declaration.json');
|
|
37
|
+
const FAILURE_FIXTURE = 'conformance-failure';
|
|
28
38
|
|
|
29
39
|
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
30
40
|
try { return await v2Discovery(); } catch { return null; }
|
|
@@ -40,6 +50,16 @@ function registryStatus(): Map<string, number> | null {
|
|
|
40
50
|
return new Map(rows.map((r) => [r.code, r.httpStatus]));
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
/** code → registered, or a vendor code whose org is registered; null when the registry or declaration is absent. */
|
|
54
|
+
function codeAccepted(): ((code: string) => boolean) | null {
|
|
55
|
+
const registry = registryStatus();
|
|
56
|
+
if (registry === null || !existsSync(DECLARATION_PATH)) return null;
|
|
57
|
+
const reg = JSON.parse(readFileSync(ERRORS_PATH, 'utf8')) as { vendorCodePattern?: string };
|
|
58
|
+
const pattern = new RegExp(reg.vendorCodePattern ?? '^(?!openwop\\.)[a-z][a-z0-9]*(-[a-z0-9]+)*\\.[a-z][a-z0-9_]*$');
|
|
59
|
+
const orgs = new Set(Object.keys((JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')) as { extensions?: Record<string, unknown> }).extensions ?? {}));
|
|
60
|
+
return (code: string) => registry.has(code) || (pattern.test(code) && orgs.has(code.split('.')[0] ?? ''));
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
async function badRequest(): Promise<OpenWOPResponse | { reason: string }> {
|
|
44
64
|
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
45
65
|
const res = await http(() => driver.get('/runs/does-not-exist'));
|
|
@@ -86,4 +106,43 @@ describe('v2 error-registry (RFC 0171 §B.1–§B.2)', () => {
|
|
|
86
106
|
expect(r.headers.get('retry-after'), req('openwop.requirement.0171.error-registry.retry-after', 'spec/v2/core/errors.md §Retry timing', 'a 429 rate_limited response MUST set Retry-After')).not.toBeNull();
|
|
87
107
|
}
|
|
88
108
|
});
|
|
109
|
+
|
|
110
|
+
it('an error code on run.failed, node.failed and the snapshot is registered or a vendor code (advisory in 2.43.1)', async () => {
|
|
111
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
112
|
+
const accepted = codeAccepted();
|
|
113
|
+
if (accepted === null) return softSkip('inapplicable', 'spec/v2/errors.json or spec/v2/declaration.json is not present in this layout');
|
|
114
|
+
const fixtures = ((await discovery())?.['fixtures'] ?? []) as unknown[];
|
|
115
|
+
if (!fixtures.includes(FAILURE_FIXTURE)) return softSkip('blocked', `fixture ${FAILURE_FIXTURE} is not advertised — no failing run to read codes from`);
|
|
116
|
+
const create = await http(() => driver.post('/runs', { workflowId: FAILURE_FIXTURE }));
|
|
117
|
+
const runId = (create?.json as { runId?: unknown } | null)?.runId;
|
|
118
|
+
if (create === null || create.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs (${FAILURE_FIXTURE}) answered ${create?.status ?? 'nothing'}`);
|
|
119
|
+
const deadline = Date.now() + 10_000;
|
|
120
|
+
let snap: Record<string, unknown> | null = null;
|
|
121
|
+
for (;;) {
|
|
122
|
+
const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
123
|
+
snap = r?.status === 200 ? (r.json as Record<string, unknown>) : null;
|
|
124
|
+
if (snap !== null && ['completed', 'failed', 'cancelled'].includes(String(snap['status']))) break;
|
|
125
|
+
if (Date.now() > deadline) return softSkip('blocked', `the ${FAILURE_FIXTURE} run did not reach a terminal state within 10 s`);
|
|
126
|
+
await new Promise((ok) => setTimeout(ok, 250));
|
|
127
|
+
}
|
|
128
|
+
const polled = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
129
|
+
const events = ((polled?.json as { events?: Array<{ type?: string; payload?: { error?: { code?: unknown } } }> } | null)?.events ?? []);
|
|
130
|
+
const seen: Array<{ where: string; code: string }> = [];
|
|
131
|
+
for (const e of events) {
|
|
132
|
+
const code = e.payload?.error?.code;
|
|
133
|
+
if ((e.type === 'run.failed' || e.type === 'node.failed') && typeof code === 'string') seen.push({ where: e.type, code });
|
|
134
|
+
}
|
|
135
|
+
const snapCode = (snap?.['error'] as { code?: unknown } | undefined)?.code;
|
|
136
|
+
if (typeof snapCode === 'string') seen.push({ where: 'snapshot.error', code: snapCode });
|
|
137
|
+
const ID = 'openwop.requirement.errors.event-code-registered';
|
|
138
|
+
expect(seen.length, req(ID, 'spec/v2/core/errors.md §The registry', `a failed ${FAILURE_FIXTURE} run MUST carry an error code on run.failed, node.failed or the snapshot (status ${String(snap?.['status'])})`)).toBeGreaterThan(0);
|
|
139
|
+
const bad = seen.filter((s) => !accepted(s.code));
|
|
140
|
+
if (bad.length > 0) {
|
|
141
|
+
// partial-witness-ok: ADVISORY for suite 2.43.1 (openwop#1698). The codes were
|
|
142
|
+
// observed and checked above; a violation is recorded, not failed, for one
|
|
143
|
+
// suite patch while hosts remap. Per-host gap rows track them.
|
|
144
|
+
return softSkip('inapplicable', `ADVISORY (suite 2.43.1, openwop#1698): unregistered, non-vendor error codes: ${bad.map((b) => `${b.where}=${b.code}`).join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
expect(bad, req(ID, 'spec/v2/core/errors.md §The registry', 'every error code on run.failed, node.failed and the snapshot MUST be registered or a vendor code')).toEqual([]);
|
|
147
|
+
}, 45_000);
|
|
89
148
|
});
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* 2. `>=1.0.0 <2.0.0` (v1 ceiling) → refused, pack_engine_unsupported
|
|
15
15
|
* 3. `>=2.0.0 <3.0.0` (admits major 2) → installs (200/201)
|
|
16
16
|
*
|
|
17
|
-
* Gated on the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* install path
|
|
17
|
+
* Gated on the seams profile (`conformance.seamsProfile:
|
|
18
|
+
* openwop-conformance-seams-v2`): its publish seam is the only install path the
|
|
19
|
+
* suite can drive. Not gated on `packs` since suite 2.43.1 (#1706): §A.1 binds
|
|
20
|
+
* at install on every path, and `packs` advertises registry resolution, which a
|
|
21
|
+
* host installing only local packs correctly omits. A seam 404 records `blocked`
|
|
22
|
+
* when `packs` is advertised and `inapplicable` when it is not.
|
|
21
23
|
*
|
|
22
24
|
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §A.1
|
|
23
25
|
* @see spec/v2/core/packs.md §"The engine range"
|
|
@@ -31,7 +33,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
|
|
|
31
33
|
import { req } from '../lib/requirement-ids.js';
|
|
32
34
|
import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
33
35
|
import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
|
|
34
|
-
import { v2Discovery,
|
|
36
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
35
37
|
|
|
36
38
|
const SECTION = 'packs.md §"The engine range" (RFC 0177 §A.1)';
|
|
37
39
|
|
|
@@ -87,6 +89,17 @@ async function publish(manifest: Record<string, unknown>) {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/** The reason this leg cannot run, or null when the seam is reachable. */
|
|
92
|
+
/**
|
|
93
|
+
* The publish seam answered 404. Whether that is a defect depends on `packs`
|
|
94
|
+
* (#1706): a host advertising `packs` claims an install path and must mount it
|
|
95
|
+
* (`blocked`); a host that omits `packs` may have no install path the suite can
|
|
96
|
+
* drive (`inapplicable`). §A.1 binds at install on every path, not on `packs`.
|
|
97
|
+
*/
|
|
98
|
+
async function seamMissing(): Promise<undefined> {
|
|
99
|
+
if (await familyAdvertised('packs')) return softSkip('blocked', 'packs-test publish seam answered 404 — packs and the seams profile are advertised but the seam is not mounted');
|
|
100
|
+
return softSkip('inapplicable', 'packs-test publish seam answered 404 and packs is not advertised — no install path the suite can drive');
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
|
|
91
104
|
if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
|
|
92
105
|
let doc: Record<string, unknown> | null;
|
|
@@ -96,7 +109,6 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
96
109
|
doc = null;
|
|
97
110
|
}
|
|
98
111
|
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
99
|
-
if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
|
|
100
112
|
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
|
|
101
113
|
return null;
|
|
102
114
|
}
|
|
@@ -106,7 +118,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
|
|
|
106
118
|
const skip = await preflight();
|
|
107
119
|
if (skip) return softSkip(skip.kind, skip.reason);
|
|
108
120
|
const res = await publish(nodePack(freshName('unbounded'), '>=1.0.0'));
|
|
109
|
-
if (res.status === 404) return
|
|
121
|
+
if (res.status === 404) return seamMissing();
|
|
110
122
|
expect(res.status, req('openwop.requirement.0177.manifest-ceiling-refused.unbounded', SECTION, 'an unbounded range does not admit protocol major 2 and MUST be refused with 400 pack_engine_unsupported')).toBe(400);
|
|
111
123
|
expect(readErrorCode(res.json), req('openwop.requirement.0177.manifest-ceiling-refused.unbounded', SECTION, 'the refusal code MUST be pack_engine_unsupported (not pack_runtime_requirement_unmet)')).toBe('pack_engine_unsupported');
|
|
112
124
|
});
|
|
@@ -115,7 +127,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
|
|
|
115
127
|
const skip = await preflight();
|
|
116
128
|
if (skip) return softSkip(skip.kind, skip.reason);
|
|
117
129
|
const res = await publish(nodePack(freshName('v1-ceiling'), '>=1.0.0 <2.0.0'));
|
|
118
|
-
if (res.status === 404) return
|
|
130
|
+
if (res.status === 404) return seamMissing();
|
|
119
131
|
expect(res.status, req('openwop.requirement.0177.manifest-ceiling-refused.v1-ceiling', SECTION, 'a <2.0.0 ceiling does not admit protocol major 2 and MUST be refused with 400 pack_engine_unsupported')).toBe(400);
|
|
120
132
|
expect(readErrorCode(res.json), req('openwop.requirement.0177.manifest-ceiling-refused.v1-ceiling', SECTION, 'the refusal code MUST be pack_engine_unsupported')).toBe('pack_engine_unsupported');
|
|
121
133
|
});
|
|
@@ -124,7 +136,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
|
|
|
124
136
|
const skip = await preflight();
|
|
125
137
|
if (skip) return softSkip(skip.kind, skip.reason);
|
|
126
138
|
const res = await publish(nodePack(freshName('v2-installs'), '>=2.0.0 <3.0.0'));
|
|
127
|
-
if (res.status === 404) return
|
|
139
|
+
if (res.status === 404) return seamMissing();
|
|
128
140
|
expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-ceiling-refused.v2-installs', SECTION, `a manifest whose range admits major 2 MUST install (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBe(true);
|
|
129
141
|
});
|
|
130
142
|
});
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* packs at all (`pack_kind_invalid`) records `skipped` for that leg.
|
|
16
16
|
*
|
|
17
17
|
* Installs through the seams-profile publish seam
|
|
18
|
-
* (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on
|
|
19
|
-
*
|
|
18
|
+
* (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on the seams
|
|
19
|
+
* profile; not on `packs` since suite 2.43.1 (#1706) — the hatch binds at install
|
|
20
|
+
* on every path. A seam 404 records `blocked` when `packs` is advertised and
|
|
21
|
+
* `inapplicable` when it is not.
|
|
20
22
|
*
|
|
21
23
|
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §C.2
|
|
22
24
|
* @see spec/v2/core/packs.md §"The manifest schema family"
|
|
@@ -30,7 +32,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
|
|
|
30
32
|
import { req } from '../lib/requirement-ids.js';
|
|
31
33
|
import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
32
34
|
import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
|
|
33
|
-
import { v2Discovery,
|
|
35
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
34
36
|
|
|
35
37
|
const SECTION = 'packs.md §"The manifest schema family" (RFC 0177 §C.2)';
|
|
36
38
|
|
|
@@ -67,6 +69,17 @@ async function publish(manifest: Record<string, unknown>, files: Record<string,
|
|
|
67
69
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
68
70
|
});
|
|
69
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The publish seam answered 404. Whether that is a defect depends on `packs`
|
|
74
|
+
* (#1706): a host advertising `packs` claims an install path and must mount it
|
|
75
|
+
* (`blocked`); a host that omits `packs` may have no install path the suite can
|
|
76
|
+
* drive (`inapplicable`). §A.1 binds at install on every path, not on `packs`.
|
|
77
|
+
*/
|
|
78
|
+
async function seamMissing(): Promise<undefined> {
|
|
79
|
+
if (await familyAdvertised('packs')) return softSkip('blocked', 'packs-test publish seam answered 404 — packs and the seams profile are advertised but the seam is not mounted');
|
|
80
|
+
return softSkip('inapplicable', 'packs-test publish seam answered 404 and packs is not advertised — no install path the suite can drive');
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
|
|
71
84
|
if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
|
|
72
85
|
let doc: Record<string, unknown> | null;
|
|
@@ -76,7 +89,6 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
76
89
|
doc = null;
|
|
77
90
|
}
|
|
78
91
|
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
79
|
-
if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
|
|
80
92
|
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
|
|
81
93
|
return null;
|
|
82
94
|
}
|
|
@@ -93,10 +105,14 @@ describe('v2-manifest-hatch-carried (RFC 0177 §C.2)', () => {
|
|
|
93
105
|
engines: { openwop: '>=2.0.0 <3.0.0' },
|
|
94
106
|
runtime: { language: 'javascript', entry: 'index.mjs', format: 'esm' },
|
|
95
107
|
nodes: [{ typeId: `${name}.echo`, version: '1.0.0', category: 'data', role: 'pure' }],
|
|
96
|
-
|
|
108
|
+
// systemPrompt: agent-manifest requires exactly one of systemPrompt /
|
|
109
|
+
// systemPromptRef (oneOf). Without it the fixture was schema-invalid for a
|
|
110
|
+
// reason unrelated to the hatch, and a conforming host had to refuse it
|
|
111
|
+
// (2.43.1; reported by MyndHyve).
|
|
112
|
+
agents: [{ agentId: `${name}.helper`, persona: 'Hatch Helper', modelClass: 'general', systemPrompt: 'You are a conformance fixture.', 'x-vendor-note': 'a hatch property the host MUST ignore' }],
|
|
97
113
|
};
|
|
98
114
|
const res = await publish(manifest, { 'index.mjs': ENTRY });
|
|
99
|
-
if (res.status === 404) return
|
|
115
|
+
if (res.status === 404) return seamMissing();
|
|
100
116
|
expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-hatch-carried.agents-x-field', SECTION, `agent-manifest admits ^(openwop-|x-|vendor\\.) — a pack whose agents[0] carries x-vendor-note MUST install (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBe(true);
|
|
101
117
|
});
|
|
102
118
|
|
|
@@ -112,7 +128,7 @@ describe('v2-manifest-hatch-carried (RFC 0177 §C.2)', () => {
|
|
|
112
128
|
prompts: [{ templateId: `${name}.system`, version: '1.0.0', kind: 'system', text: 'You are a conformance fixture.', 'x-vendor-note': 'a hatch property the host MUST ignore' }],
|
|
113
129
|
};
|
|
114
130
|
const res = await publish(manifest, {});
|
|
115
|
-
if (res.status === 404) return
|
|
131
|
+
if (res.status === 404) return seamMissing();
|
|
116
132
|
const code = readErrorCode(res.json);
|
|
117
133
|
if (res.status >= 400 && code === 'pack_kind_invalid') return softSkip('skipped', 'host does not install kind: prompt packs (pack_kind_invalid) — the prompt-template hatch cannot be observed on this host');
|
|
118
134
|
expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-hatch-carried.prompt-template-x-field', SECTION, `prompt-template admits ^(openwop-|x-|vendor\\.) — a prompt pack whose prompts[0] carries x-vendor-note MUST install (got ${res.status} ${code ?? ''})`)).toBe(true);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RFC 0173 §B — `pack-isolation` (suite 2.0.0, target major 2; gated on `
|
|
2
|
+
* RFC 0173 §B — `pack-isolation` (suite 2.0.0, target major 2; gated on `sandbox`).
|
|
3
|
+
*
|
|
4
|
+
* Gated on `sandbox` alone since suite 2.43.1 (#1706). It was gated on `packs`
|
|
5
|
+
* + `sandbox`, but `packs` advertises registry resolution (packs.md §The
|
|
6
|
+
* `packs` capability), which a host executing only local packs correctly omits.
|
|
7
|
+
* `sandbox` is the advertisement §B requires of any host executing packs.
|
|
3
8
|
*
|
|
4
9
|
* Isolation binds with pack EXECUTION: a host that executes third-party packs
|
|
5
10
|
* MUST enforce the eight `node-pack-sandbox-*` invariants and MUST advertise
|
|
@@ -41,11 +46,10 @@ async function discovery(): Promise<Record<string, unknown> | null> {
|
|
|
41
46
|
try { return await v2Discovery(); } catch { return null; }
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
/** Gate for every leg:
|
|
49
|
+
/** Gate for every leg: sandbox advertised. Returns the sandbox facet or null (reason recorded). */
|
|
45
50
|
async function gated(): Promise<Record<string, unknown> | null> {
|
|
46
51
|
const doc = await discovery();
|
|
47
52
|
if (!doc) { softSkip('blocked', 'discovery unreachable'); return null; }
|
|
48
|
-
if (!(await gateFamily('packs'))) { softSkip('inapplicable', 'packs family not advertised — no pack execution, no isolation obligation (gate recorded under openwop.family.packs)'); return null; }
|
|
49
53
|
const sandbox = await gateFamily('sandbox');
|
|
50
54
|
if (!sandbox) { softSkip('inapplicable', 'sandbox family not advertised (gate recorded under openwop.family.sandbox)'); return null; }
|
|
51
55
|
return sandbox;
|
|
@@ -56,7 +60,7 @@ async function invoke(typeId: string, extra: Record<string, unknown> = {}): Prom
|
|
|
56
60
|
const doc = await discovery();
|
|
57
61
|
if (!doc || !seamsProfileAdvertised(doc)) { softSkip('inapplicable', `the ${typeId} leg is seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised`); return null; }
|
|
58
62
|
const res = await driver.post(INVOKE, { typeId, ...extra });
|
|
59
|
-
if (res.status === 404 || res.status === 403 || res.status === 405) { seamAbsent(`host advertises
|
|
63
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) { seamAbsent(`host advertises sandbox but ${INVOKE} answered ${res.status} — the ${typeId} leg is unobservable (host-sample-test-seams.md §8)`); return null; }
|
|
60
64
|
// The same id is ALSO asserted by its own `it` above, and that is the copy the
|
|
61
65
|
// registry can see: `generate-requirement-registry.mjs` harvests `req(…)` only
|
|
62
66
|
// within an `it`, so this helper-level call alone left the id out of
|
|
@@ -74,7 +78,7 @@ function expectEscape(id: string, r: InvokeResult, escapeKind: string, invariant
|
|
|
74
78
|
expect(r.error?.details?.escapeKind, req(id, 'host-sample-test-seams.md §8 SandboxError', `escapeKind MUST be ${escapeKind}`)).toBe(escapeKind);
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
describe('RFC 0173 §B — pack-isolation (gated on
|
|
81
|
+
describe('RFC 0173 §B — pack-isolation (gated on sandbox)', () => {
|
|
78
82
|
it('sandbox.isolationModel names a real mechanism, never node:vm', async () => {
|
|
79
83
|
const sandbox = await gated();
|
|
80
84
|
if (!sandbox) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
@@ -94,7 +98,7 @@ describe('RFC 0173 §B — pack-isolation (gated on packs + sandbox)', () => {
|
|
|
94
98
|
const doc = await discovery();
|
|
95
99
|
if (!doc || !seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised');
|
|
96
100
|
const res = await driver.post(INVOKE, { typeId: 'misbehave.fs-escape-read' });
|
|
97
|
-
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises
|
|
101
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises sandbox but ${INVOKE} answered ${res.status}`);
|
|
98
102
|
expect(
|
|
99
103
|
res.status,
|
|
100
104
|
req('openwop.requirement.0173.pack-isolation.seam', 'host-sample-test-seams.md §8', `${INVOKE} MUST answer 200 { result } | 200 { error } — the eight behavioural legs are unobservable otherwise`),
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* failure; any other refusal fails.
|
|
18
18
|
*
|
|
19
19
|
* Both legs install through the seams-profile publish seam
|
|
20
|
-
* (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on
|
|
21
|
-
*
|
|
20
|
+
* (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on the seams
|
|
21
|
+
* profile; not on `packs` since suite 2.43.1 (#1706) — the rule binds at install
|
|
22
|
+
* on every path. A seam 404 records `blocked` when `packs` is advertised and
|
|
23
|
+
* `inapplicable` when it is not.
|
|
22
24
|
*
|
|
23
25
|
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §B.1, §B.2
|
|
24
26
|
* @see spec/v2/core/packs.md §"Peer-dependency identifiers", §"The alias table"
|
|
@@ -35,7 +37,7 @@ import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
|
35
37
|
import { req } from '../lib/requirement-ids.js';
|
|
36
38
|
import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
37
39
|
import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
|
|
38
|
-
import { v2Discovery,
|
|
40
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
39
41
|
|
|
40
42
|
const SECTION = 'packs.md §"Peer-dependency identifiers" (RFC 0177 §B.1)';
|
|
41
43
|
const ALIASES = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'peer-dependency-aliases.json');
|
|
@@ -86,6 +88,17 @@ async function publish(manifest: Record<string, unknown>) {
|
|
|
86
88
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
87
89
|
});
|
|
88
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The publish seam answered 404. Whether that is a defect depends on `packs`
|
|
93
|
+
* (#1706): a host advertising `packs` claims an install path and must mount it
|
|
94
|
+
* (`blocked`); a host that omits `packs` may have no install path the suite can
|
|
95
|
+
* drive (`inapplicable`). §A.1 binds at install on every path, not on `packs`.
|
|
96
|
+
*/
|
|
97
|
+
async function seamMissing(): Promise<undefined> {
|
|
98
|
+
if (await familyAdvertised('packs')) return softSkip('blocked', 'packs-test publish seam answered 404 — packs and the seams profile are advertised but the seam is not mounted');
|
|
99
|
+
return softSkip('inapplicable', 'packs-test publish seam answered 404 and packs is not advertised — no install path the suite can drive');
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
|
|
90
103
|
if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
|
|
91
104
|
let doc: Record<string, unknown> | null;
|
|
@@ -95,7 +108,6 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
95
108
|
doc = null;
|
|
96
109
|
}
|
|
97
110
|
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
98
|
-
if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
|
|
99
111
|
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
|
|
100
112
|
return null;
|
|
101
113
|
}
|
|
@@ -105,7 +117,7 @@ describe('v2-peer-dependency-declared (RFC 0177 §B.1/§B.2)', () => {
|
|
|
105
117
|
const skip = await preflight();
|
|
106
118
|
if (skip) return softSkip(skip.kind, skip.reason);
|
|
107
119
|
const res = await publish(nodePack(freshName('peer-undefined'), { 'host.nonexistent': 'required' }));
|
|
108
|
-
if (res.status === 404) return
|
|
120
|
+
if (res.status === 404) return seamMissing();
|
|
109
121
|
expect(res.status, req('openwop.requirement.0177.peer-dependency-declared.undefined-key', SECTION, 'a key absent from spec/v2/declaration.json MUST be refused with 400 pack_peer_dependency_undefined')).toBe(400);
|
|
110
122
|
expect(readErrorCode(res.json), req('openwop.requirement.0177.peer-dependency-declared.undefined-key', SECTION, 'the refusal code MUST be pack_peer_dependency_undefined')).toBe('pack_peer_dependency_undefined');
|
|
111
123
|
});
|
|
@@ -124,7 +136,7 @@ describe('v2-peer-dependency-declared (RFC 0177 §B.1/§B.2)', () => {
|
|
|
124
136
|
}
|
|
125
137
|
if (!chosen) return softSkip('inapplicable', 'no facet-less alias row names a family this host advertises — the overlap alias cannot be exercised');
|
|
126
138
|
const res = await publish(nodePack(freshName('peer-alias'), { [chosen.alias]: 'required' }));
|
|
127
|
-
if (res.status === 404) return
|
|
139
|
+
if (res.status === 404) return seamMissing();
|
|
128
140
|
const code = readErrorCode(res.json);
|
|
129
141
|
if (res.status === 400 && code === 'pack_peer_dependency_undefined') return softSkip('skipped', `host does not resolve the overlap alias ${chosen.alias} (RFC 0177 §B.2 is a MAY during the overlap; MUST NOT after v1 end-of-support)`);
|
|
130
142
|
expect([200, 201].includes(res.status), req('openwop.requirement.0177.peer-dependency-declared.alias-overlap', 'packs.md §"The alias table" (RFC 0177 §B.2)', `alias ${chosen.alias} → ${chosen.family} (advertised) MUST install through the overlap or be refused only with pack_peer_dependency_undefined (got ${res.status} ${code ?? ''})`)).toBe(true);
|