@openwop/openwop-conformance 1.5.0 → 1.6.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 +27 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +25 -4
- package/api/openapi.yaml +371 -0
- package/coverage.md +31 -4
- package/fixtures/conformance-phase4-nondet-tool.json +53 -0
- package/fixtures/conformance-phase4-replay-divergence.json +40 -0
- package/fixtures.md +5 -3
- package/package.json +1 -1
- package/schemas/README.md +4 -0
- package/schemas/annotation-create.schema.json +37 -0
- package/schemas/annotation.schema.json +56 -0
- package/schemas/capabilities.schema.json +191 -3
- package/schemas/credential-reference.schema.json +21 -0
- package/schemas/node-pack-manifest.schema.json +112 -1
- package/schemas/run-diff-response.schema.json +64 -0
- package/schemas/run-event-payloads.schema.json +104 -2
- package/schemas/run-event.schema.json +8 -1
- package/schemas/run-snapshot.schema.json +11 -0
- package/src/lib/behavior-gate.ts +51 -0
- package/src/lib/driver.ts +13 -1
- package/src/lib/feedback.ts +31 -0
- package/src/lib/saml-idp.ts +179 -0
- package/src/scenarios/approval-gate-events.test.ts +61 -0
- package/src/scenarios/approval-gate-flow.test.ts +68 -0
- package/src/scenarios/auth-saml-profile.test.ts +119 -0
- package/src/scenarios/auth-scim-profile.test.ts +65 -0
- package/src/scenarios/authorization-fail-closed.test.ts +80 -0
- package/src/scenarios/authorization-roles-shape.test.ts +83 -0
- package/src/scenarios/connector-manifest-validity.test.ts +142 -0
- package/src/scenarios/credential-payload-redaction.test.ts +93 -0
- package/src/scenarios/credentials-capability-shape.test.ts +90 -0
- package/src/scenarios/cross-engine-append-behavior.test.ts +204 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +13 -6
- package/src/scenarios/cross-workspace-isolation.test.ts +72 -0
- package/src/scenarios/deadletter-capability-shape.test.ts +59 -0
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +62 -0
- package/src/scenarios/experimental-tier-shape.test.ts +192 -0
- package/src/scenarios/feedback-capability-shape.test.ts +35 -0
- package/src/scenarios/feedback-correction-redaction.test.ts +35 -0
- package/src/scenarios/feedback-cross-tenant-isolation.test.ts +37 -0
- package/src/scenarios/feedback-fork-not-copied.test.ts +40 -0
- package/src/scenarios/feedback-on-terminal-run.test.ts +32 -0
- package/src/scenarios/feedback-record-and-list.test.ts +32 -0
- package/src/scenarios/feedback-unsupported-501.test.ts +32 -0
- package/src/scenarios/identity-owner-shape.test.ts +64 -0
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +13 -12
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +87 -12
- package/src/scenarios/multi-region-idempotency-behavior.test.ts +203 -0
- package/src/scenarios/oauth-capability-shape.test.ts +97 -0
- package/src/scenarios/oauth-connector-redaction.test.ts +91 -0
- package/src/scenarios/pack-registry-isolation.test.ts +108 -0
- package/src/scenarios/pack-registry-publish.test.ts +1 -1
- package/src/scenarios/prompt-mutation-workspace-membership-enforced.test.ts +126 -0
- package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +183 -0
- package/src/scenarios/redaction.test.ts +4 -1
- package/src/scenarios/replay-divergence-at-refusal.test.ts +187 -7
- package/src/scenarios/replay-observable-sequence-determinism.test.ts +20 -6
- package/src/scenarios/run-diff.test.ts +143 -0
- package/src/scenarios/sandbox-capability-gate-respected.test.ts +7 -1
- package/src/scenarios/sandbox-memory-cap.test.ts +7 -5
- package/src/scenarios/sandbox-mvp-behavior.test.ts +280 -0
- package/src/scenarios/sandbox-no-cross-pack-mutation.test.ts +7 -1
- package/src/scenarios/sandbox-no-host-env-leak.test.ts +5 -1
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +9 -1
- package/src/scenarios/sandbox-no-host-process-escape.test.ts +5 -1
- package/src/scenarios/sandbox-no-network-escape.test.ts +5 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +7 -5
- package/src/scenarios/scheduling-capability-shape.test.ts +81 -0
- package/src/scenarios/scheduling-cron-fires-once.test.ts +66 -0
- package/src/scenarios/secret-leakage-otel-attribute.test.ts +241 -0
- package/src/scenarios/spec-corpus-validity.test.ts +6 -3
|
@@ -27,9 +27,15 @@
|
|
|
27
27
|
* mock provider returning a valid envelope on the original run and a
|
|
28
28
|
* refusal on the replay (or vice-versa). Reference workflow-engine ships
|
|
29
29
|
* a mock-AI provider (`OPENWOP_MULTI_AGENT_EXECUTION_MODEL=true`); the
|
|
30
|
-
* Phase 4 wiring
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Phase 4 wiring (landed 2026-05-23 via commits `1fce55a` + `bba3b4a`)
|
|
31
|
+
* extends it with `checkReplayDivergence()` in the executor catch-path
|
|
32
|
+
* + symmetric success-path detection of envelope-kind divergence; emits
|
|
33
|
+
* `replay.divergedAtRefusal` event and fails the run with
|
|
34
|
+
* `error.code: 'replay_diverged_at_refusal'` when source vs replay
|
|
35
|
+
* differ at the same nodeId. Behavioral coverage is now real: 3
|
|
36
|
+
* assertions PASS against workflow-engine when Phase 4 advertisement
|
|
37
|
+
* is enabled (cover both divergence directions: original=valid +
|
|
38
|
+
* replay=refusal AND original=refusal + replay=valid).
|
|
33
39
|
*
|
|
34
40
|
* @see RFCS/0041-multi-agent-replay-under-nondeterminism.md §B
|
|
35
41
|
* @see spec/v1/replay.md §"Envelope-refusal recovery in replay (MAE-8 closure)"
|
|
@@ -113,6 +119,40 @@ describe.skipIf(HTTP_SKIP)('replay-divergence-at-refusal: advertisement shape (R
|
|
|
113
119
|
});
|
|
114
120
|
});
|
|
115
121
|
|
|
122
|
+
interface RunSnapshot {
|
|
123
|
+
status?: string;
|
|
124
|
+
error?: { code?: string; message?: string };
|
|
125
|
+
}
|
|
126
|
+
interface RunEventDoc {
|
|
127
|
+
type: string;
|
|
128
|
+
nodeId?: string;
|
|
129
|
+
sequence?: number;
|
|
130
|
+
payload?: Record<string, unknown>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function pollUntilTerminal(runId: string): Promise<RunSnapshot> {
|
|
134
|
+
for (let i = 0; i < 50; i++) {
|
|
135
|
+
const r = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
136
|
+
const snap = r.json as RunSnapshot;
|
|
137
|
+
if (snap.status === 'completed' || snap.status === 'failed' || snap.status === 'cancelled') {
|
|
138
|
+
return snap;
|
|
139
|
+
}
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`run ${runId} did not reach terminal within 5s`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readEvents(runId: string): Promise<RunEventDoc[]> {
|
|
146
|
+
const r = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
147
|
+
const body = r.json as { events?: RunEventDoc[] };
|
|
148
|
+
return body.events ?? [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function programMock(nodeId: string, program: Array<Record<string, unknown>>): Promise<number> {
|
|
152
|
+
const r = await driver.post('/v1/host/sample/test/mock-ai/program', { nodeId, program });
|
|
153
|
+
return r.status;
|
|
154
|
+
}
|
|
155
|
+
|
|
116
156
|
describe.skipIf(HTTP_SKIP)('replay-divergence-at-refusal: behavioral (RFC 0041 §B MAE-8)', () => {
|
|
117
157
|
// Behavioral assertion drives a workflow whose mock-AI provider returns a
|
|
118
158
|
// valid envelope on the original run + a refusal on the replay (or
|
|
@@ -127,8 +167,148 @@ describe.skipIf(HTTP_SKIP)('replay-divergence-at-refusal: behavioral (RFC 0041
|
|
|
127
167
|
// originalEnvelopeKind === 'valid' AND replayEnvelopeKind === 'refusal'.
|
|
128
168
|
// 7. Assert NO silent substitution: the replay's continuation past the
|
|
129
169
|
// diverging node MUST NOT execute (run terminates at the divergence).
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
170
|
+
|
|
171
|
+
async function gateOnPhase4(ctx: { skip: () => void }): Promise<boolean> {
|
|
172
|
+
const d = await readDiscovery();
|
|
173
|
+
const rd = d?.capabilities?.multiAgent?.executionModel?.replayDeterminism;
|
|
174
|
+
if (rd?.supported !== true || rd?.refusalDivergenceEmission !== true) {
|
|
175
|
+
ctx.skip();
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it('Phase 4 host MUST emit replay.divergedAtRefusal + fail with replay_diverged_at_refusal when original=valid + replay=refusal', async (ctx) => {
|
|
182
|
+
if (!(await gateOnPhase4(ctx))) return;
|
|
183
|
+
|
|
184
|
+
const NODE_ID = 'structured-call';
|
|
185
|
+
// Original program: valid envelope. Replay program (set after the
|
|
186
|
+
// original completes): refusal. Programming twice is the spec-canonical
|
|
187
|
+
// pattern — see spec/v1/host-sample-test-seams.md §5.
|
|
188
|
+
const validEnv = '{"valid":true}';
|
|
189
|
+
const programStatus = await programMock(NODE_ID, [
|
|
190
|
+
{ content: validEnv, stopReason: 'end_turn' as const },
|
|
191
|
+
]);
|
|
192
|
+
if (programStatus === 404) {
|
|
193
|
+
ctx.skip(); // mock-AI program seam not exposed — soft-skip
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
expect(programStatus).toBe(200);
|
|
197
|
+
|
|
198
|
+
const createRes = await driver.post('/v1/runs', {
|
|
199
|
+
workflowId: 'conformance-phase4-replay-divergence',
|
|
200
|
+
});
|
|
201
|
+
if (createRes.status === 404 || createRes.status === 422) {
|
|
202
|
+
ctx.skip(); // fixture not advertised
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
expect(createRes.status).toBe(201);
|
|
206
|
+
const sourceRunId = (createRes.json as { runId: string }).runId;
|
|
207
|
+
const sourceTerminal = await pollUntilTerminal(sourceRunId);
|
|
208
|
+
expect(sourceTerminal.status).toBe('completed');
|
|
209
|
+
|
|
210
|
+
// Stage refusal for the replay's mock-AI dispatch.
|
|
211
|
+
await programMock(NODE_ID, [
|
|
212
|
+
{ content: 'safety-refused-for-conformance', stopReason: 'safety' as const, refusalText: 'safety-refused-for-conformance' },
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
const forkRes = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, {
|
|
216
|
+
fromSeq: 0,
|
|
217
|
+
mode: 'replay',
|
|
218
|
+
});
|
|
219
|
+
expect(forkRes.status).toBe(201);
|
|
220
|
+
const replayRunId = (forkRes.json as { runId: string }).runId;
|
|
221
|
+
const replayTerminal = await pollUntilTerminal(replayRunId);
|
|
222
|
+
|
|
223
|
+
expect(
|
|
224
|
+
replayTerminal.status,
|
|
225
|
+
driver.describe(
|
|
226
|
+
'RFCS/0041-multi-agent-replay-under-nondeterminism.md §B + spec/v1/rest-endpoints.md §"Common error codes"',
|
|
227
|
+
'replay MUST terminate `failed` when refusal-divergence is detected (silent substitution is non-conformant)',
|
|
228
|
+
),
|
|
229
|
+
).toBe('failed');
|
|
230
|
+
expect(
|
|
231
|
+
replayTerminal.error?.code,
|
|
232
|
+
driver.describe(
|
|
233
|
+
'spec/v1/rest-endpoints.md §"Common error codes" — replay_diverged_at_refusal',
|
|
234
|
+
'error.code MUST be `replay_diverged_at_refusal` per the canonical catalog',
|
|
235
|
+
),
|
|
236
|
+
).toBe('replay_diverged_at_refusal');
|
|
237
|
+
|
|
238
|
+
const replayEvents = await readEvents(replayRunId);
|
|
239
|
+
const divergenceEvent = replayEvents.find((e) => e.type === 'replay.divergedAtRefusal');
|
|
240
|
+
expect(
|
|
241
|
+
divergenceEvent,
|
|
242
|
+
driver.describe(
|
|
243
|
+
'schemas/run-event-payloads.schema.json §replayDivergedAtRefusal',
|
|
244
|
+
'replay event log MUST contain exactly one `replay.divergedAtRefusal` event identifying the divergence',
|
|
245
|
+
),
|
|
246
|
+
).toBeDefined();
|
|
247
|
+
expect(divergenceEvent?.payload?.sourceRunId).toBe(sourceRunId);
|
|
248
|
+
expect(divergenceEvent?.payload?.nodeId).toBe(NODE_ID);
|
|
249
|
+
expect(
|
|
250
|
+
divergenceEvent?.payload?.originalEnvelopeKind,
|
|
251
|
+
driver.describe(
|
|
252
|
+
'schemas/run-event-payloads.schema.json §replayDivergedAtRefusal.originalEnvelopeKind',
|
|
253
|
+
'originalEnvelopeKind MUST be `valid` (source run completed normally)',
|
|
254
|
+
),
|
|
255
|
+
).toBe('valid');
|
|
256
|
+
expect(
|
|
257
|
+
divergenceEvent?.payload?.replayEnvelopeKind,
|
|
258
|
+
driver.describe(
|
|
259
|
+
'schemas/run-event-payloads.schema.json §replayDivergedAtRefusal.replayEnvelopeKind',
|
|
260
|
+
'replayEnvelopeKind MUST be `refusal` (replay hit the refusal entry of the mock program)',
|
|
261
|
+
),
|
|
262
|
+
).toBe('refusal');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('Phase 4 host MUST emit replay.divergedAtRefusal + fail with replay_diverged_at_refusal when original=refusal + replay=valid (symmetric case)', async (ctx) => {
|
|
266
|
+
if (!(await gateOnPhase4(ctx))) return;
|
|
267
|
+
|
|
268
|
+
const NODE_ID = 'structured-call';
|
|
269
|
+
// Symmetric: original=refusal, replay=valid.
|
|
270
|
+
const programStatus = await programMock(NODE_ID, [
|
|
271
|
+
{ content: 'safety-refused-for-conformance', stopReason: 'safety' as const, refusalText: 'safety-refused-for-conformance' },
|
|
272
|
+
]);
|
|
273
|
+
if (programStatus === 404) {
|
|
274
|
+
ctx.skip();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
expect(programStatus).toBe(200);
|
|
278
|
+
|
|
279
|
+
const createRes = await driver.post('/v1/runs', {
|
|
280
|
+
workflowId: 'conformance-phase4-replay-divergence',
|
|
281
|
+
});
|
|
282
|
+
if (createRes.status === 404 || createRes.status === 422) {
|
|
283
|
+
ctx.skip();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
expect(createRes.status).toBe(201);
|
|
287
|
+
const sourceRunId = (createRes.json as { runId: string }).runId;
|
|
288
|
+
const sourceTerminal = await pollUntilTerminal(sourceRunId);
|
|
289
|
+
// Source run fails because the LLM refused.
|
|
290
|
+
expect(sourceTerminal.status).toBe('failed');
|
|
291
|
+
|
|
292
|
+
// Stage valid envelope for the replay's mock-AI dispatch.
|
|
293
|
+
await programMock(NODE_ID, [
|
|
294
|
+
{ content: '{"valid":true}', stopReason: 'end_turn' as const },
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
const forkRes = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, {
|
|
298
|
+
fromSeq: 0,
|
|
299
|
+
mode: 'replay',
|
|
300
|
+
});
|
|
301
|
+
expect(forkRes.status).toBe(201);
|
|
302
|
+
const replayRunId = (forkRes.json as { runId: string }).runId;
|
|
303
|
+
const replayTerminal = await pollUntilTerminal(replayRunId);
|
|
304
|
+
|
|
305
|
+
expect(replayTerminal.status).toBe('failed');
|
|
306
|
+
expect(replayTerminal.error?.code).toBe('replay_diverged_at_refusal');
|
|
307
|
+
|
|
308
|
+
const replayEvents = await readEvents(replayRunId);
|
|
309
|
+
const divergenceEvent = replayEvents.find((e) => e.type === 'replay.divergedAtRefusal');
|
|
310
|
+
expect(divergenceEvent).toBeDefined();
|
|
311
|
+
expect(divergenceEvent?.payload?.originalEnvelopeKind).toBe('refusal');
|
|
312
|
+
expect(divergenceEvent?.payload?.replayEnvelopeKind).toBe('valid');
|
|
313
|
+
});
|
|
134
314
|
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `capabilities.multiAgent.executionModel.version >= 4` AND
|
|
6
6
|
* `capabilities.multiAgent.executionModel.replayDeterminism.supported: true`.
|
|
7
7
|
*
|
|
8
|
-
* Asserts (behavioral, when a
|
|
8
|
+
* Asserts (behavioral, when a host advertises `version: 4` + the contract):
|
|
9
9
|
*
|
|
10
10
|
* 1. A `mode: replay` fork from event-log index `fromSeq` produces an
|
|
11
11
|
* event-log prefix `[0, fromSeq]` that is byte-equivalent to the
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
* Driving the assertion requires a workflow fixture whose tool call is
|
|
27
27
|
* pure-nondeterministic (different bytes on each call) but whose
|
|
28
28
|
* observable result is what gets cached. Reference workflow-engine ships
|
|
29
|
-
* `core.noop` + deterministic fixtures;
|
|
29
|
+
* `core.noop` + deterministic fixtures; the `version: 4` wiring needs a
|
|
30
30
|
* nondeterministic-tool fixture (e.g., `conformance-phase4-nondet-tool`).
|
|
31
31
|
* Until that lands, the cross-boundary assertion is surfaced as `it.todo`
|
|
32
32
|
* so test reporters track the gap.
|
|
33
33
|
*
|
|
34
34
|
* @see RFCS/0041-multi-agent-replay-under-nondeterminism.md §C
|
|
35
35
|
* @see spec/v1/replay.md §"Observable-output-sequence determinism vs bit-equivalent execution (MAE-9 closure)"
|
|
36
|
-
* @see spec/v1/multi-agent-execution.md §"
|
|
36
|
+
* @see spec/v1/multi-agent-execution.md §"Replay determinism under nondeterminism (RFC 0041)"
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { describe, it } from 'vitest';
|
|
@@ -62,10 +62,19 @@ describe('replay-observable-sequence-determinism: prefix byte-equivalence (RFC 0
|
|
|
62
62
|
// 6. Read original + replay RunSnapshot at index N; assert
|
|
63
63
|
// variables + channels + status byte-equivalent.
|
|
64
64
|
// Surfaced as `todo` until the `conformance-phase4-nondet-tool`
|
|
65
|
-
// fixture ships in the suite — consistent with the sibling
|
|
65
|
+
// fixture ships in the suite — consistent with the sibling RFC 0041
|
|
66
66
|
// scenarios (`replay-divergence-at-refusal.test.ts`,
|
|
67
67
|
// `replay-llm-cache-key-portable.test.ts`).
|
|
68
|
-
|
|
68
|
+
// Marked out of stable profile via RFC 0042 §B (experimental tier):
|
|
69
|
+
// RFC 0041 §C remains Active, so its wire shape MAY shift compatibly
|
|
70
|
+
// within v1.x. Hosts that wire this assertion before RFC 0041 graduates
|
|
71
|
+
// to Accepted SHOULD advertise `multiAgent.executionModel.tier:
|
|
72
|
+
// 'experimental'` + `experimentalUntil` per RFC 0042 §A. Path-to-runnable
|
|
73
|
+
// requires: (a) host pure-replay observable-cache emission via the
|
|
74
|
+
// `:fork mode: replay` re-dispatch path and (b) the test seam endpoint
|
|
75
|
+
// contract for cache-hit-vs-fresh-call distinction (see
|
|
76
|
+
// `spec/v1/host-sample-test-seams.md` for the established seam pattern).
|
|
77
|
+
it.skip('original and replay event-log prefixes [0, fromSeq] MUST be byte-equivalent (modulo per-region clock + ULID-T entropy) — out of stable profile via RFC 0042');
|
|
69
78
|
});
|
|
70
79
|
|
|
71
80
|
describe('replay-observable-sequence-determinism: observable-result caching (RFC 0041 §C)', () => {
|
|
@@ -76,5 +85,10 @@ describe('replay-observable-sequence-determinism: observable-result caching (RFC
|
|
|
76
85
|
// this a valid determinism contract — bit-equivalent execution would
|
|
77
86
|
// require unbounded caching (rejected per RFC 0041 §"Alternatives
|
|
78
87
|
// considered" #2).
|
|
79
|
-
|
|
88
|
+
// Marked out of stable profile via RFC 0042 §B (experimental tier):
|
|
89
|
+
// see the prefix-byte-equivalence comment above for the same routing.
|
|
90
|
+
// This is RFC 0041 §C's load-bearing assertion; it lands as a runnable
|
|
91
|
+
// `it()` when RFC 0041 graduates to Accepted on first non-steward host
|
|
92
|
+
// adoption.
|
|
93
|
+
it.skip('replay of a workflow containing a nondeterministic tool call reproduces the original observable result, NOT a fresh call — out of stable profile via RFC 0042');
|
|
80
94
|
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0054 — run diff & execution comparison.
|
|
3
|
+
*
|
|
4
|
+
* Exercises `GET /v1/runs/{runId}:diff?against={otherRunId}` per
|
|
5
|
+
* `spec/v1/rest-endpoints.md` §"GET /v1/runs/{runId}:diff" and
|
|
6
|
+
* `schemas/run-diff-response.schema.json`. The endpoint is OPTIONAL —
|
|
7
|
+
* hosts that don't implement it return 404 and these scenarios soft-skip.
|
|
8
|
+
*
|
|
9
|
+
* Coverage:
|
|
10
|
+
* - identical: diffing a run against itself ⇒ divergedAtSeq null,
|
|
11
|
+
* empty eventDiffs (the determinism floor).
|
|
12
|
+
* - divergence: diffing two structurally-different runs ⇒ a non-null
|
|
13
|
+
* integer divergedAtSeq; eventDiffs begin at that seq.
|
|
14
|
+
* - state-shape: response conforms to run-diff-response.schema.json and
|
|
15
|
+
* stateDiff is redaction-safe (no credential-shaped keys).
|
|
16
|
+
* - error-surface: missing `against` ⇒ 400; nonexistent `against` ⇒ 404
|
|
17
|
+
* (the access boundary; full cross-principal authz needs a
|
|
18
|
+
* multi-principal harness — host-specific).
|
|
19
|
+
*
|
|
20
|
+
* @see RFCS/0054-run-diff-and-execution-comparison.md
|
|
21
|
+
* @see api/openapi.yaml §diffRun
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest';
|
|
25
|
+
import { driver } from '../lib/driver.js';
|
|
26
|
+
|
|
27
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
28
|
+
|
|
29
|
+
// Two standard conformance fixtures with structurally-different event
|
|
30
|
+
// logs — diffing one against the other is a deterministic divergence.
|
|
31
|
+
const FIXTURE_A = 'conformance-agent-reasoning';
|
|
32
|
+
const FIXTURE_B = 'conformance-dispatch-loop';
|
|
33
|
+
|
|
34
|
+
interface DiffResponse {
|
|
35
|
+
a: string;
|
|
36
|
+
b: string;
|
|
37
|
+
divergedAtSeq: number | null;
|
|
38
|
+
eventDiffs: Array<{ seq: number; op: string; aEvent?: unknown; bEvent?: unknown }>;
|
|
39
|
+
stateDiff: Record<string, unknown>;
|
|
40
|
+
truncated?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function createRun(workflowId: string): Promise<string | null> {
|
|
44
|
+
const res = await driver.post('/v1/runs', { workflowId });
|
|
45
|
+
if (res.status !== 201) return null;
|
|
46
|
+
return (res.json as { runId: string }).runId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Poll until the run is terminal (best-effort; bounded). */
|
|
50
|
+
async function settle(runId: string): Promise<void> {
|
|
51
|
+
for (let i = 0; i < 20; i++) {
|
|
52
|
+
const r = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
53
|
+
const status = (r.json as { status?: string })?.status;
|
|
54
|
+
if (status === 'completed' || status === 'failed' || status === 'cancelled') return;
|
|
55
|
+
await new Promise((res) => setTimeout(res, 100));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe.skipIf(HTTP_SKIP)('run-diff: GET /v1/runs/{runId}:diff (RFC 0054)', () => {
|
|
60
|
+
it('diffing a run against itself ⇒ divergedAtSeq null + empty eventDiffs', async (ctx) => {
|
|
61
|
+
const runId = await createRun(FIXTURE_A);
|
|
62
|
+
if (!runId) { ctx.skip(); return; }
|
|
63
|
+
await settle(runId);
|
|
64
|
+
|
|
65
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}:diff?against=${encodeURIComponent(runId)}`);
|
|
66
|
+
if (res.status === 404) { ctx.skip(); return; } // endpoint not implemented
|
|
67
|
+
expect(res.status, driver.describe('spec/v1/rest-endpoints.md §:diff', 'self-diff MUST return 200')).toBe(200);
|
|
68
|
+
|
|
69
|
+
const body = res.json as DiffResponse;
|
|
70
|
+
expect(body.a).toBe(runId);
|
|
71
|
+
expect(body.b).toBe(runId);
|
|
72
|
+
expect(
|
|
73
|
+
body.divergedAtSeq,
|
|
74
|
+
driver.describe('RFCS/0054 §C', 'identical logs MUST yield divergedAtSeq: null'),
|
|
75
|
+
).toBeNull();
|
|
76
|
+
expect(body.eventDiffs, 'identical logs MUST yield an empty eventDiffs array').toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('diffing two structurally-different runs ⇒ non-null divergedAtSeq aligned to eventDiffs[0]', async (ctx) => {
|
|
80
|
+
const [ra, rb] = await Promise.all([createRun(FIXTURE_A), createRun(FIXTURE_B)]);
|
|
81
|
+
if (!ra || !rb) { ctx.skip(); return; }
|
|
82
|
+
await Promise.all([settle(ra), settle(rb)]);
|
|
83
|
+
|
|
84
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(ra)}:diff?against=${encodeURIComponent(rb)}`);
|
|
85
|
+
if (res.status === 404) { ctx.skip(); return; }
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
|
|
88
|
+
const body = res.json as DiffResponse;
|
|
89
|
+
expect(
|
|
90
|
+
typeof body.divergedAtSeq === 'number' && body.divergedAtSeq >= 0,
|
|
91
|
+
driver.describe('RFCS/0054 §C', 'structurally-different runs MUST report a non-null integer divergedAtSeq'),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
expect(body.eventDiffs.length, 'divergent runs MUST report at least one eventDiff').toBeGreaterThan(0);
|
|
94
|
+
expect(
|
|
95
|
+
body.eventDiffs[0]?.seq,
|
|
96
|
+
driver.describe('RFCS/0054 §C', 'eventDiffs MUST begin at divergedAtSeq'),
|
|
97
|
+
).toBe(body.divergedAtSeq);
|
|
98
|
+
for (const d of body.eventDiffs) {
|
|
99
|
+
expect(['added', 'removed', 'changed']).toContain(d.op);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('response conforms to run-diff-response.schema.json and stateDiff is redaction-safe', async (ctx) => {
|
|
104
|
+
const [ra, rb] = await Promise.all([createRun(FIXTURE_A), createRun(FIXTURE_B)]);
|
|
105
|
+
if (!ra || !rb) { ctx.skip(); return; }
|
|
106
|
+
await Promise.all([settle(ra), settle(rb)]);
|
|
107
|
+
|
|
108
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(ra)}:diff?against=${encodeURIComponent(rb)}`);
|
|
109
|
+
if (res.status === 404) { ctx.skip(); return; }
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
|
|
112
|
+
const body = res.json as DiffResponse;
|
|
113
|
+
expect(typeof body.a === 'string' && typeof body.b === 'string', 'a + b MUST be strings').toBe(true);
|
|
114
|
+
expect(Array.isArray(body.eventDiffs), 'eventDiffs MUST be an array').toBe(true);
|
|
115
|
+
expect(body.stateDiff !== null && typeof body.stateDiff === 'object', 'stateDiff MUST be an object').toBe(true);
|
|
116
|
+
// Redaction-safe: no credential-shaped material leaks into the diff.
|
|
117
|
+
const serialized = JSON.stringify(body.stateDiff);
|
|
118
|
+
expect(
|
|
119
|
+
/sk-|api[_-]?key|secret|bearer\s/i.test(serialized),
|
|
120
|
+
driver.describe('RFCS/0054 §B', 'stateDiff MUST be redaction-safe — no credential material'),
|
|
121
|
+
).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('missing `against` ⇒ 400; nonexistent `against` ⇒ 404 (access boundary)', async (ctx) => {
|
|
125
|
+
const runId = await createRun(FIXTURE_A);
|
|
126
|
+
if (!runId) { ctx.skip(); return; }
|
|
127
|
+
|
|
128
|
+
const probe = await driver.get(`/v1/runs/${encodeURIComponent(runId)}:diff?against=${encodeURIComponent(runId)}`);
|
|
129
|
+
if (probe.status === 404) { ctx.skip(); return; } // endpoint not implemented at all
|
|
130
|
+
|
|
131
|
+
const missing = await driver.get(`/v1/runs/${encodeURIComponent(runId)}:diff`);
|
|
132
|
+
expect(
|
|
133
|
+
missing.status,
|
|
134
|
+
driver.describe('api/openapi.yaml §diffRun', 'missing required `against` query param MUST return 400'),
|
|
135
|
+
).toBe(400);
|
|
136
|
+
|
|
137
|
+
const nonexistent = await driver.get(`/v1/runs/${encodeURIComponent(runId)}:diff?against=does-not-exist-${Date.now()}`);
|
|
138
|
+
expect(
|
|
139
|
+
nonexistent.status,
|
|
140
|
+
driver.describe('RFCS/0054 §A', 'diffing against a run the caller cannot read/that does not exist MUST NOT return 200'),
|
|
141
|
+
).toBe(404);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -23,5 +23,11 @@ import { describe, it } from 'vitest';
|
|
|
23
23
|
// reporting a vacuous PASS.
|
|
24
24
|
|
|
25
25
|
describe('sandbox-capability-gate-respected: behavioral (RFC 0035 §B)', () => {
|
|
26
|
-
|
|
26
|
+
// Behavioral coverage in `sandbox-mvp-behavior.test.ts` §"capability-gate-respected"
|
|
27
|
+
// (drives `POST /v1/host/sample/test/sandbox-invoke` against the
|
|
28
|
+
// workflow-engine's node:vm MVP and asserts `error.code:
|
|
29
|
+
// 'sandbox_capability_denied'` + `details.requestedCapability` per
|
|
30
|
+
// `host-capabilities.md` §"Error codes"). `it.skip` preserves the
|
|
31
|
+
// per-invariant file structure without inflating the `it.todo` count.
|
|
32
|
+
it.skip('behavioral coverage in sandbox-mvp-behavior.test.ts §"capability-gate-respected"');
|
|
27
33
|
});
|
|
@@ -50,9 +50,11 @@ describe.skipIf(HTTP_SKIP)('sandbox-memory-cap: capability shape + behavioral (R
|
|
|
50
50
|
).toBe(true);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
// Behavioral
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
it.
|
|
53
|
+
// Behavioral coverage in `sandbox-mvp-behavior.test.ts` §"memory-exceeded"
|
|
54
|
+
// (drives `POST /v1/host/sample/test/sandbox-invoke` against the
|
|
55
|
+
// workflow-engine's node:vm MVP and asserts `error.code:
|
|
56
|
+
// 'sandbox_memory_exceeded'` per `host-capabilities.md` §"Error codes").
|
|
57
|
+
// `it.skip` preserves the per-invariant file structure without inflating
|
|
58
|
+
// the `it.todo` count external auditors track.
|
|
59
|
+
it.skip('behavioral coverage in sandbox-mvp-behavior.test.ts §"memory-exceeded"');
|
|
58
60
|
});
|