@openwop/openwop-conformance 2.37.1 → 2.39.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 +14 -0
- package/README.md +3 -3
- package/dist/cli.js +8 -18
- package/dist/lib/certification-bundle-v3.js +46 -18
- package/dist/lib/jcs.js +274 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +457 -33
- package/scenario-majors.json +10 -3
- package/schemas/CORPUS-STAMP.json +29 -29
- package/src/cli.ts +8 -16
- package/src/lib/certification-bundle-v3.ts +42 -17
- package/src/lib/front-mux.ts +36 -1
- package/src/lib/jcs.ts +229 -0
- package/src/lib/llm-cache-key-recipe.ts +10 -20
- package/src/lib/scoped-receiver.ts +2 -2
- package/src/scenarios/jcs-vectors.test.ts +108 -0
- package/src/scenarios/semantic-digest-vectors.test.ts +8 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +130 -1
- package/src/scenarios/v2-a2a-push-delivery.test.ts +246 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +18 -4
- package/src/scenarios/v2-content-locale-keys.test.ts +10 -1
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +7 -1
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +7 -1
- package/vectors/jcs-v1.json +294 -0
- package/vectors/semantic-request-digest-v2.json +52 -0
package/src/lib/jcs.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0212 — canonical JSON is RFC 8785 (JCS), and the input MUST be I-JSON.
|
|
3
|
+
*
|
|
4
|
+
* Every signature and digest in the corpus is over these bytes: pack
|
|
5
|
+
* signatures (`ed25519-canonical-json`), the certification-bundle attestation,
|
|
6
|
+
* `discovery.sha256`, `witnessSha256`, and the RFC 0150 semantic request digest.
|
|
7
|
+
* `conformance/vectors/jcs-v1.json` is the normative test of this module and of
|
|
8
|
+
* any other-language implementation.
|
|
9
|
+
*
|
|
10
|
+
* Two entry points, because two of the refusals are invisible after a parse:
|
|
11
|
+
*
|
|
12
|
+
* - `canonicalJSON(value)` — the VALUE boundary. Refuses what a native value
|
|
13
|
+
* can still show: non-finite numbers, non-JSON values (undefined, functions,
|
|
14
|
+
* bigint, symbols, class instances such as Date) and lone surrogates.
|
|
15
|
+
* - `parseIJson(text)` — the TEXT boundary. `JSON.parse` keeps the last of two
|
|
16
|
+
* duplicate names and rounds `9007199254740993` to `…992` silently; both are
|
|
17
|
+
* refused here, before coercion. Read a document you will re-canonicalize
|
|
18
|
+
* through this, not `JSON.parse`.
|
|
19
|
+
*
|
|
20
|
+
* Why not `Object.keys(v).sort()` + `JSON.stringify` with no checks (the code
|
|
21
|
+
* this replaces): it is JCS for well-formed input — the default sort IS UTF-16
|
|
22
|
+
* code-unit order and ES number/string serialization IS JCS §3.2.2 — but it
|
|
23
|
+
* turns NaN into `null`, emits the text `undefined`, and signs a rounded
|
|
24
|
+
* integer. Each of those is a document two verifiers read differently with no
|
|
25
|
+
* error on either side.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type JcsRefusalKind = 'duplicate-name' | 'lone-surrogate' | 'integer-out-of-range' | 'non-finite' | 'not-json';
|
|
29
|
+
|
|
30
|
+
export class JcsRefusal extends Error {
|
|
31
|
+
readonly kind: JcsRefusalKind;
|
|
32
|
+
constructor(kind: JcsRefusalKind, message: string) {
|
|
33
|
+
super(`RFC 0212 §B refusal (${kind}): ${message}`);
|
|
34
|
+
this.name = 'JcsRefusal';
|
|
35
|
+
this.kind = kind;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A JSON number literal, matched in place (sticky) so parsing stays linear. */
|
|
40
|
+
const NUMBER = /-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/y;
|
|
41
|
+
|
|
42
|
+
/** ±(2^53 − 1), for the integer-literal refusal at the text boundary. */
|
|
43
|
+
const MAX_EXACT_BIG = 2n ** 53n - 1n;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* RFC 8785 §3.2.3 — compare by UTF-16 code units. Explicit rather than the
|
|
47
|
+
* `Array.prototype.sort` default so the rule is visible at every call site, and
|
|
48
|
+
* never `localeCompare`: collation is locale-dependent (Czech sorts `ch` after
|
|
49
|
+
* `h`; English puts `a` before `A` and ignores `-`), so a digest computed with
|
|
50
|
+
* it depends on the machine that computed it.
|
|
51
|
+
*/
|
|
52
|
+
export function codeUnitCompare(a: string, b: string): number {
|
|
53
|
+
const n = Math.min(a.length, b.length);
|
|
54
|
+
for (let i = 0; i < n; i += 1) {
|
|
55
|
+
const d = a.charCodeAt(i) - b.charCodeAt(i);
|
|
56
|
+
if (d !== 0) return d;
|
|
57
|
+
}
|
|
58
|
+
return a.length - b.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertWellFormed(s: string, where: string): void {
|
|
62
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
63
|
+
const u = s.charCodeAt(i);
|
|
64
|
+
if (u >= 0xd800 && u <= 0xdbff) {
|
|
65
|
+
const next = s.charCodeAt(i + 1);
|
|
66
|
+
if (next >= 0xdc00 && next <= 0xdfff) { i += 1; continue; }
|
|
67
|
+
throw new JcsRefusal('lone-surrogate', `lone high surrogate U+${u.toString(16).toUpperCase()} in ${where}`);
|
|
68
|
+
}
|
|
69
|
+
if (u >= 0xdc00 && u <= 0xdfff) throw new JcsRefusal('lone-surrogate', `lone low surrogate U+${u.toString(16).toUpperCase()} in ${where}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function serializeNumber(n: number): string {
|
|
74
|
+
if (!Number.isFinite(n)) throw new JcsRefusal('non-finite', `${String(n)} is not a JSON number`);
|
|
75
|
+
// No magnitude check here: a double is already exact, and JCS serializes it
|
|
76
|
+
// (RFC 8785 Appendix B includes 9007199254740994). The integer-range refusal
|
|
77
|
+
// is about a LITERAL that no double holds, which only the text shows — see
|
|
78
|
+
// `parseIJson`.
|
|
79
|
+
return Object.is(n, -0) ? '0' : String(n);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isPlainObject(v: object): boolean {
|
|
83
|
+
const proto = Object.getPrototypeOf(v) as unknown;
|
|
84
|
+
return proto === Object.prototype || proto === null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** RFC 8785 serialization of an I-JSON value. Throws `JcsRefusal` instead of coercing. */
|
|
88
|
+
export function canonicalJSON(value: unknown): string {
|
|
89
|
+
if (value === null) return 'null';
|
|
90
|
+
switch (typeof value) {
|
|
91
|
+
case 'boolean': return value ? 'true' : 'false';
|
|
92
|
+
case 'number': return serializeNumber(value);
|
|
93
|
+
case 'string': assertWellFormed(value, 'a string'); return JSON.stringify(value);
|
|
94
|
+
case 'object': break;
|
|
95
|
+
default: throw new JcsRefusal('not-json', `a ${typeof value} is not a JSON value`);
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
const parts: string[] = [];
|
|
99
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
100
|
+
if (!(i in value)) throw new JcsRefusal('not-json', 'a sparse array hole is not a JSON value');
|
|
101
|
+
parts.push(canonicalJSON(value[i]));
|
|
102
|
+
}
|
|
103
|
+
return `[${parts.join(',')}]`;
|
|
104
|
+
}
|
|
105
|
+
if (!isPlainObject(value)) throw new JcsRefusal('not-json', `a ${(value as object).constructor?.name ?? 'non-plain'} object is not a JSON value`);
|
|
106
|
+
const obj = value as Record<string, unknown>;
|
|
107
|
+
const keys = Object.keys(obj).sort(codeUnitCompare);
|
|
108
|
+
return `{${keys.map((k) => { assertWellFormed(k, 'a member name'); return `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`; }).join(',')}}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse JSON text, refusing what `JSON.parse` would silently accept or change:
|
|
113
|
+
* duplicate member names, integer literals outside ±(2^53 − 1), literals that
|
|
114
|
+
* overflow to ±Infinity, and lone surrogates. Members are defined, not
|
|
115
|
+
* assigned, so a member named `__proto__` is data, not a prototype write.
|
|
116
|
+
*/
|
|
117
|
+
export function parseIJson(text: string): unknown {
|
|
118
|
+
let i = 0;
|
|
119
|
+
const fail = (m: string): never => { throw new JcsRefusal('not-json', `${m} at offset ${i}`); };
|
|
120
|
+
const ws = (): void => { while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i += 1; };
|
|
121
|
+
|
|
122
|
+
const str = (): string => {
|
|
123
|
+
if (text[i] !== '"') fail('expected a string');
|
|
124
|
+
i += 1;
|
|
125
|
+
let out = '';
|
|
126
|
+
for (;;) {
|
|
127
|
+
if (i >= text.length) fail('unterminated string');
|
|
128
|
+
const c = text[i];
|
|
129
|
+
i += 1;
|
|
130
|
+
if (c === '"') break;
|
|
131
|
+
if (c === '\\') {
|
|
132
|
+
const e = text[i];
|
|
133
|
+
i += 1;
|
|
134
|
+
switch (e) {
|
|
135
|
+
case '"': out += '"'; break;
|
|
136
|
+
case '\\': out += '\\'; break;
|
|
137
|
+
case '/': out += '/'; break;
|
|
138
|
+
case 'b': out += '\b'; break;
|
|
139
|
+
case 'f': out += '\f'; break;
|
|
140
|
+
case 'n': out += '\n'; break;
|
|
141
|
+
case 'r': out += '\r'; break;
|
|
142
|
+
case 't': out += '\t'; break;
|
|
143
|
+
case 'u': {
|
|
144
|
+
const h = text.slice(i, i + 4);
|
|
145
|
+
if (!/^[0-9a-fA-F]{4}$/.test(h)) fail('bad \\u escape');
|
|
146
|
+
out += String.fromCharCode(parseInt(h, 16));
|
|
147
|
+
i += 4;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
default: fail('bad escape');
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
if ((c as string).charCodeAt(0) < 0x20) fail('raw control character in a string');
|
|
154
|
+
out += c;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
assertWellFormed(out, 'a string');
|
|
158
|
+
return out;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const num = (): number => {
|
|
162
|
+
NUMBER.lastIndex = i;
|
|
163
|
+
const m = NUMBER.exec(text);
|
|
164
|
+
if (m === null) return fail('bad number');
|
|
165
|
+
i += m[0].length;
|
|
166
|
+
if (m[2] === undefined && m[3] === undefined) {
|
|
167
|
+
const b = BigInt(m[0]);
|
|
168
|
+
if (b > MAX_EXACT_BIG || b < -MAX_EXACT_BIG) throw new JcsRefusal('integer-out-of-range', `integer literal ${m[0]} is outside ±(2^53 − 1)`);
|
|
169
|
+
}
|
|
170
|
+
const v = Number(m[0]);
|
|
171
|
+
if (!Number.isFinite(v)) throw new JcsRefusal('non-finite', `${m[0]} overflows to a non-finite number`);
|
|
172
|
+
return v;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const val = (): unknown => {
|
|
176
|
+
ws();
|
|
177
|
+
const c = text[i];
|
|
178
|
+
if (c === '{') {
|
|
179
|
+
i += 1;
|
|
180
|
+
const obj: Record<string, unknown> = {};
|
|
181
|
+
ws();
|
|
182
|
+
if (text[i] === '}') { i += 1; return obj; }
|
|
183
|
+
for (;;) {
|
|
184
|
+
ws();
|
|
185
|
+
const k = str();
|
|
186
|
+
if (Object.prototype.hasOwnProperty.call(obj, k)) throw new JcsRefusal('duplicate-name', `duplicate member name ${JSON.stringify(k)}`);
|
|
187
|
+
ws();
|
|
188
|
+
if (text[i] !== ':') fail('expected :');
|
|
189
|
+
i += 1;
|
|
190
|
+
Object.defineProperty(obj, k, { value: val(), enumerable: true, writable: true, configurable: true });
|
|
191
|
+
ws();
|
|
192
|
+
const d = text[i];
|
|
193
|
+
i += 1;
|
|
194
|
+
if (d === '}') return obj;
|
|
195
|
+
if (d !== ',') fail('expected , or }');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (c === '[') {
|
|
199
|
+
i += 1;
|
|
200
|
+
const arr: unknown[] = [];
|
|
201
|
+
ws();
|
|
202
|
+
if (text[i] === ']') { i += 1; return arr; }
|
|
203
|
+
for (;;) {
|
|
204
|
+
arr.push(val());
|
|
205
|
+
ws();
|
|
206
|
+
const d = text[i];
|
|
207
|
+
i += 1;
|
|
208
|
+
if (d === ']') return arr;
|
|
209
|
+
if (d !== ',') fail('expected , or ]');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (c === '"') return str();
|
|
213
|
+
if (text.startsWith('true', i)) { i += 4; return true; }
|
|
214
|
+
if (text.startsWith('false', i)) { i += 5; return false; }
|
|
215
|
+
if (text.startsWith('null', i)) { i += 4; return null; }
|
|
216
|
+
if (c === '-' || (c !== undefined && c >= '0' && c <= '9')) return num();
|
|
217
|
+
return fail('unexpected token');
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const v = val();
|
|
221
|
+
ws();
|
|
222
|
+
if (i !== text.length) fail('trailing data');
|
|
223
|
+
return v;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** JCS bytes of a JSON text, with every §B refusal applied. */
|
|
227
|
+
export function canonicalizeText(text: string): string {
|
|
228
|
+
return canonicalJSON(parseIJson(text));
|
|
229
|
+
}
|
|
@@ -10,30 +10,20 @@
|
|
|
10
10
|
* RFC 0041 §E SECURITY-invariant probe (intra-host reproducibility +
|
|
11
11
|
* non-recipe-field invariance + Phase 4 advertisement alignment).
|
|
12
12
|
*
|
|
13
|
-
* `canonicalize`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `canonicalize` is RFC 8785 JCS with the RFC 0212 I-JSON refusal set (the
|
|
14
|
+
* suite's one implementation, `./jcs.ts`), and `tools[]` sorts by UTF-16 code
|
|
15
|
+
* units — never `localeCompare`, which orders `get_weather` / `getWeather`
|
|
16
|
+
* differently per locale and so breaks the TS/Python/Go agreement RFC 0150 §C
|
|
17
|
+
* asks for. Keep in sync with `spec/v1/replay.md` §B.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import { createHash } from 'node:crypto';
|
|
20
21
|
import { driver } from './driver.js';
|
|
22
|
+
import { canonicalJSON, codeUnitCompare } from './jcs.js';
|
|
21
23
|
|
|
22
|
-
/** RFC 8785 JCS
|
|
23
|
-
* fields). Sorted keys recursively; no whitespace; preserved array order;
|
|
24
|
-
* strings JSON-encoded verbatim (no NFC normalization — the recipe
|
|
25
|
-
* inputs in our test seam are ASCII). */
|
|
24
|
+
/** RFC 8785 JCS (RFC 0212). Throws `JcsRefusal` on a non-I-JSON value. */
|
|
26
25
|
export function canonicalize(value: unknown): string {
|
|
27
|
-
|
|
28
|
-
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
|
|
29
|
-
if (typeof value === 'string') return JSON.stringify(value);
|
|
30
|
-
if (Array.isArray(value)) return '[' + value.map((v) => canonicalize(v)).join(',') + ']';
|
|
31
|
-
if (typeof value === 'object') {
|
|
32
|
-
const obj = value as Record<string, unknown>;
|
|
33
|
-
const keys = Object.keys(obj).sort();
|
|
34
|
-
return '{' + keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(',') + '}';
|
|
35
|
-
}
|
|
36
|
-
return JSON.stringify(value);
|
|
26
|
+
return canonicalJSON(value);
|
|
37
27
|
}
|
|
38
28
|
|
|
39
29
|
/** @deprecated RETIRED v1 projection (pre RFC 0150 §C). Kept ONLY so a
|
|
@@ -44,7 +34,7 @@ export function canonicalize(value: unknown): string {
|
|
|
44
34
|
export function projectRecipe(raw: Record<string, unknown>): Record<string, unknown> {
|
|
45
35
|
const out: Record<string, unknown> = { provider: raw.provider, model: raw.model, messages: raw.messages };
|
|
46
36
|
if (Array.isArray(raw.tools) && raw.tools.length > 0) {
|
|
47
|
-
out.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => a.name
|
|
37
|
+
out.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => codeUnitCompare(a.name, b.name));
|
|
48
38
|
}
|
|
49
39
|
if (typeof raw.temperature === 'number') out.temperature = raw.temperature;
|
|
50
40
|
if (typeof raw.topP === 'number') out.topP = raw.topP;
|
|
@@ -76,7 +66,7 @@ export const SEMANTIC_REQUEST_RECIPE_V2 = 'openwop-semantic-request-v2';
|
|
|
76
66
|
export function projectSemanticRequestV2(raw: Record<string, unknown>): Record<string, unknown> {
|
|
77
67
|
const request: Record<string, unknown> = { messages: raw.messages };
|
|
78
68
|
if (Array.isArray(raw.tools) && raw.tools.length > 0) {
|
|
79
|
-
request.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => a.name
|
|
69
|
+
request.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => codeUnitCompare(a.name, b.name));
|
|
80
70
|
}
|
|
81
71
|
for (const k of ['temperature', 'topP', 'topK', 'maxOutputTokens', 'seed'] as const) {
|
|
82
72
|
if (typeof raw[k] === 'number') request[k] = raw[k];
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
import { randomBytes } from 'node:crypto';
|
|
51
51
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
52
|
-
import { FRONT_MUX_PREFIX, registerBehindFront, routeFronted, unregisterBehindFront } from './front-mux.js';
|
|
52
|
+
import { FRONT_MUX_PREFIX, registerBehindFront, routeFronted, unregisterBehindFront, withoutFrontPath } from './front-mux.js';
|
|
53
53
|
import { receiverBinding, resolvePublicFront } from './webhook-receiver.js';
|
|
54
54
|
|
|
55
55
|
/** The operator's public front for the suite's webhook receiver. */
|
|
@@ -122,7 +122,7 @@ export async function startScopedReceiver(
|
|
|
122
122
|
// Counted BEFORE routing: `routeFronted` answers an unknown nonce itself and
|
|
123
123
|
// cannot report that it did, and a request that is not this exercise's must
|
|
124
124
|
// still be visible in the failure detail.
|
|
125
|
-
if (!(request.url ?? '/').startsWith(`${FRONT_MUX_PREFIX}${nonce}`)) foreign += 1;
|
|
125
|
+
if (!withoutFrontPath(WEBHOOK_FRONT_ENV, request.url ?? '/').startsWith(`${FRONT_MUX_PREFIX}${nonce}`)) foreign += 1;
|
|
126
126
|
if (routeFronted(WEBHOOK_FRONT_ENV, request, res)) return;
|
|
127
127
|
// Not an `/fx/` path at all: a stranger, or a host that dropped the path it
|
|
128
128
|
// was given. Answered, never recorded — a receiver that counts what it was
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0212 — canonical JSON is RFC 8785 (JCS), and the input MUST be I-JSON.
|
|
3
|
+
*
|
|
4
|
+
* `conformance/vectors/jcs-v1.json` is normative: every signature and digest in
|
|
5
|
+
* the corpus (pack signatures, the certification-bundle attestation,
|
|
6
|
+
* `discovery.sha256`, `witnessSha256`, the RFC 0150 request digest) is over
|
|
7
|
+
* these bytes, and an SDK in another language reproduces the same file. This
|
|
8
|
+
* scenario holds the suite's one implementation (`lib/jcs.ts`) to it.
|
|
9
|
+
*
|
|
10
|
+
* Every leg RECOMPUTES from the vector input; none compares two constants from
|
|
11
|
+
* the file. The sabotages RFC 0212 names, each of which turns a leg red:
|
|
12
|
+
*
|
|
13
|
+
* - a rebuild-object canonicalizer (`JSON.stringify` of an object whose keys
|
|
14
|
+
* were inserted in sorted order) — integer-like keys enumerate first, so
|
|
15
|
+
* `integer-like-keys` fails;
|
|
16
|
+
* - NaN → `null` coercion — the value-boundary refusal leg fails;
|
|
17
|
+
* - a parser that keeps the last duplicate name (`JSON.parse`) — the
|
|
18
|
+
* `duplicate-name` refusal is accepted;
|
|
19
|
+
* - a `localeCompare` comparator — the ordering leg and the
|
|
20
|
+
* `rfc8785-3.2.3-sort` vector fail.
|
|
21
|
+
*
|
|
22
|
+
* Server-free and always-on; the vectors ship with the conformance package.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { dirname, resolve } from 'node:path';
|
|
30
|
+
import { canonicalJSON, canonicalizeText, codeUnitCompare, JcsRefusal } from '../lib/jcs.js';
|
|
31
|
+
import { witnessDigest, type BundleV3Requirement } from '../lib/certification-bundle-v3.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
|
|
34
|
+
interface NumberVector { readonly ieee754: string; readonly canonical: string }
|
|
35
|
+
interface NumberRefusal { readonly ieee754: string; readonly refuse: string }
|
|
36
|
+
interface ObjectVector { readonly id: string; readonly why: string; readonly input: string; readonly canonical: string; readonly sha256: string }
|
|
37
|
+
interface RefusalVector { readonly id: string; readonly why: string; readonly input: string; readonly refuse: string }
|
|
38
|
+
interface Ordering { readonly why: string; readonly ids: readonly string[]; readonly codeUnit: readonly string[] }
|
|
39
|
+
|
|
40
|
+
const VECTORS_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'vectors', 'jcs-v1.json');
|
|
41
|
+
const doc = JSON.parse(readFileSync(VECTORS_PATH, 'utf8')) as {
|
|
42
|
+
numbers: readonly NumberVector[];
|
|
43
|
+
numberRefusals: readonly NumberRefusal[];
|
|
44
|
+
objects: readonly ObjectVector[];
|
|
45
|
+
refusals: readonly RefusalVector[];
|
|
46
|
+
ordering: Ordering;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const SPEC = 'RFC 0212 · spec/v2/core/conformance.md §"Canonical JSON"';
|
|
50
|
+
const sha256 = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex');
|
|
51
|
+
const double = (hex: string): number => Buffer.from(hex, 'hex').readDoubleBE(0);
|
|
52
|
+
|
|
53
|
+
function refusalOf(fn: () => unknown): JcsRefusal | undefined {
|
|
54
|
+
try { fn(); } catch (e) { if (e instanceof JcsRefusal) return e; throw e; }
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('RFC 0212 — canonical JSON is RFC 8785 JCS over I-JSON', () => {
|
|
59
|
+
it('the vector set is present and non-trivial', () => {
|
|
60
|
+
// An empty or truncated file would make every leg below vacuous.
|
|
61
|
+
expect(doc.numbers.length, req('openwop.it.jcs-vectors.the-vector-set-is-present-and-non-trivial', SPEC, 'the vectors MUST cover RFC 8785 Appendix B numbers, documents and every §B refusal kind')).toBeGreaterThanOrEqual(20);
|
|
62
|
+
expect(doc.objects.length).toBeGreaterThanOrEqual(10);
|
|
63
|
+
expect(new Set(doc.refusals.map((r) => r.refuse))).toEqual(new Set(['duplicate-name', 'lone-surrogate', 'integer-out-of-range', 'not-json', 'non-finite']));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each(doc.numbers.map((n) => [n.ieee754, n] as const))('number %s serializes per RFC 8785 Appendix B', (_hex, n) => {
|
|
67
|
+
expect(canonicalJSON(double(n.ieee754)), req('openwop.it.jcs-vectors.number-s-serializes-per-rfc-8785-appendix-b', SPEC, 'numbers MUST serialize as ECMAScript Number::toString (RFC 8785 §3.2.2.3)')).toBe(n.canonical);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it.each(doc.objects.map((o) => [o.id, o] as const))('document %s canonicalizes and hashes', (_id, o) => {
|
|
71
|
+
// The preimage is asserted as well as the hash so a failing implementation
|
|
72
|
+
// can diff WHICH member it got wrong.
|
|
73
|
+
const canonical = canonicalizeText(o.input);
|
|
74
|
+
expect(canonical, req('openwop.it.jcs-vectors.document-s-canonicalizes-and-hashes', SPEC, o.why)).toBe(o.canonical);
|
|
75
|
+
expect(sha256(canonical), req('openwop.it.jcs-vectors.document-s-canonicalizes-and-hashes', SPEC, o.why)).toBe(o.sha256);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.each(doc.refusals.map((r) => [r.id, r] as const))('text %s is refused, not coerced', (_id, r) => {
|
|
79
|
+
const refusal = refusalOf(() => canonicalizeText(r.input));
|
|
80
|
+
expect(refusal?.kind, req('openwop.it.jcs-vectors.text-s-is-refused-not-coerced', SPEC, `RFC 0212 §B: ${r.why}`)).toBe(r.refuse);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('a non-I-JSON native value is refused at the value boundary', () => {
|
|
84
|
+
const cases: readonly [string, unknown][] = [
|
|
85
|
+
...doc.numberRefusals.map((n) => [`ieee754 ${n.ieee754}`, { n: double(n.ieee754) }] as [string, unknown]),
|
|
86
|
+
['NaN', { n: Number.NaN }],
|
|
87
|
+
['undefined member', { a: undefined }],
|
|
88
|
+
['undefined element', [undefined]],
|
|
89
|
+
['Date', { at: new Date(0) }],
|
|
90
|
+
['bigint', { n: 1n }],
|
|
91
|
+
['lone surrogate', { s: '\ud800' }],
|
|
92
|
+
['lone surrogate in a name', { '\udc00': 1 }],
|
|
93
|
+
];
|
|
94
|
+
for (const [label, value] of cases) {
|
|
95
|
+
expect(refusalOf(() => canonicalJSON(value)), req('openwop.it.jcs-vectors.a-non-i-json-native-value-is-refused-at-the-value-boundary', SPEC, `RFC 0212 §B: ${label} MUST be refused, not serialized`)).toBeInstanceOf(JcsRefusal);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('member names and certification rows sort by UTF-16 code units, not by locale', () => {
|
|
100
|
+
const o = doc.ordering;
|
|
101
|
+
expect([...o.ids].sort(codeUnitCompare), req('openwop.it.jcs-vectors.member-names-and-certification-rows-sort-by-utf-16-code-units-not-by-locale', SPEC, o.why)).toEqual([...o.codeUnit]);
|
|
102
|
+
// The certification digest uses the same comparator (RFC 0212 §C): rows
|
|
103
|
+
// given in vector order digest to the JCS of the code-unit-sorted rows.
|
|
104
|
+
const rows: BundleV3Requirement[] = o.ids.map((id) => ({ id, scenario: 's.test.ts', result: 'executed-pass' }) as BundleV3Requirement);
|
|
105
|
+
const expected = sha256(canonicalJSON([...o.codeUnit].map((id) => ({ id, scenario: 's.test.ts', result: 'executed-pass' }))));
|
|
106
|
+
expect(witnessDigest(rows), req('openwop.it.jcs-vectors.member-names-and-certification-rows-sort-by-utf-16-code-units-not-by-locale', SPEC, 'witnessSha256 rows MUST sort by id in UTF-16 code-unit order; a locale-sensitive comparator MUST NOT be used')).toBe(expected);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -110,6 +110,14 @@ describe('RFC 0150 §C — semantic request digest golden vectors', () => {
|
|
|
110
110
|
expect(digest('tools-sorted-by-name'), req('openwop.it.semantic-digest-vectors.tool-order-is-not-semantic-sorted-and-reversed-agree', 'RFC 0150 §C', 'tool order is not semantic — sorted and reversed agree')).toBe(digest('tools-reversed-same-digest'));
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it('tool order uses UTF-16 code units, where a locale comparator disagrees', () => {
|
|
114
|
+
// RFC 0212 §D. `alpha`/`zeta` order the same under every comparator, so the
|
|
115
|
+
// pair above cannot catch a `localeCompare` sort; `getWeather`/`get_weather`
|
|
116
|
+
// can — English collation ignores `_` and puts them the other way round.
|
|
117
|
+
expect(digest('tools-code-unit-order'), req('openwop.it.semantic-digest-vectors.tool-order-uses-utf-16-code-units-where-a-locale-comparator-disagrees', 'RFC 0212 §D · RFC 0150 §C', 'tools[] MUST sort by name in UTF-16 code-unit order')).toBe(digest('tools-code-unit-order-reversed'));
|
|
118
|
+
expect(canonicalize(projectSemanticRequestV2(byId.get('tools-code-unit-order')?.input ?? {})), req('openwop.it.semantic-digest-vectors.tool-order-uses-utf-16-code-units-where-a-locale-comparator-disagrees', 'RFC 0212 §D · RFC 0150 §C', 'getWeather (0x57) MUST precede get_weather (0x5F)')).toContain('"tools":[{"name":"getWeather"');
|
|
119
|
+
});
|
|
120
|
+
|
|
113
121
|
it('message order IS semantic — reversing changes the digest', () => {
|
|
114
122
|
expect(
|
|
115
123
|
digest('message-order-is-semantic'),
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* @see spec/v2/interop-map.json a2a.operations / a2a.taskState / a2a.errors
|
|
26
26
|
* @see RFCS/0208-v2-a2a-mcp-operation-mappings.md §C, §D, §E
|
|
27
27
|
* @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §D.1 (the credential leg; interop-map.json a2a.taskState override row)
|
|
28
|
+
* @see RFCS/0214-a2a-push-credential-is-a-destination-credential.md (the push-config refusal leg; invariants a2a-push-credential-destination-bound, a2a-push-secrets-not-returned)
|
|
28
29
|
* @see RFCS/0211-a2a-error-details-are-errorinfo.md §A–§E (the error-details legs; the isolation comparator)
|
|
29
30
|
*/
|
|
30
31
|
import { describe, it, expect } from 'vitest';
|
|
@@ -38,9 +39,10 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
38
39
|
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
39
40
|
import { req } from '../lib/requirement-ids.js';
|
|
40
41
|
import { SEAMS_PREFIX } from '../lib/seams.js';
|
|
42
|
+
import { unservedDestination } from '../lib/scoped-receiver.js';
|
|
41
43
|
import { errorInfos, normaliseErrorData, isOpenwopEnvelope, A2A_ERROR_DOMAIN } from '../lib/a2a-error-info.js';
|
|
42
44
|
|
|
43
|
-
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the A2A client: every leg POSTs JSON-RPC to the interface the host\'s own card lists;
|
|
45
|
+
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the A2A client: every leg POSTs JSON-RPC to the interface the host\'s own card lists; the RFC 0214 push-config legs register an UNSERVED destination (lib/scoped-receiver unservedDestination) whose deliveries are neither required nor read — delivery is v2-a2a-push-delivery\'s claim';
|
|
44
46
|
|
|
45
47
|
const DOC = 'spec/v2/core/interop.md §"The operation mappings" (RFC 0208)';
|
|
46
48
|
const PROFILE = 'a2a-1.0';
|
|
@@ -249,6 +251,133 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
|
|
|
249
251
|
await rpc(t.url, 'CancelTask', { id });
|
|
250
252
|
});
|
|
251
253
|
|
|
254
|
+
it('a host not advertising a2a.pushNotifications refuses all four push-config operations with PushNotificationNotSupportedError', async () => {
|
|
255
|
+
const rid = 'openwop.requirement.0214.a2a-push-unadvertised-refused';
|
|
256
|
+
const pushDoc = 'interop-map.json a2a.errors PushNotificationNotSupportedError (serverWhen: push config without a2a.pushNotifications) and the push-config operation rows; A2A v1.0.1 §3.3.4';
|
|
257
|
+
const t = await target(true);
|
|
258
|
+
if (!t.ok) return skip(t, rid);
|
|
259
|
+
const a2a = (await familyAdvertised('a2a'))!;
|
|
260
|
+
if (a2a['pushNotifications'] === true) return softSkip('inapplicable', 'a2a.pushNotifications is advertised — the push-config rows are served here, not refused');
|
|
261
|
+
const first = await startApprovalTask(t.url);
|
|
262
|
+
const id = first.task?.id;
|
|
263
|
+
expect(typeof id, req(rid, DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
|
|
264
|
+
// Positive control: the task is readable, so a -32001 below could not be masking the refusal under test.
|
|
265
|
+
const read = await rpc(t.url, 'GetTask', { id });
|
|
266
|
+
expect(read.error, req(rid, 'a2a.operations GetTask', `positive control: the task the suite just started MUST be readable (got ${JSON.stringify(read.error)})`)).toBeUndefined();
|
|
267
|
+
const configId = `conf-0214-${randomBytes(6).toString('hex')}`;
|
|
268
|
+
const calls: Array<[string, Record<string, unknown>]> = [
|
|
269
|
+
['CreateTaskPushNotificationConfig', { taskId: id, url: 'https://push.example.com/openwop-conformance' }],
|
|
270
|
+
['GetTaskPushNotificationConfig', { taskId: id, id: configId }],
|
|
271
|
+
['ListTaskPushNotificationConfigs', { taskId: id }],
|
|
272
|
+
['DeleteTaskPushNotificationConfig', { taskId: id, id: configId }],
|
|
273
|
+
];
|
|
274
|
+
for (const [method, params] of calls) {
|
|
275
|
+
const r = await rpc(t.url, method, params);
|
|
276
|
+
expect(r.error?.code, req(rid, pushDoc, `${method} on a host not advertising a2a.pushNotifications MUST be refused PushNotificationNotSupportedError -32003 — not method-not-found -32601 (got ${JSON.stringify(r.error ?? r.result)})`)).toBe(-32003);
|
|
277
|
+
}
|
|
278
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── RFC 0214 — push-config legs on a host that ADVERTISES a2a.pushNotifications ──
|
|
282
|
+
// Delivery itself (credential at the destination, no redirect, discard, fork) needs
|
|
283
|
+
// a public receiver and lives in v2-a2a-push-delivery.test.ts; these three need none.
|
|
284
|
+
|
|
285
|
+
it('RFC 0214 — CreateTaskPushNotificationConfig refuses a non-https or private destination', async () => {
|
|
286
|
+
const rid = 'openwop.requirement.0214.a2a-push-register-ssrf';
|
|
287
|
+
const doc = 'interop-map.json a2a.operations CreateTaskPushNotificationConfig ("the url MUST pass the webhooks.md egress guard"); webhooks.md §Egress; RFC 0214 §B';
|
|
288
|
+
const t = await target(true);
|
|
289
|
+
if (!t.ok) return skip(t, rid);
|
|
290
|
+
const a2a = (await familyAdvertised('a2a'))!;
|
|
291
|
+
if (a2a['pushNotifications'] !== true) return softSkip('inapplicable', 'a2a.pushNotifications is not advertised — the refusal row (a2a-push-unadvertised-refused) applies instead');
|
|
292
|
+
const first = await startApprovalTask(t.url);
|
|
293
|
+
const id = first.task?.id;
|
|
294
|
+
expect(typeof id, req(rid, DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
|
|
295
|
+
for (const bad of ['http://push.example.com/openwop-conformance', 'https://10.0.0.1/openwop-conformance', 'https://127.0.0.1/openwop-conformance', 'https://169.254.169.254/latest/meta-data']) {
|
|
296
|
+
const r = await rpc(t.url, 'CreateTaskPushNotificationConfig', { taskId: id, url: bad });
|
|
297
|
+
expect(r.result, req(rid, doc, `a push destination the egress guard refuses (${bad}) MUST NOT be registered (got result ${JSON.stringify(r.result)})`)).toBeUndefined();
|
|
298
|
+
expect(typeof r.error?.code, req(rid, doc, `the refusal of ${bad} MUST be a JSON-RPC error (got ${JSON.stringify(r)})`)).toBe('number');
|
|
299
|
+
}
|
|
300
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('RFC 0214 §E — a push config of another tenant\'s task, of another task, or unknown, is answered identically', async () => {
|
|
304
|
+
const rid = 'openwop.requirement.0214.a2a-push-config-isolation';
|
|
305
|
+
const doc = 'interop.md §"A2A push delivery" (RFC 0214 §E); interop.md §"The operation mappings" Isolation';
|
|
306
|
+
const t = await target(true);
|
|
307
|
+
if (!t.ok) return skip(t, rid);
|
|
308
|
+
const a2a = (await familyAdvertised('a2a'))!;
|
|
309
|
+
if (a2a['pushNotifications'] !== true) return softSkip('inapplicable', 'a2a.pushNotifications is not advertised — no push config can exist to isolate');
|
|
310
|
+
const one = (await startApprovalTask(t.url)).task?.id;
|
|
311
|
+
const two = (await startApprovalTask(t.url)).task?.id;
|
|
312
|
+
expect(typeof one === 'string' && typeof two === 'string' && one.includes('/'), req(rid, DOC, 'SendMessage MUST start two tasks with tenant-bound ids')).toBe(true);
|
|
313
|
+
const dest = unservedDestination('https://example.com/openwop-conformance-push');
|
|
314
|
+
const created = await rpc(t.url, 'CreateTaskPushNotificationConfig', { taskId: one, url: dest.url });
|
|
315
|
+
const cfg = (created.result ?? {}) as { id?: string };
|
|
316
|
+
if (created.error !== undefined && !dest.tunnelled) {
|
|
317
|
+
await rpc(t.url, 'CancelTask', { id: one }); await rpc(t.url, 'CancelTask', { id: two });
|
|
318
|
+
return softSkip('blocked', `Create was refused for ${dest.url} and no public front (OPENWOP_WEBHOOK_RECEIVER_URL) is wired — the isolation leg needs one config to exist (${JSON.stringify(created.error)})`);
|
|
319
|
+
}
|
|
320
|
+
expect(typeof cfg.id, req(rid, doc, `positive control: Create on the caller's own task with a public destination MUST return a config id (got ${JSON.stringify(created.error ?? created.result)})`)).toBe('string');
|
|
321
|
+
const own = await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: one, id: cfg.id });
|
|
322
|
+
expect(own.error, req(rid, doc, `positive control: the caller MUST read its own config back (got ${JSON.stringify(own.error)})`)).toBeUndefined();
|
|
323
|
+
const opaque = one!.slice(one!.indexOf('/') + 1);
|
|
324
|
+
const foreignTask = `zz-conformance-foreign/${opaque}`;
|
|
325
|
+
const unknownId = `pnc-${randomBytes(12).toString('hex')}`;
|
|
326
|
+
const answers: Array<[string, Rpc, string]> = [
|
|
327
|
+
['unknown config id', await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: one, id: unknownId }), unknownId],
|
|
328
|
+
['config id under another of the caller\'s tasks', await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: two, id: cfg.id }), cfg.id!],
|
|
329
|
+
['config id under another tenant\'s task segment', await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: foreignTask, id: cfg.id }), cfg.id!],
|
|
330
|
+
];
|
|
331
|
+
const shape = (r: Rpc, echo: string): string => JSON.stringify({ status: r.status, code: r.error?.code, message: (r.error?.message ?? '').split(echo).join('<id>'), data: normaliseErrorData(r.error?.data, echo), result: r.result });
|
|
332
|
+
const base = shape(answers[0]![1], answers[0]![2]);
|
|
333
|
+
expect(answers[0]![1].error?.code, req(rid, doc, `an unknown config id MUST be refused (got ${JSON.stringify(answers[0]![1].result)})`)).toBeTypeOf('number');
|
|
334
|
+
for (const [what, r, echo] of answers.slice(1)) {
|
|
335
|
+
expect(shape(r, echo), req(rid, doc, `a read of a ${what} MUST be answered exactly as an unknown id — same status, code, message and error details (minus an echo of the requested id)`)).toBe(base);
|
|
336
|
+
}
|
|
337
|
+
const foreignDelete = await rpc(t.url, 'DeleteTaskPushNotificationConfig', { taskId: foreignTask, id: cfg.id });
|
|
338
|
+
expect(foreignDelete.result === undefined || JSON.stringify(foreignDelete.result) === '{}', req(rid, doc, `a delete naming another tenant's task MUST NOT disclose anything beyond the idempotent answer (got ${JSON.stringify(foreignDelete)})`)).toBe(true);
|
|
339
|
+
const still = await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: one, id: cfg.id });
|
|
340
|
+
expect(still.error, req(rid, doc, 'a delete naming another tenant\'s task MUST NOT delete the caller\'s config')).toBeUndefined();
|
|
341
|
+
await rpc(t.url, 'DeleteTaskPushNotificationConfig', { taskId: one, id: cfg.id });
|
|
342
|
+
const again = await rpc(t.url, 'DeleteTaskPushNotificationConfig', { taskId: one, id: cfg.id });
|
|
343
|
+
expect(again.error, req(rid, 'A2A v1.0.1 §3.1.10 ("MUST be idempotent")', `a second delete of the same config MUST succeed idempotently (got ${JSON.stringify(again.error)})`)).toBeUndefined();
|
|
344
|
+
await rpc(t.url, 'CancelTask', { id: one }); await rpc(t.url, 'CancelTask', { id: two });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('RFC 0214 §A — a push config\'s token and credentials are never returned, and never reach task, events or state', async () => {
|
|
348
|
+
const rid = 'openwop.requirement.0214.a2a-push-secrets-not-returned';
|
|
349
|
+
const doc = 'interop-map.json a2a.operations push rows ("secrets are never returned"); security-defaults.md §"Onward hops" (RFC 0214 §A)';
|
|
350
|
+
const t = await target(true);
|
|
351
|
+
if (!t.ok) return skip(t, rid);
|
|
352
|
+
const a2a = (await familyAdvertised('a2a'))!;
|
|
353
|
+
if (a2a['pushNotifications'] !== true) return softSkip('inapplicable', 'a2a.pushNotifications is not advertised — no push credential can be registered');
|
|
354
|
+
const first = await startApprovalTask(t.url);
|
|
355
|
+
const id = first.task?.id;
|
|
356
|
+
expect(typeof id, req(rid, DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
|
|
357
|
+
const credSentinel = `owcred${randomBytes(12).toString('hex')}`;
|
|
358
|
+
const tokenSentinel = `owtok${randomBytes(12).toString('hex')}`;
|
|
359
|
+
const dest = unservedDestination('https://example.com/openwop-conformance-push');
|
|
360
|
+
const created = await rpc(t.url, 'CreateTaskPushNotificationConfig', { taskId: id, url: dest.url, token: tokenSentinel, authentication: { scheme: 'Bearer', credentials: credSentinel } });
|
|
361
|
+
if (created.error !== undefined && !dest.tunnelled) {
|
|
362
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
363
|
+
return softSkip('blocked', `Create was refused for ${dest.url} and no public front (OPENWOP_WEBHOOK_RECEIVER_URL) is wired (${JSON.stringify(created.error)})`);
|
|
364
|
+
}
|
|
365
|
+
const cfgId = (created.result as { id?: string } | undefined)?.id;
|
|
366
|
+
expect(typeof cfgId, req(rid, doc, `positive control: Create with a credential MUST succeed (got ${JSON.stringify(created.error ?? created.result)})`)).toBe('string');
|
|
367
|
+
const leaks = (what: string, v: unknown): void => {
|
|
368
|
+
const text = JSON.stringify(v ?? null);
|
|
369
|
+
expect(text.includes(credSentinel) || text.includes(tokenSentinel), req(rid, doc, `${what} MUST NOT carry the registered token or credentials`)).toBe(false);
|
|
370
|
+
};
|
|
371
|
+
leaks('the Create response', created.result);
|
|
372
|
+
leaks('GetTaskPushNotificationConfig', (await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: id, id: cfgId })).result);
|
|
373
|
+
leaks('ListTaskPushNotificationConfigs', (await rpc(t.url, 'ListTaskPushNotificationConfigs', { taskId: id })).result);
|
|
374
|
+
leaks('GetTask', (await rpc(t.url, 'GetTask', { id })).result);
|
|
375
|
+
leaks('the run snapshot (getRun)', (await driver.get(`/runs/${encodeURIComponent(id!)}`)).json);
|
|
376
|
+
leaks('the run event log (pollRunEvents)', (await driver.get(`/runs/${encodeURIComponent(id!)}/events/poll?timeout=1`)).json);
|
|
377
|
+
await rpc(t.url, 'DeleteTaskPushNotificationConfig', { taskId: id, id: cfgId });
|
|
378
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
379
|
+
});
|
|
380
|
+
|
|
252
381
|
it('ListTasks returns only what listRuns returns to the same Subject; tenant and contextId never select', async () => {
|
|
253
382
|
const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
|
|
254
383
|
if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-caller leg cannot run');
|