@openwop/openwop-conformance 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +2 -2
- package/coverage.md +26 -14
- package/fixtures/conformance-agent-low-confidence.json +7 -4
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
- package/fixtures/conformance-agent-reasoning.json +23 -4
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
- package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
- package/fixtures/conformance-dispatch-input-mapping.json +49 -0
- package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
- package/fixtures/conformance-dispatch-output-mapping.json +49 -0
- package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +12 -2
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/agent-ref.schema.json +1 -1
- package/schemas/ai-envelope.schema.json +106 -0
- package/schemas/capabilities.schema.json +248 -0
- package/schemas/core-conformance-mock-agent-config.schema.json +147 -0
- package/schemas/dispatch-config.schema.json +26 -0
- package/schemas/envelopes/clarification.request.schema.json +43 -0
- package/schemas/envelopes/error.schema.json +26 -0
- package/schemas/envelopes/schema.request.schema.json +22 -0
- package/schemas/envelopes/schema.response.schema.json +22 -0
- package/schemas/node-pack-manifest.schema.json +5 -0
- package/schemas/pack-lockfile.schema.json +16 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
- package/src/lib/webhook-receiver.ts +137 -0
- package/src/lib/workflow-chain-expansion.ts +213 -0
- package/src/scenarios/agentPackCatalog.test.ts +216 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
- package/src/scenarios/agentReasoningEvents.test.ts +58 -7
- package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
- package/src/scenarios/ai-envelope-shape.test.ts +362 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +173 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +150 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +69 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +73 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +87 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +143 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +176 -0
- package/src/scenarios/append-ordering.test.ts +44 -0
- package/src/scenarios/artifact-auth.test.ts +58 -0
- package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/blob-presign-expiry.test.ts +66 -0
- package/src/scenarios/blob-roundtrip.test.ts +48 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +47 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +98 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +94 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +65 -0
- package/src/scenarios/fs-path-traversal.test.ts +124 -0
- package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
- package/src/scenarios/kv-atomic-increment.test.ts +74 -0
- package/src/scenarios/kv-cas.test.ts +75 -0
- package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
- package/src/scenarios/kv-ttl-expiry.test.ts +47 -0
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
- package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
- package/src/scenarios/pause-resume.test.ts +43 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +67 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +48 -0
- package/src/scenarios/search-bm25-roundtrip.test.ts +47 -0
- package/src/scenarios/spec-corpus-validity.test.ts +17 -1
- package/src/scenarios/sql-injection-rejection.test.ts +84 -0
- package/src/scenarios/sql-transaction-atomicity.test.ts +66 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +66 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +100 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +47 -0
- package/src/scenarios/table-schema-enforcement.test.ts +47 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +48 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
- package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
- package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs-path-traversal — RFC 0014 §C invariant verification.
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE. RFC 0014 promoted to `Active` 2026-05-17. The
|
|
5
|
+
* `capabilities.fs` block has landed in `schemas/capabilities.schema.json`
|
|
6
|
+
* and the invariant row `fs-path-traversal` is in `SECURITY/invariants.yaml`.
|
|
7
|
+
*
|
|
8
|
+
* Capability-gated: skips when the host does not advertise
|
|
9
|
+
* `capabilities.fs.supported = true`.
|
|
10
|
+
*
|
|
11
|
+
* What this scenario asserts:
|
|
12
|
+
* 1. Advertisement shape — `capabilities.fs` is either absent or a
|
|
13
|
+
* well-formed object; when `supported: true`, `sandboxRoot` is
|
|
14
|
+
* non-empty (RFC 0014 §A).
|
|
15
|
+
* 2. Path-traversal MUST-NOT — when the host exposes the optional
|
|
16
|
+
* `POST /v1/host/sample/fs/read` test seam, absolute paths outside
|
|
17
|
+
* the sandbox AND relative `../` escapes MUST be rejected with a
|
|
18
|
+
* 4xx envelope whose `error.code` is in the canonical rejection set.
|
|
19
|
+
*
|
|
20
|
+
* Hosts without the test seam soft-skip the path-escape steps and still
|
|
21
|
+
* assert the advertisement shape. This lets the suite pass against the
|
|
22
|
+
* stricter hosts that don't expose any unauthenticated fs probe.
|
|
23
|
+
*
|
|
24
|
+
* @see RFCS/0014-host-fs-capability.md
|
|
25
|
+
* @see SECURITY/invariants.yaml id: fs-path-traversal
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { driver } from '../lib/driver.js';
|
|
30
|
+
|
|
31
|
+
interface DiscoveryFs {
|
|
32
|
+
supported?: boolean;
|
|
33
|
+
sandboxRoot?: string;
|
|
34
|
+
maxFileSizeBytes?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DiscoveryDoc {
|
|
38
|
+
capabilities?: {
|
|
39
|
+
fs?: DiscoveryFs;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readFs(): Promise<DiscoveryFs | null> {
|
|
44
|
+
const res = await driver.get('/.well-known/openwop');
|
|
45
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
46
|
+
return body?.capabilities?.fs ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PATH_REJECTION_CODES: ReadonlySet<string> = new Set([
|
|
50
|
+
'path_outside_sandbox',
|
|
51
|
+
'fs_path_traversal',
|
|
52
|
+
'invalid_path',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
describe('fs-path-traversal: advertisement shape (RFC 0014 §A)', () => {
|
|
56
|
+
it('capabilities.fs is either absent or well-formed', async () => {
|
|
57
|
+
const fs = await readFs();
|
|
58
|
+
if (fs === null) return; // host doesn't advertise fs at all
|
|
59
|
+
expect(
|
|
60
|
+
typeof fs.supported,
|
|
61
|
+
driver.describe(
|
|
62
|
+
'capabilities.schema.json §fs',
|
|
63
|
+
'capabilities.fs.supported MUST be a boolean when fs is advertised',
|
|
64
|
+
),
|
|
65
|
+
).toBe('boolean');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fs.sandboxRoot is set + non-empty when fs.supported=true', async () => {
|
|
69
|
+
const fs = await readFs();
|
|
70
|
+
if (!fs?.supported) return;
|
|
71
|
+
expect(
|
|
72
|
+
typeof fs.sandboxRoot,
|
|
73
|
+
driver.describe(
|
|
74
|
+
'RFC 0014 §A',
|
|
75
|
+
'capabilities.fs.sandboxRoot MUST be present when fs.supported=true',
|
|
76
|
+
),
|
|
77
|
+
).toBe('string');
|
|
78
|
+
expect(
|
|
79
|
+
(fs.sandboxRoot ?? '').length,
|
|
80
|
+
driver.describe('RFC 0014 §A', 'capabilities.fs.sandboxRoot MUST NOT be empty'),
|
|
81
|
+
).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('fs-path-traversal: MUST-NOT escape sandboxRoot (RFC 0014 §C)', () => {
|
|
86
|
+
it('absolute path outside sandbox is rejected', async () => {
|
|
87
|
+
const fs = await readFs();
|
|
88
|
+
if (!fs?.supported) return;
|
|
89
|
+
const res = await driver.post('/v1/host/sample/fs/read', { path: '/etc/passwd' });
|
|
90
|
+
// 404 from a host that hasn't wired the test seam is a soft-skip.
|
|
91
|
+
if (res.status === 404) return;
|
|
92
|
+
expect(
|
|
93
|
+
res.status,
|
|
94
|
+
driver.describe(
|
|
95
|
+
'SECURITY/invariants.yaml fs-path-traversal',
|
|
96
|
+
'absolute paths outside sandboxRoot MUST be rejected with a 4xx envelope',
|
|
97
|
+
),
|
|
98
|
+
).toBeGreaterThanOrEqual(400);
|
|
99
|
+
const code = (res.json as { error?: { code?: string } } | undefined)?.error?.code;
|
|
100
|
+
expect(
|
|
101
|
+
code !== undefined && PATH_REJECTION_CODES.has(code),
|
|
102
|
+
driver.describe(
|
|
103
|
+
'SECURITY/invariants.yaml fs-path-traversal',
|
|
104
|
+
`error.code MUST be one of {${[...PATH_REJECTION_CODES].join(', ')}}, got: ${code ?? '(absent)'}`,
|
|
105
|
+
),
|
|
106
|
+
).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('relative ../ path escape is rejected', async () => {
|
|
110
|
+
const fs = await readFs();
|
|
111
|
+
if (!fs?.supported) return;
|
|
112
|
+
const res = await driver.post('/v1/host/sample/fs/read', { path: '../../etc/passwd' });
|
|
113
|
+
if (res.status === 404) return;
|
|
114
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
115
|
+
const code = (res.json as { error?: { code?: string } } | undefined)?.error?.code;
|
|
116
|
+
expect(
|
|
117
|
+
code !== undefined && PATH_REJECTION_CODES.has(code),
|
|
118
|
+
driver.describe(
|
|
119
|
+
'SECURITY/invariants.yaml fs-path-traversal',
|
|
120
|
+
`error.code MUST be one of {${[...PATH_REJECTION_CODES].join(', ')}}, got: ${code ?? '(absent)'}`,
|
|
121
|
+
),
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core.openwop.http.idempotency-key — determinism contract
|
|
3
|
+
*
|
|
4
|
+
* Verifies the normative MUST from `idempotency.md §"Idempotency-Key"`
|
|
5
|
+
* and the pack's documented purpose (retry-safety): identical
|
|
6
|
+
* `(runId, nodeId, payload)` → identical key, so the remote endpoint
|
|
7
|
+
* can dedupe across retries.
|
|
8
|
+
*
|
|
9
|
+
* Hardens against the 1.1.0 / 1.1.1 defect where the default `uuid`
|
|
10
|
+
* mode returned a fresh `randomUUID()` per call, defeating retry-safety
|
|
11
|
+
* (each retry produced a different key → remote treated every attempt
|
|
12
|
+
* as a new request). Removed in 1.1.2 as a safety-fix per
|
|
13
|
+
* `COMPATIBILITY.md §3` — see CHANGELOG `[Unreleased]` §"core.openwop.http@1.1.2".
|
|
14
|
+
*
|
|
15
|
+
* Server-free. Loads the pack module via dynamic import and asserts:
|
|
16
|
+
*
|
|
17
|
+
* 1. Default mode (`composite`) produces a deterministic key for
|
|
18
|
+
* identical inputs.
|
|
19
|
+
* 2. Payload sensitivity — same run+node, different payload → different key.
|
|
20
|
+
* 3. Run isolation — same node+payload, different run → different key.
|
|
21
|
+
* 4. Node isolation — same run+payload, different node → different key.
|
|
22
|
+
* 5. `hash` mode is deterministic in the payload alone (run+node ignored).
|
|
23
|
+
* 6. Output shape matches the canonical `^openwop-[0-9a-f]{16}$` pattern
|
|
24
|
+
* (per `idempotency-key.output.json` 1.1.2).
|
|
25
|
+
* 7. Removed `uuid` mode rejects with `CONFIG_INVALID` — the safety-fix
|
|
26
|
+
* surfaces loudly rather than silently producing weak keys.
|
|
27
|
+
* 8. Pack output matches the canonical SHA-256 formula computed
|
|
28
|
+
* independently in this test — third-party pack reimplementations
|
|
29
|
+
* can run the same scenario against their runtime to verify
|
|
30
|
+
* cross-impl agreement.
|
|
31
|
+
*
|
|
32
|
+
* Skip-conditions: none. The scenario validates the spec corpus +
|
|
33
|
+
* reference pack source directly; runs in any environment with the
|
|
34
|
+
* repo checked out.
|
|
35
|
+
*
|
|
36
|
+
* @see spec/v1/idempotency.md §"Idempotency-Key"
|
|
37
|
+
* @see packs/core.openwop.http/schemas/idempotency-key.config.json
|
|
38
|
+
* @see packs/core.openwop.http/schemas/idempotency-key.output.json
|
|
39
|
+
* @see SECURITY/invariants.yaml#idempotency-key-deterministic
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
44
|
+
import { existsSync } from 'node:fs';
|
|
45
|
+
import { dirname, resolve } from 'node:path';
|
|
46
|
+
import { fileURLToPath } from 'node:url';
|
|
47
|
+
|
|
48
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const PACK_PATH = resolve(__dirname, '../../../packs/core.openwop.http/index.mjs');
|
|
50
|
+
|
|
51
|
+
interface IdempotencyKeyCtx {
|
|
52
|
+
config?: { mode?: string };
|
|
53
|
+
inputs?: { payload?: unknown; runId?: string; nodeId?: string };
|
|
54
|
+
runId?: string;
|
|
55
|
+
nodeId?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface IdempotencyKeyResult {
|
|
59
|
+
status: 'success';
|
|
60
|
+
outputs: { key: string };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type IdempotencyKeyFn = (ctx: IdempotencyKeyCtx) => Promise<IdempotencyKeyResult>;
|
|
64
|
+
|
|
65
|
+
const KEY_PATTERN = /^openwop-[0-9a-f]{16}$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Canonical formula per `idempotency-key.{config,output}.json` (1.1.2):
|
|
69
|
+
* sha256( runId || \0 || nodeId || \0 || JSON.stringify(payload) )
|
|
70
|
+
* key = 'openwop-' + digest.hex().slice(0, 16)
|
|
71
|
+
*
|
|
72
|
+
* Mirrors `deriveIdempotencyKey` in `packs/core.openwop.http/index.mjs`
|
|
73
|
+
* (the same formula `core.openwop.http.fetch` uses internally). Inlined
|
|
74
|
+
* here so the test verifies the spec contract independent of the pack
|
|
75
|
+
* runtime — a third-party reimplementation that follows the spec MUST
|
|
76
|
+
* produce identical keys for these vectors.
|
|
77
|
+
*/
|
|
78
|
+
function canonicalCompositeKey(runId: string, nodeId: string, payload: unknown): string {
|
|
79
|
+
const h = createHash('sha256');
|
|
80
|
+
h.update(runId, 'utf8');
|
|
81
|
+
h.update('\0', 'utf8');
|
|
82
|
+
h.update(nodeId, 'utf8');
|
|
83
|
+
h.update('\0', 'utf8');
|
|
84
|
+
if (payload !== undefined && payload !== null) {
|
|
85
|
+
h.update(JSON.stringify(payload), 'utf8');
|
|
86
|
+
}
|
|
87
|
+
return 'openwop-' + h.digest('hex').slice(0, 16);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function canonicalHashKey(payload: unknown): string {
|
|
91
|
+
const h = createHash('sha256').update(JSON.stringify(payload ?? null), 'utf8').digest('hex');
|
|
92
|
+
return 'openwop-' + h.slice(0, 16);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('category: core.openwop.http.idempotency-key — determinism contract', () => {
|
|
96
|
+
let idempotencyKey: IdempotencyKeyFn;
|
|
97
|
+
let packAvailable: boolean;
|
|
98
|
+
|
|
99
|
+
beforeAll(async () => {
|
|
100
|
+
packAvailable = existsSync(PACK_PATH);
|
|
101
|
+
if (!packAvailable) return;
|
|
102
|
+
const mod = (await import(PACK_PATH)) as { idempotencyKey?: IdempotencyKeyFn };
|
|
103
|
+
if (typeof mod.idempotencyKey !== 'function') {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`expected packs/core.openwop.http/index.mjs to export idempotencyKey; got ${typeof mod.idempotencyKey}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
idempotencyKey = mod.idempotencyKey;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('skips cleanly when packs/ is not bundled', () => {
|
|
112
|
+
// This scenario reads the in-tree pack source. When the conformance
|
|
113
|
+
// suite is consumed as a published npm package, packs/ isn't shipped
|
|
114
|
+
// — the scenario soft-skips rather than failing.
|
|
115
|
+
if (!packAvailable) {
|
|
116
|
+
console.warn(`[idempotency-key-determinism] packs/core.openwop.http/index.mjs not present; skipping`);
|
|
117
|
+
expect(packAvailable).toBe(false);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
expect(packAvailable).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('default mode (composite) — identical (runId, nodeId, payload) produces identical keys', async () => {
|
|
124
|
+
if (!packAvailable) return;
|
|
125
|
+
const ctx: IdempotencyKeyCtx = {
|
|
126
|
+
runId: 'run-1',
|
|
127
|
+
nodeId: 'node-1',
|
|
128
|
+
inputs: { payload: { hello: 'world' } },
|
|
129
|
+
};
|
|
130
|
+
const a = await idempotencyKey(ctx);
|
|
131
|
+
const b = await idempotencyKey(ctx);
|
|
132
|
+
expect(a.outputs.key, 'idempotency.md §Idempotency-Key: same logical request MUST produce same key').toBe(b.outputs.key);
|
|
133
|
+
expect(a.outputs.key).toMatch(KEY_PATTERN);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('payload sensitivity — different payload produces different key', async () => {
|
|
137
|
+
if (!packAvailable) return;
|
|
138
|
+
const baseCtx = { runId: 'run-1', nodeId: 'node-1' };
|
|
139
|
+
const a = await idempotencyKey({ ...baseCtx, inputs: { payload: { hello: 'world' } } });
|
|
140
|
+
const b = await idempotencyKey({ ...baseCtx, inputs: { payload: { hello: 'CHANGED' } } });
|
|
141
|
+
expect(a.outputs.key).not.toBe(b.outputs.key);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('run isolation — different runId produces different key', async () => {
|
|
145
|
+
if (!packAvailable) return;
|
|
146
|
+
const payload = { hello: 'world' };
|
|
147
|
+
const a = await idempotencyKey({ runId: 'run-1', nodeId: 'node-1', inputs: { payload } });
|
|
148
|
+
const b = await idempotencyKey({ runId: 'run-2', nodeId: 'node-1', inputs: { payload } });
|
|
149
|
+
expect(a.outputs.key).not.toBe(b.outputs.key);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('node isolation — different nodeId produces different key', async () => {
|
|
153
|
+
if (!packAvailable) return;
|
|
154
|
+
const payload = { hello: 'world' };
|
|
155
|
+
const a = await idempotencyKey({ runId: 'run-1', nodeId: 'node-A', inputs: { payload } });
|
|
156
|
+
const b = await idempotencyKey({ runId: 'run-1', nodeId: 'node-B', inputs: { payload } });
|
|
157
|
+
expect(a.outputs.key).not.toBe(b.outputs.key);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('hash mode is deterministic in the payload alone (run/node ignored)', async () => {
|
|
161
|
+
if (!packAvailable) return;
|
|
162
|
+
const payload = { request: 'payload-here', id: 42 };
|
|
163
|
+
const a = await idempotencyKey({ config: { mode: 'hash' }, runId: 'run-1', nodeId: 'node-1', inputs: { payload } });
|
|
164
|
+
const b = await idempotencyKey({ config: { mode: 'hash' }, runId: 'run-2', nodeId: 'node-2', inputs: { payload } });
|
|
165
|
+
expect(a.outputs.key, 'hash mode MUST ignore run/node — useful for global remote-dedup caches').toBe(b.outputs.key);
|
|
166
|
+
expect(a.outputs.key).toMatch(KEY_PATTERN);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('output shape — every emitted key matches openwop-<sha256-prefix-16>', async () => {
|
|
170
|
+
if (!packAvailable) return;
|
|
171
|
+
const samples: IdempotencyKeyCtx[] = [
|
|
172
|
+
{ runId: 'r', nodeId: 'n', inputs: { payload: null } },
|
|
173
|
+
{ runId: 'r', nodeId: 'n', inputs: { payload: 'string-payload' } },
|
|
174
|
+
{ runId: 'r', nodeId: 'n', inputs: { payload: [1, 2, 3] } },
|
|
175
|
+
{ runId: 'r', nodeId: 'n', inputs: { payload: { nested: { value: true } } } },
|
|
176
|
+
{ config: { mode: 'hash' }, inputs: { payload: 'x' } },
|
|
177
|
+
];
|
|
178
|
+
for (const ctx of samples) {
|
|
179
|
+
const result = await idempotencyKey(ctx);
|
|
180
|
+
expect(result.outputs.key, `key shape for ctx=${JSON.stringify(ctx)}`).toMatch(KEY_PATTERN);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('uuid mode rejects with CONFIG_INVALID (safety-fix per 1.1.2)', async () => {
|
|
185
|
+
if (!packAvailable) return;
|
|
186
|
+
// The removed mode names the safety-fix in its error message so
|
|
187
|
+
// pinned-version callers diagnosing a regression find the fix.
|
|
188
|
+
let caught: unknown;
|
|
189
|
+
try {
|
|
190
|
+
await idempotencyKey({ config: { mode: 'uuid' }, inputs: { payload: 'x' } });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
caught = err;
|
|
193
|
+
}
|
|
194
|
+
expect(caught, 'mode: uuid MUST be rejected — the 1.1.0/1.1.1 non-deterministic default was removed').toBeInstanceOf(Error);
|
|
195
|
+
expect((caught as Error & { code?: string }).code).toBe('CONFIG_INVALID');
|
|
196
|
+
expect((caught as Error).message).toMatch(/uuid.*removed|safety-fix/i);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('cross-impl invariant — pack output equals canonical SHA-256 formula', async () => {
|
|
200
|
+
if (!packAvailable) return;
|
|
201
|
+
// Five fixed vectors. A third-party core.openwop.http reimplementation
|
|
202
|
+
// can run the same scenario against its runtime — agreement on these
|
|
203
|
+
// vectors proves wire-level interop on the Idempotency-Key shape.
|
|
204
|
+
const vectors = [
|
|
205
|
+
{ runId: 'run-A', nodeId: 'node-1', payload: { task: 'create-charge', amount: 100 } },
|
|
206
|
+
{ runId: 'run-A', nodeId: 'node-2', payload: { task: 'create-charge', amount: 100 } },
|
|
207
|
+
{ runId: 'run-B', nodeId: 'node-1', payload: { task: 'create-charge', amount: 100 } },
|
|
208
|
+
{ runId: 'r', nodeId: 'n', payload: null },
|
|
209
|
+
{ runId: 'r', nodeId: 'n', payload: 'just-a-string' },
|
|
210
|
+
];
|
|
211
|
+
for (const v of vectors) {
|
|
212
|
+
const packed = await idempotencyKey({
|
|
213
|
+
runId: v.runId,
|
|
214
|
+
nodeId: v.nodeId,
|
|
215
|
+
inputs: { payload: v.payload },
|
|
216
|
+
});
|
|
217
|
+
const expected = canonicalCompositeKey(v.runId, v.nodeId, v.payload);
|
|
218
|
+
expect(
|
|
219
|
+
packed.outputs.key,
|
|
220
|
+
`vector ${JSON.stringify(v)} — pack output MUST match canonical sha256(runId\\0nodeId\\0JSON(payload))`,
|
|
221
|
+
).toBe(expected);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Hash-mode vectors: same formula independent of run/node.
|
|
225
|
+
for (const payload of [null, 'x', { nested: 1 }, [1, 2]]) {
|
|
226
|
+
const packed = await idempotencyKey({ config: { mode: 'hash' }, inputs: { payload } });
|
|
227
|
+
expect(packed.outputs.key).toBe(canonicalHashKey(payload));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF-3 close-out — interrupt token matrix coverage per
|
|
3
|
+
* `plans/openwop-protocol-gap-closure-plan.md` Workstream 2.
|
|
4
|
+
*
|
|
5
|
+
* Verifies the negative + replay paths on `GET /v1/interrupts/{token}`
|
|
6
|
+
* and `POST /v1/interrupts/{token}` that complement the existing
|
|
7
|
+
* positive-path coverage in `interrupt-external-event-correlation.test.ts`:
|
|
8
|
+
*
|
|
9
|
+
* 1. Malformed token (random bytes) — inspect MUST return 400 or 404.
|
|
10
|
+
* 2. Unknown token (well-formed but no interrupt) — inspect MUST
|
|
11
|
+
* return 404 with `not_found` or similar.
|
|
12
|
+
* 3. Already-resolved token — resolve once (positive path),
|
|
13
|
+
* then replay the same `POST` — MUST return 409 or 404 (host
|
|
14
|
+
* MAY treat already-resolved as gone OR as conflict).
|
|
15
|
+
* 4. Wrong action — `POST` with a payload whose `action` field is
|
|
16
|
+
* NOT in the interrupt's allowed-actions list MUST return 400
|
|
17
|
+
* `validation_error`.
|
|
18
|
+
* 5. Cross-run-id leak — synthesize a token with `runId=other-run`
|
|
19
|
+
* embedded but valid HMAC envelope — MUST return 401 or 404
|
|
20
|
+
* (NEVER 200 from a different run's scope).
|
|
21
|
+
*
|
|
22
|
+
* Gating identical to interrupt-external-event-correlation: skips when
|
|
23
|
+
* the `conformance-external-event` fixture isn't advertised.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v1/interrupt.md §"Signed-token callback"
|
|
26
|
+
* @see spec/v1/rest-endpoints.md §"GET /v1/interrupts/{token}" + §"POST /v1/interrupts/{token}"
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { driver } from '../lib/driver.js';
|
|
31
|
+
import { pollUntilStatus } from '../lib/polling.js';
|
|
32
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
33
|
+
|
|
34
|
+
const FIXTURE = 'conformance-external-event';
|
|
35
|
+
const SKIP = !isFixtureAdvertised(FIXTURE);
|
|
36
|
+
|
|
37
|
+
function randomBytesB64(length: number): string {
|
|
38
|
+
return Buffer.from(
|
|
39
|
+
Array.from({ length }, () => Math.floor(Math.random() * 256)),
|
|
40
|
+
).toString('base64url');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe.skipIf(SKIP)('interrupt-token-matrix: GET /v1/interrupts/{token} negative paths', () => {
|
|
44
|
+
it('malformed token returns 400 or 404 (NEVER 200)', async () => {
|
|
45
|
+
const malformed = '!!!not-a-valid-token!!!';
|
|
46
|
+
const res = await driver.get(`/v1/interrupts/${encodeURIComponent(malformed)}`);
|
|
47
|
+
expect([400, 404]).toContain(res.status);
|
|
48
|
+
expect(res.status, driver.describe(
|
|
49
|
+
'rest-endpoints.md GET /v1/interrupts/{token}',
|
|
50
|
+
'malformed interrupt token MUST NOT return 200',
|
|
51
|
+
)).not.toBe(200);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('well-formed but unknown token returns 404', async () => {
|
|
55
|
+
// Plausibly-shaped opaque token that the host has no record of.
|
|
56
|
+
const unknown = `tok_${randomBytesB64(32)}`;
|
|
57
|
+
const res = await driver.get(`/v1/interrupts/${encodeURIComponent(unknown)}`);
|
|
58
|
+
expect(res.status, driver.describe(
|
|
59
|
+
'rest-endpoints.md GET /v1/interrupts/{token}',
|
|
60
|
+
'unknown interrupt token MUST return 404',
|
|
61
|
+
)).toBe(404);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe.skipIf(SKIP)('interrupt-token-matrix: POST /v1/interrupts/{token} negative paths', () => {
|
|
66
|
+
it('replay after successful resolve returns 409 or 404', async () => {
|
|
67
|
+
// Drive a run to suspension; capture the real token; resolve once;
|
|
68
|
+
// replay the same POST and assert it doesn't succeed twice.
|
|
69
|
+
const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
|
|
70
|
+
expect(create.status).toBe(201);
|
|
71
|
+
const runId = (create.json as { runId: string }).runId;
|
|
72
|
+
|
|
73
|
+
await pollUntilStatus(runId, 'waiting-external-event', { timeoutMs: 10_000 }).catch(() => null);
|
|
74
|
+
|
|
75
|
+
const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
76
|
+
const interrupts =
|
|
77
|
+
((snap.json as { interrupts?: Array<{ token?: string }> }).interrupts ?? [])
|
|
78
|
+
.filter((i) => typeof i.token === 'string');
|
|
79
|
+
const token = interrupts[0]?.token;
|
|
80
|
+
if (typeof token !== 'string') {
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.warn('[interrupt-token-matrix] host did not surface an interrupt token; skipping replay subtest');
|
|
83
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
84
|
+
reason: 'conformance-cleanup',
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resolve1 = await driver.post(`/v1/interrupts/${encodeURIComponent(token)}`, {
|
|
90
|
+
correlation: { orderId: 'order-token-matrix', status: 'accepted' },
|
|
91
|
+
});
|
|
92
|
+
if (resolve1.status !== 200 && resolve1.status !== 202) {
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.warn(
|
|
95
|
+
`[interrupt-token-matrix] first resolve returned ${resolve1.status}; can't exercise replay path. Skipping.`,
|
|
96
|
+
);
|
|
97
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
98
|
+
reason: 'conformance-cleanup',
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const resolve2 = await driver.post(`/v1/interrupts/${encodeURIComponent(token)}`, {
|
|
104
|
+
correlation: { orderId: 'order-token-matrix', status: 'accepted' },
|
|
105
|
+
});
|
|
106
|
+
expect([404, 409, 410], driver.describe(
|
|
107
|
+
'rest-endpoints.md POST /v1/interrupts/{token}',
|
|
108
|
+
'replay of an already-resolved interrupt token MUST NOT return 2xx (host MAY 404/409/410)',
|
|
109
|
+
)).toContain(resolve2.status);
|
|
110
|
+
|
|
111
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
112
|
+
reason: 'conformance-cleanup',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('unknown token returns 404 on POST', async () => {
|
|
117
|
+
const unknown = `tok_${randomBytesB64(32)}`;
|
|
118
|
+
const res = await driver.post(`/v1/interrupts/${encodeURIComponent(unknown)}`, {
|
|
119
|
+
correlation: { orderId: 'noop', status: 'whatever' },
|
|
120
|
+
});
|
|
121
|
+
expect(res.status, driver.describe(
|
|
122
|
+
'rest-endpoints.md POST /v1/interrupts/{token}',
|
|
123
|
+
'POST on an unknown interrupt token MUST return 404',
|
|
124
|
+
)).toBe(404);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kv-atomic-increment — RFC 0015 §B point 4 (atomic increment).
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE (advertisement + behavioral). Behavioral half drives N
|
|
5
|
+
* concurrent +1 increments through the reference-host test seam and asserts
|
|
6
|
+
* the final value equals N. Hosts that don't expose the seam soft-skip.
|
|
7
|
+
*
|
|
8
|
+
* @see RFCS/0015-host-kv-storage-capability.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { driver } from '../lib/driver.js';
|
|
13
|
+
|
|
14
|
+
interface DiscoveryDoc {
|
|
15
|
+
capabilities?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readCap(): Promise<Record<string, unknown> | null> {
|
|
19
|
+
const res = await driver.get('/.well-known/openwop');
|
|
20
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
21
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
22
|
+
const final = (top && typeof top === 'object') ? (top as Record<string, unknown>)["kvStorage"] : undefined;
|
|
23
|
+
return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function call(op: string, args: Record<string, unknown>) {
|
|
27
|
+
return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'kv', op, args });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('kv-atomic-increment: advertisement shape (RFC 0015)', () => {
|
|
31
|
+
it('capabilities.kvStorage is either absent or a well-formed object', async () => {
|
|
32
|
+
const cap = await readCap();
|
|
33
|
+
if (cap === null) return;
|
|
34
|
+
expect(
|
|
35
|
+
typeof cap.supported,
|
|
36
|
+
driver.describe(
|
|
37
|
+
'capabilities.schema.json §kvStorage',
|
|
38
|
+
'capabilities.kvStorage.supported MUST be a boolean when present',
|
|
39
|
+
),
|
|
40
|
+
).toBe('boolean');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('atomicIncrement is a boolean when set', async () => {
|
|
44
|
+
const cap = await readCap();
|
|
45
|
+
if (!cap || cap.supported !== true) return;
|
|
46
|
+
const sub = cap.atomicIncrement;
|
|
47
|
+
if (sub === undefined) return;
|
|
48
|
+
expect(typeof sub, driver.describe('RFC 0015 §A', 'atomicIncrement MUST be boolean when present')).toBe('boolean');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('kv-atomic-increment: behavioral (RFC 0015 §B point 4)', () => {
|
|
53
|
+
it('N concurrent +1 increments converge to exactly N', async () => {
|
|
54
|
+
const cap = await readCap();
|
|
55
|
+
if (!cap || cap.supported !== true || cap.atomicIncrement !== true) return;
|
|
56
|
+
const key = `atomic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
57
|
+
const probe = await call('atomicIncrement', { key, delta: 0 });
|
|
58
|
+
if (probe.status === 404) return; // seam not exposed
|
|
59
|
+
|
|
60
|
+
const N = 50;
|
|
61
|
+
const results = await Promise.all(
|
|
62
|
+
Array.from({ length: N }, () => call('atomicIncrement', { key, delta: 1 })),
|
|
63
|
+
);
|
|
64
|
+
for (const r of results) {
|
|
65
|
+
expect(r.status, 'each increment MUST succeed').toBe(200);
|
|
66
|
+
}
|
|
67
|
+
const finalRes = await call('get', { key });
|
|
68
|
+
const finalBody = finalRes.json as { value?: unknown };
|
|
69
|
+
expect(
|
|
70
|
+
finalBody.value,
|
|
71
|
+
driver.describe('RFC 0015 §B point 4', `${N} concurrent increments MUST converge to exactly ${N}`),
|
|
72
|
+
).toBe(N);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kv-cas — RFC 0015 §B point 5 (compare-and-swap atomicity).
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE (advertisement + behavioral). Asserts that a matching
|
|
5
|
+
* `expect` swaps and a stale `expect` rejects with `swapped:false` and
|
|
6
|
+
* returns the current actual value.
|
|
7
|
+
*
|
|
8
|
+
* @see RFCS/0015-host-kv-storage-capability.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { driver } from '../lib/driver.js';
|
|
13
|
+
|
|
14
|
+
interface DiscoveryDoc {
|
|
15
|
+
capabilities?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readCap(): Promise<Record<string, unknown> | null> {
|
|
19
|
+
const res = await driver.get('/.well-known/openwop');
|
|
20
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
21
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
22
|
+
const final = (top && typeof top === 'object') ? (top as Record<string, unknown>)["kvStorage"] : undefined;
|
|
23
|
+
return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function call(op: string, args: Record<string, unknown>) {
|
|
27
|
+
return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'kv', op, args });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('kv-cas: advertisement shape (RFC 0015)', () => {
|
|
31
|
+
it('capabilities.kvStorage is either absent or a well-formed object', async () => {
|
|
32
|
+
const cap = await readCap();
|
|
33
|
+
if (cap === null) return;
|
|
34
|
+
expect(
|
|
35
|
+
typeof cap.supported,
|
|
36
|
+
driver.describe(
|
|
37
|
+
'capabilities.schema.json §kvStorage',
|
|
38
|
+
'capabilities.kvStorage.supported MUST be a boolean when present',
|
|
39
|
+
),
|
|
40
|
+
).toBe('boolean');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('compareAndSwap is a boolean when set', async () => {
|
|
44
|
+
const cap = await readCap();
|
|
45
|
+
if (!cap || cap.supported !== true) return;
|
|
46
|
+
const sub = cap.compareAndSwap;
|
|
47
|
+
if (sub === undefined) return;
|
|
48
|
+
expect(typeof sub, driver.describe('RFC 0015 §A', 'compareAndSwap MUST be boolean when present')).toBe('boolean');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('kv-cas: behavioral (RFC 0015 §B point 5)', () => {
|
|
53
|
+
it('CAS with matching expect succeeds; stale expect fails with swapped:false', async () => {
|
|
54
|
+
const cap = await readCap();
|
|
55
|
+
if (!cap || cap.supported !== true || cap.compareAndSwap !== true) return;
|
|
56
|
+
const key = `cas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
57
|
+
|
|
58
|
+
const setRes = await call('set', { key, value: 'v1' });
|
|
59
|
+
if (setRes.status === 404) return;
|
|
60
|
+
expect(setRes.status).toBe(200);
|
|
61
|
+
|
|
62
|
+
// Matching expect → swaps.
|
|
63
|
+
const okRes = await call('cas', { key, expect: 'v1', set: 'v2' });
|
|
64
|
+
expect(okRes.status, 'matching CAS MUST 200').toBe(200);
|
|
65
|
+
const okBody = okRes.json as { swapped?: boolean };
|
|
66
|
+
expect(okBody.swapped, 'matching expect MUST swap').toBe(true);
|
|
67
|
+
|
|
68
|
+
// Stale expect → no swap.
|
|
69
|
+
const staleRes = await call('cas', { key, expect: 'v1', set: 'v3' });
|
|
70
|
+
expect(staleRes.status, 'stale CAS MUST 200 (CAS is non-throwing)').toBe(200);
|
|
71
|
+
const staleBody = staleRes.json as { swapped?: boolean; actual?: unknown };
|
|
72
|
+
expect(staleBody.swapped, 'stale expect MUST NOT swap').toBe(false);
|
|
73
|
+
expect(staleBody.actual, 'stale CAS MUST surface current value').toBe('v2');
|
|
74
|
+
});
|
|
75
|
+
});
|