@openwop/openwop-conformance 2.45.2 → 2.45.4

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +2 -2
  3. package/dist/cli.js +1 -1
  4. package/dist/lib/certification-bundle-verify.js +22 -1
  5. package/dist/lib/profiles.js +17 -2
  6. package/dist/lib/requirement-registry.js +15 -0
  7. package/dist/lib/scenario-disposition.js +60 -9
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/fixtures/conformance-budget-tool-calls.json +81 -0
  10. package/fixtures/conformance-fs-probe.json +25 -0
  11. package/fixtures/conformance-queue-consume.json +27 -0
  12. package/fixtures/conformance-queue-publish.json +29 -0
  13. package/fixtures/conformance-safefetch-probe.json +23 -0
  14. package/fixtures/conformance-secret-resolve-then-fail.json +33 -0
  15. package/fixtures/conformance-storage-probe.json +27 -0
  16. package/fixtures/conformance-tool-scope-probe.json +27 -0
  17. package/fixtures/openwop-secrets-run-witness.json +29 -0
  18. package/fixtures.md +113 -1
  19. package/package.json +2 -2
  20. package/requirement-aliases.json +72 -71
  21. package/requirements.json +1081 -43
  22. package/scenario-majors.json +45 -3
  23. package/schemas/CORPUS-STAMP.json +22 -22
  24. package/src/cli.ts +1 -1
  25. package/src/lib/backpressure-witness.ts +163 -0
  26. package/src/lib/budget-witness.ts +165 -0
  27. package/src/lib/certification-bundle-verify.ts +23 -2
  28. package/src/lib/driver.ts +61 -2
  29. package/src/lib/major-profile.ts +93 -0
  30. package/src/lib/memoryAttribution.ts +41 -6
  31. package/src/lib/polling.ts +2 -18
  32. package/src/lib/profiles.ts +26 -2
  33. package/src/lib/requirement-registry.ts +12 -0
  34. package/src/lib/run-secrets-witness.ts +240 -0
  35. package/src/lib/scenario-disposition.ts +54 -7
  36. package/src/lib/scratch-host.ts +130 -0
  37. package/src/lib/secret-scan.ts +141 -0
  38. package/src/lib/timeout-scale.ts +25 -0
  39. package/src/lib/triggerBridge.ts +69 -1
  40. package/src/scenarios/byok-roundtrip.test.ts +20 -4
  41. package/src/scenarios/runner-ledger.test.ts +2 -0
  42. package/src/scenarios/secrets-run-witness.test.ts +154 -0
  43. package/src/scenarios/trigger-bridge-delivery.test.ts +183 -126
  44. package/src/scenarios/trigger-refused-event-keeps-subscription.test.ts +141 -0
  45. package/src/scenarios/v2-budget-enforcement.test.ts +94 -0
  46. package/src/scenarios/v2-eval-mode-unadvertised-refused.test.ts +55 -0
  47. package/src/scenarios/v2-fs-sandbox-escape-refused.test.ts +161 -0
  48. package/src/scenarios/v2-memory-cross-tenant-isolation.test.ts +113 -0
  49. package/src/scenarios/v2-production-backpressure.test.ts +74 -0
  50. package/src/scenarios/v2-queue-cross-tenant-isolation.test.ts +118 -0
  51. package/src/scenarios/v2-safefetch-ssrf-refused.test.ts +198 -0
  52. package/src/scenarios/v2-secret-canary-absent.test.ts +211 -0
  53. package/src/scenarios/v2-secrets-run-witness.test.ts +167 -0
  54. package/src/scenarios/v2-storage-cross-tenant-isolation.test.ts +213 -0
  55. package/src/scenarios/v2-tool-authorization-fail-closed.test.ts +197 -0
  56. package/src/scenarios/v2-workspace-scope-from-identity.test.ts +146 -0
@@ -31,7 +31,7 @@ import {
31
31
  type DiscoveryPayload,
32
32
  } from './profiles.js';
33
33
  import { CERTIFIABLE, DISPOSITIONS, type Disposition } from './requirement-ledger.js';
34
- import { floorFilesFor, requirementIdForPrefix, requirementIdForScenario } from './requirement-registry.js';
34
+ import { floorFilesFor, requirementIdForAnyOf, requirementIdForPrefix, requirementIdForScenario } from './requirement-registry.js';
35
35
  import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
36
36
 
37
37
  /**
@@ -149,7 +149,7 @@ export function findLiteral(value: unknown, literal: string): string[] {
149
149
  * it is the prefix requirement id, and the rows that satisfy it are the
150
150
  * matching scenario rows.
151
151
  */
152
- function requiredFor(profile: string, document: Readonly<Record<string, unknown>>): { files: string[]; prefixes: string[]; discoveryOnly: boolean } | null {
152
+ function requiredFor(profile: string, document: Readonly<Record<string, unknown>>): { files: string[]; prefixes: string[]; anyOf: Array<readonly string[]>; discoveryOnly: boolean } | null {
153
153
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
154
154
  if (floor === undefined) return null;
155
155
  // Discovery-conditional floors (G7) are evaluated against the bundle's own
@@ -159,6 +159,7 @@ function requiredFor(profile: string, document: Readonly<Record<string, unknown>
159
159
  return {
160
160
  files: [...files],
161
161
  prefixes: [...(floor.requiredAnyPrefix ?? [])],
162
+ anyOf: [...(floor.requiredAnyOf ?? [])],
162
163
  discoveryOnly: floor.discoveryOnly === true,
163
164
  };
164
165
  }
@@ -272,6 +273,26 @@ export function verifyBundleV2(bundle: BundleV2Like): BundleV2Verdict {
272
273
  if (!considered.some((r) => isWitnessedPass(r))) notCertifiable.push(id);
273
274
  }
274
275
 
276
+ for (const group of req.anyOf) {
277
+ // RFC 0229 §E: one requirement, satisfied by a WITNESSED pass of any
278
+ // member with no member failing. The emitter writes a summary row under
279
+ // the group id; member rows are read when it is absent.
280
+ const id = requirementIdForAnyOf(group);
281
+ required.push(id);
282
+ const summary = byId.get(id)?.[0];
283
+ const members = group.map((f) => byId.get(requirementIdForScenario(scenarioBasename(f)))?.[0]).filter((r): r is BundleV2Requirement => r !== undefined);
284
+ if (summary === undefined && members.length === 0) {
285
+ profileRejections.push({ kind: 'unwitnessed-requirement', profile, requirementId: id, detail: `${id}: no alternative (${group.join(', ')}) recorded a row` });
286
+ continue;
287
+ }
288
+ const considered = summary !== undefined ? [summary] : members;
289
+ if (considered.some((r) => r.disposition === 'executed-pass' && !isWitnessedPass(r))) {
290
+ profileRejections.push({ kind: 'vacuous-pass', profile, requirementId: id, detail: `${id}: executed-pass with no witnessed assertion` });
291
+ continue;
292
+ }
293
+ if (considered.some((r) => r.disposition === 'executed-fail') || !considered.some((r) => isWitnessedPass(r))) notCertifiable.push(id);
294
+ }
295
+
275
296
  const evidenceValid = profileRejections.length === 0;
276
297
  const certified = derivable && evidenceValid && notCertifiable.length === 0 && (req.discoveryOnly || required.length > 0);
277
298
  return { profile, derivable, floorUnspecified: false, required, rejections: profileRejections, notCertifiable, evidenceValid, certified };
package/src/lib/driver.ts CHANGED
@@ -11,6 +11,46 @@
11
11
 
12
12
  import { loadEnv } from './env.js';
13
13
  import { seamPath, targetMajor } from './seams.js';
14
+ import { scaledTimeoutMs } from './timeout-scale.js';
15
+
16
+ /**
17
+ * How long one request may go unanswered before the driver gives up (#1829).
18
+ * Below vitest's 30 s `testTimeout`, so a lost response ends as a named
19
+ * {@link TransportError} and not as a bare harness timeout. Driver long-polls
20
+ * are at most 5 s. Scales with `OPENWOP_POLL_TIMEOUT_SCALE`.
21
+ */
22
+ export const REQUEST_TIMEOUT_MS = 20_000;
23
+
24
+ /** Leads every {@link TransportError} message; `resolveItRecord` keys on it. */
25
+ export const TRANSPORT_LOSS_PREFIX = 'transport-loss: ';
26
+
27
+ /**
28
+ * No response arrived: the request timed out or the connection failed. The
29
+ * suite observed nothing about the host, so this is never a verdict on it. A
30
+ * runner whose network dropped for 9 s once recorded `executed-fail` against a
31
+ * host that had answered in 2.6 ms (MyndHyve cut, suite 2.45.2).
32
+ */
33
+ export class TransportError extends Error {
34
+ constructor(
35
+ readonly kind: 'timeout' | 'network',
36
+ readonly method: string,
37
+ /** Origin and path only. A query string can carry a token. */
38
+ readonly target: string,
39
+ reason: string,
40
+ ) {
41
+ super(`${TRANSPORT_LOSS_PREFIX}${method} ${target} got no response (${reason}); the suite could not observe the host`);
42
+ this.name = 'TransportError';
43
+ }
44
+ }
45
+
46
+ function withoutQuery(url: string): string {
47
+ try {
48
+ const u = new URL(url);
49
+ return `${u.origin}${u.pathname}`;
50
+ } catch {
51
+ return url.split('?')[0] ?? url;
52
+ }
53
+ }
14
54
 
15
55
  export interface OpenWOPResponse {
16
56
  readonly status: number;
@@ -25,6 +65,10 @@ export interface OpenWOPRequestInit {
25
65
  /** `false` sends no default credential. A caller-supplied `Authorization`
26
66
  * header is always sent as given, whatever this says. */
27
67
  readonly authenticated?: boolean;
68
+ /** Replaces the driver's own bound entirely; an abort it raises is rethrown as-is. */
69
+ readonly signal?: AbortSignal;
70
+ /** Overrides {@link REQUEST_TIMEOUT_MS} for one request (scaled the same way). */
71
+ readonly timeoutMs?: number;
28
72
  }
29
73
 
30
74
  class OpenWOPDriver {
@@ -83,9 +127,24 @@ class OpenWOPDriver {
83
127
  fetchInit.body = JSON.stringify(init.body);
84
128
  }
85
129
  }
86
- const res = await fetch(url, fetchInit);
130
+ const boundMs = scaledTimeoutMs(init.timeoutMs ?? REQUEST_TIMEOUT_MS);
131
+ fetchInit.signal = init.signal ?? AbortSignal.timeout(boundMs);
132
+ let res: Response;
133
+ let text: string;
134
+ try {
135
+ res = await fetch(url, fetchInit);
136
+ // The body is read under the same bound: a response whose headers arrive
137
+ // and whose body never does is the same lost observation.
138
+ text = await res.text();
139
+ } catch (err) {
140
+ // The caller's own signal aborted: that is the caller's event, not a loss.
141
+ if (init.signal?.aborted === true) throw err;
142
+ const name = err instanceof Error ? err.name : '';
143
+ if (name === 'TimeoutError') throw new TransportError('timeout', method, withoutQuery(url), `no response within ${boundMs} ms`);
144
+ const cause = err instanceof Error ? (err.cause instanceof Error ? err.cause.message : err.message) : String(err);
145
+ throw new TransportError('network', method, withoutQuery(url), cause.slice(0, 160));
146
+ }
87
147
 
88
- const text = await res.text();
89
148
  let json: unknown;
90
149
  try {
91
150
  json = text.length > 0 ? JSON.parse(text) : undefined;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What differs between protocol majors, as data.
3
+ *
4
+ * A scenario that is "the same requirement at another major" used to be a
5
+ * copy of the earlier file with the paths, event names and envelope rules
6
+ * edited by hand. The differences are few and regular, so they live in one
7
+ * table here. A shared witness (`backpressure-witness.ts`) takes a profile and
8
+ * never names a major; the scenario file for each major is a thin wrapper.
9
+ *
10
+ * **Adding a major** is one row in {@link MAJOR_PROFILES} plus one thin
11
+ * scenario file per ported witness. `majorProfile()` throws on a major with no
12
+ * row, so a missing row is a loud failure and never a silent fall back to an
13
+ * older major's rules.
14
+ *
15
+ * Keep a field here only when a shared witness reads it. A rule one major
16
+ * states and another does not is not a field: the witness asks the profile
17
+ * whether the rule binds (see `retryTiming`), and cites the document that
18
+ * states it.
19
+ */
20
+
21
+ import { capabilityFamily } from './discovery-capabilities.js';
22
+ import { codemapV1toV2 } from './era2-seed.js';
23
+
24
+ export interface MajorProfile {
25
+ readonly major: number;
26
+ /** The run collection, e.g. `/v1/runs` or `/runs`. */
27
+ readonly runsPath: string;
28
+ /** Headers a raw `fetch` must add to speak this major (the driver adds its own). */
29
+ readonly versionHeaders: Readonly<Record<string, string>>;
30
+ /** Where the corpus states this major's rules, for requirement citations. */
31
+ readonly specRoot: string;
32
+ /**
33
+ * Where retry timing lives on an error response. `header-and-details`: the
34
+ * envelope repeats `Retry-After` in `details.retryAfter`. `header-only`: the
35
+ * header is the only place, and `details.retryAfter*` is forbidden.
36
+ */
37
+ readonly retryTiming: 'header-and-details' | 'header-only';
38
+ /** The advertised record for a capability family, or `null` when the host does not advertise it. */
39
+ family(doc: unknown, key: string): Record<string, unknown> | null;
40
+ /**
41
+ * The `createRun` body fragment that carries a run budget policy, or `null`
42
+ * when this major has no run wire surface for one (a witness that needs it
43
+ * is then `inapplicable` at this major).
44
+ */
45
+ runBudget(policy: Readonly<Record<string, unknown>>): Record<string, unknown> | null;
46
+ /**
47
+ * This major's name for an event, given its era-1 name. `undefined` when the
48
+ * mapping is not on disk in this layout: the caller records that as an
49
+ * unread observation and never guesses a name.
50
+ */
51
+ eventType(era1Name: string): string | undefined;
52
+ }
53
+
54
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
55
+
56
+ export const MAJOR_PROFILES: Readonly<Record<number, MajorProfile>> = {
57
+ 1: {
58
+ major: 1,
59
+ runsPath: '/v1/runs',
60
+ versionHeaders: {},
61
+ specRoot: 'spec/v1',
62
+ retryTiming: 'header-and-details',
63
+ // v1: a family is advertised when its record says `supported: true`.
64
+ family: (doc, key) => {
65
+ const rec = capabilityFamily(doc, key);
66
+ return isRecord(rec) && rec['supported'] === true ? rec : null;
67
+ },
68
+ // v1 budgets are driven through a host seam, not `createRun`.
69
+ runBudget: () => null,
70
+ eventType: (era1Name) => era1Name,
71
+ },
72
+ 2: {
73
+ major: 2,
74
+ runsPath: '/runs',
75
+ versionHeaders: { 'OpenWOP-Version': '2.0' },
76
+ specRoot: 'spec/v2/core',
77
+ retryTiming: 'header-only',
78
+ // v2: presence of the record is the advertisement.
79
+ family: (doc, key) => {
80
+ const rec = isRecord(doc) ? doc[key] : undefined;
81
+ return isRecord(rec) ? rec : null;
82
+ },
83
+ runBudget: (policy) => ({ configurable: { version: 1, budget: { ...policy } } }),
84
+ eventType: (era1Name) => codemapV1toV2().get(era1Name),
85
+ },
86
+ };
87
+
88
+ /** The profile for a major. Throws when the table has no row for it. */
89
+ export function majorProfile(major: number): MajorProfile {
90
+ const p = MAJOR_PROFILES[major];
91
+ if (p === undefined) throw new Error(`no MajorProfile for major ${major} — add a row to MAJOR_PROFILES in lib/major-profile.ts`);
92
+ return p;
93
+ }
@@ -42,9 +42,19 @@ export async function readMemoryAttributionCap(): Promise<Record<string, unknown
42
42
  return attr && typeof attr === 'object' ? (attr as Record<string, unknown>) : null;
43
43
  }
44
44
 
45
- /** True when the host commits to emitting `memory.written`. */
45
+ /**
46
+ * True when the host commits to emitting `memory.written`.
47
+ *
48
+ * Major 1: the v1 block's `supported: true` (its schema `const`) AND
49
+ * `emitsWriteEvents: true`. Major 2: there is no `supported` field — presence
50
+ * of the `memory` family record IS the claim (RFC 0169 §A.2), and
51
+ * `readMemoryAttributionCap` returns the `attribution` object only when that
52
+ * record is present — so the commitment is `emitsWriteEvents: true` alone.
53
+ * Requiring `supported` at major 2 soft-skipped every v2 host (suite 2.45.3).
54
+ */
46
55
  export function emitsWriteEvents(cap: Record<string, unknown> | null): boolean {
47
- return cap?.['supported'] === true && cap?.['emitsWriteEvents'] === true;
56
+ if (cap?.['emitsWriteEvents'] !== true) return false;
57
+ return targetMajor() === 2 ? true : cap['supported'] === true;
48
58
  }
49
59
 
50
60
  const SEED_FIXTURE = 'conformance-noop';
@@ -71,9 +81,34 @@ interface RunEventLike {
71
81
  payload?: Record<string, unknown>;
72
82
  }
73
83
 
74
- /** Fetches a run's events and returns only the `memory.written` ones. */
84
+ /**
85
+ * Fetches a run's events and returns only the `memory.written` ones.
86
+ *
87
+ * Major 2: `GET /runs/{runId}/events` is the SSE stream (`streamRunEvents`,
88
+ * `text/event-stream` only), so a JSON read of it yields nothing and every leg
89
+ * would soft-skip "run wrote no memory" — a `blocked` row that denies
90
+ * certification for a suite defect. The JSON projection at v2 is the poll
91
+ * (`events.md` §Poll), read from sequence 0 and paged on `afterSequence` until
92
+ * `lastSequence` is reached.
93
+ */
75
94
  export async function memoryWrittenEvents(runId: string): Promise<RunEventLike[]> {
76
- const res = await driver.get(`${runsPath()}/${encodeURIComponent(runId)}/events`);
77
- const events = (res.json as { events?: RunEventLike[] } | undefined)?.events ?? [];
78
- return events.filter((e) => e.type === 'memory.written');
95
+ if (targetMajor() !== 2) {
96
+ const res = await driver.get(`${runsPath()}/${encodeURIComponent(runId)}/events`);
97
+ const events = (res.json as { events?: RunEventLike[] } | undefined)?.events ?? [];
98
+ return events.filter((e) => e.type === 'memory.written');
99
+ }
100
+ const out: RunEventLike[] = [];
101
+ let after = -1;
102
+ for (let page = 0; page < 50; page++) {
103
+ const q = after < 0 ? '' : `&afterSequence=${after}`;
104
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1${q}`);
105
+ const body = res.json as { events?: Array<RunEventLike & { sequence?: number }>; lastSequence?: number } | undefined;
106
+ const events = res.status === 200 && Array.isArray(body?.events) ? body.events : [];
107
+ out.push(...events);
108
+ const seqs = events.map((e) => e.sequence).filter((s): s is number => typeof s === 'number');
109
+ const next = seqs.length ? Math.max(...seqs) : after;
110
+ if (next <= after || typeof body?.lastSequence !== 'number' || next >= body.lastSequence) break;
111
+ after = next;
112
+ }
113
+ return out.filter((e) => e.type === 'memory.written');
79
114
  }
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { driver } from './driver.js';
31
+ import { scaledTimeoutMs } from './timeout-scale.js';
31
32
  import { targetMajor } from './seams.js';
32
33
 
33
34
  export interface RunSnapshot {
@@ -52,24 +53,7 @@ export interface RunSnapshot {
52
53
  const POLL_INTERVAL_MS = 250;
53
54
  const DEFAULT_TIMEOUT_MS = Number(process.env.OPENWOP_LIFECYCLE_TIMEOUT_MS ?? 10_000);
54
55
 
55
- /**
56
- * Multiplier applied to every poll bound (see the module docstring). Invalid,
57
- * non-positive, or non-finite values fall back to `1` rather than silently
58
- * producing a zero or negative deadline — a mis-set knob must not turn every
59
- * poll into an instant failure that looks like a host defect.
60
- */
61
- function pollTimeoutScale(): number {
62
- const raw = process.env.OPENWOP_POLL_TIMEOUT_SCALE;
63
- if (raw === undefined || raw === '') return 1;
64
- const n = Number(raw);
65
- return Number.isFinite(n) && n > 0 ? n : 1;
66
- }
67
-
68
- /** Apply the scale to a bound, rounding up so a scale of 1 is exactly a no-op. */
69
- export function scaledTimeoutMs(timeoutMs: number): number {
70
- const scale = pollTimeoutScale();
71
- return scale === 1 ? timeoutMs : Math.ceil(timeoutMs * scale);
72
- }
56
+ export { scaledTimeoutMs };
73
57
 
74
58
  /**
75
59
  * Live-model scenarios (RFC 0111's `conformance-context-budget-live`) drive real
@@ -495,6 +495,16 @@ export interface ProfileFloor {
495
495
  readonly required: readonly string[];
496
496
  /** Prefix groups where ≥1 matching passed scenario satisfies the group. */
497
497
  readonly requiredAnyPrefix?: readonly string[];
498
+ /**
499
+ * RFC 0229 §E — ANY-OF groups: each inner list names alternative scenario
500
+ * files, and the group is satisfied when at least one of them records a
501
+ * WITNESSED pass and none of them records a failure. A group is one
502
+ * requirement (`openwop.floor.anyof.<stem>+<stem>`, a summary row the runner
503
+ * derives from the members, like `requiredAnyPrefix`). An `inapplicable` or
504
+ * `skipped` member never satisfies it: `openwop-secrets` is certified by the
505
+ * canary round-trip OR the run-witness, not by the absence of both.
506
+ */
507
+ readonly requiredAnyOf?: ReadonlyArray<readonly string[]>;
498
508
  /**
499
509
  * The profile is discovery-payload-only: its predicate IS the whole claim and
500
510
  * it has no runtime floor. This flag exists so an EMPTY floor is a decision on
@@ -529,6 +539,11 @@ export interface ProfileFloor {
529
539
  readonly conditional?: ReadonlyArray<{ readonly path: string; readonly includes: string; readonly required: readonly string[] }>;
530
540
  }
531
541
 
542
+ /** Every scenario FILE a floor names: `required`, every `conditional` branch, and every `requiredAnyOf` member. */
543
+ export function floorMemberFiles(floor: ProfileFloor): string[] {
544
+ return [...floor.required, ...(floor.conditional ?? []).flatMap((c) => c.required), ...(floor.requiredAnyOf ?? []).flat()];
545
+ }
546
+
532
547
  export const PROFILE_FLOOR_SCENARIOS: Readonly<Record<string, ProfileFloor>> = {
533
548
  'openwop-core-standard': {
534
549
  required: [
@@ -607,7 +622,11 @@ export const PROFILE_FLOOR_SCENARIOS: Readonly<Record<string, ProfileFloor>> = {
607
622
  // `profiles.md` §`openwop-secrets`: credential resolution per `run-options.md`
608
623
  // §"Credential references" — the BYOK canary round-trip (`fixtures.md`
609
624
  // §conformance-secrets-roundtrip, SR-1) is the profile's proof.
610
- 'openwop-secrets': { required: ['byok-roundtrip.test.ts'] },
625
+ // RFC 0229 §E (2026-09-30): a SECOND floor path, as an any-of group. The
626
+ // canary fixture's node is an oracle over whatever its name reaches, so a
627
+ // production host rightly withholds it; the run-witness reads only a value
628
+ // the caller supplied with the same run and outputs only `matched`.
629
+ 'openwop-secrets': { required: [], requiredAnyOf: [['byok-roundtrip.test.ts', 'secrets-run-witness.test.ts']] },
611
630
 
612
631
  // `profiles.md` §`openwop-provider-policy`: the four-mode taxonomy shape and
613
632
  // its enforcement on the wire.
@@ -756,9 +775,14 @@ export function verifyBundleProfile(bundle: CertificationBundleLike, profile: st
756
775
  }
757
776
  const missingFloor = requiredFiles.filter((r) => !passed.has(scenarioBasename(r)));
758
777
  const prefixOk = (floor.requiredAnyPrefix ?? []).every((p) => [...passed].some((s) => s.startsWith(p)));
778
+ // RFC 0229 §E any-of groups: a v1 bundle lists only what passed, so a group
779
+ // holds when one member is in `results.passed`; an unmet group is reported
780
+ // as missing, spelled as its alternatives.
781
+ const groups = floor.requiredAnyOf ?? [];
782
+ for (const g of groups) if (!g.some((f) => passed.has(scenarioBasename(f)))) missingFloor.push(g.join(' | '));
759
783
  // A conditional floor none of whose branches matched requires nothing — that
760
784
  // is unprovable for a non-discovery-only profile, not proven.
761
- const evaluable = floor.discoveryOnly === true || requiredFiles.length > 0 || (floor.requiredAnyPrefix ?? []).length > 0;
785
+ const evaluable = floor.discoveryOnly === true || requiredFiles.length > 0 || (floor.requiredAnyPrefix ?? []).length > 0 || groups.length > 0;
762
786
  const floorProven = evaluable && missingFloor.length === 0 && prefixOk;
763
787
  return {
764
788
  profile,
@@ -36,6 +36,15 @@ export function requirementIdForPrefix(prefix: string): string {
36
36
  return `openwop.floor.any.${prefix}`;
37
37
  }
38
38
 
39
+ /**
40
+ * Any-of groups (RFC 0229 §E) become one requirement:
41
+ * `['byok-roundtrip.test.ts', 'secrets-run-witness.test.ts']` →
42
+ * `openwop.floor.anyof.byok-roundtrip+secrets-run-witness`.
43
+ */
44
+ export function requirementIdForAnyOf(files: readonly string[]): string {
45
+ return `openwop.floor.anyof.${files.map((f) => f.replace(/\.test\.ts$/, '')).join('+')}`;
46
+ }
47
+
39
48
  /**
40
49
  * The requirement IDs a profile's certification rests on.
41
50
  *
@@ -122,6 +131,7 @@ export function requirementsFor(profile: string, document?: Readonly<Record<stri
122
131
  return [
123
132
  ...files.map(requirementIdForScenario),
124
133
  ...(floor.requiredAnyPrefix ?? []).map(requirementIdForPrefix),
134
+ ...(floor.requiredAnyOf ?? []).map(requirementIdForAnyOf),
125
135
  ];
126
136
  }
127
137
 
@@ -134,6 +144,8 @@ export function allRequirements(): readonly string[] {
134
144
  for (const f of floor.required) ids.add(requirementIdForScenario(f));
135
145
  for (const c of floor.conditional ?? []) for (const f of c.required) ids.add(requirementIdForScenario(f));
136
146
  for (const p of floor.requiredAnyPrefix ?? []) ids.add(requirementIdForPrefix(p));
147
+ // an any-of group is one requirement, and each member file records under its own floor id
148
+ for (const g of floor.requiredAnyOf ?? []) { ids.add(requirementIdForAnyOf(g)); for (const f of g) ids.add(requirementIdForScenario(f)); }
137
149
  void profile;
138
150
  }
139
151
  return [...ids].sort();
@@ -0,0 +1,240 @@
1
+ /**
2
+ * RFC 0229 — the production-safe secret witness, shared by `secrets-run-witness`
3
+ * (major 1, `/v1/...`) and `v2-secrets-run-witness` (major 2, unversioned
4
+ * paths under `OpenWOP-Version: 2.0`, which the driver adds).
5
+ *
6
+ * The suite draws a fresh value `C` per run (48 CSPRNG bytes, base64url, 64
7
+ * characters, no fixed prefix) and supplies it as the top-level
8
+ * `runSecrets: [{ ref, value: C }]` of `createRun`. The fixture
9
+ * `openwop-secrets-run-witness` runs one `core.secret.witness` node that
10
+ * compares `sha256(C)` with an `expectedSha256` input and outputs only
11
+ * `{ matched }`. The scans read RAW response text, never parsed JSON, so an
12
+ * escaping difference cannot hide a hit (RFC 0229 Implementation notes).
13
+ *
14
+ * @see spec/v2/core/host-services.md §Run-supplied secrets
15
+ * @see spec/v1/capabilities.md §"Run-supplied secrets"
16
+ * @see RFCS/0229-production-safe-secrets-witness.md
17
+ */
18
+
19
+ import { createHash, randomBytes } from 'node:crypto';
20
+ import { driver, type OpenWOPResponse } from './driver.js';
21
+ import { loadEnv } from './env.js';
22
+ import { discoveryFamilies } from './discovery-capabilities.js';
23
+ import { isFixtureAdvertised } from './fixtures.js';
24
+
25
+ export const WITNESS_FIXTURE = 'openwop-secrets-run-witness';
26
+ export const WITNESS_NODE = 'witness';
27
+ export const WITNESS_REF = 'run:openwop-witness';
28
+ export const CANARY_NAME = 'openwop-conformance-canary-secret';
29
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
30
+
31
+ export type Major = 1 | 2;
32
+ export type Gate =
33
+ | { ok: true; maxEntries: number; branchFork: boolean }
34
+ | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
35
+
36
+ export const sha256Hex = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex');
37
+
38
+ /** A fresh witness value: 48 CSPRNG bytes, base64url, 64 characters, no fixed prefix. */
39
+ export function freshValue(): string {
40
+ return randomBytes(48).toString('base64url');
41
+ }
42
+
43
+ /** A fresh non-`run:` secret name (the scope leg's "random name"). */
44
+ export function freshName(): string {
45
+ return `openwop-witness-${randomBytes(9).toString('hex')}`;
46
+ }
47
+
48
+ /**
49
+ * Every form in which `C` "appears" (RFC 0229 §F row 2): raw; standard and
50
+ * URL-safe base64 of its UTF-8 bytes, padded and not; lower- and upper-case
51
+ * hex of those bytes; percent-encoded; JSON-string-escaped; and `sha256(C)`
52
+ * (lower and upper hex, and base64) — the digest is an output §B.3 forbids.
53
+ */
54
+ export function forms(c: string): ReadonlyArray<{ name: string; text: string }> {
55
+ const bytes = Buffer.from(c, 'utf8');
56
+ const b64 = bytes.toString('base64');
57
+ const b64url = bytes.toString('base64url');
58
+ const digest = createHash('sha256').update(bytes).digest();
59
+ const out = [
60
+ { name: 'raw', text: c },
61
+ { name: 'base64', text: b64 },
62
+ { name: 'base64 (unpadded)', text: b64.replace(/=+$/, '') },
63
+ { name: 'base64url', text: b64url },
64
+ { name: 'base64url (padded)', text: b64url + '='.repeat((4 - (b64url.length % 4)) % 4) },
65
+ { name: 'hex', text: bytes.toString('hex') },
66
+ { name: 'HEX', text: bytes.toString('hex').toUpperCase() },
67
+ { name: 'percent-encoded', text: encodeURIComponent(c) },
68
+ { name: 'JSON-escaped', text: JSON.stringify(c).slice(1, -1) },
69
+ { name: 'sha256 hex', text: digest.toString('hex') },
70
+ { name: 'SHA256 HEX', text: digest.toString('hex').toUpperCase() },
71
+ { name: 'sha256 base64', text: digest.toString('base64') },
72
+ ];
73
+ return out.filter((f, i) => out.findIndex((g) => g.text === f.text) === i);
74
+ }
75
+
76
+ /**
77
+ * The forms of `c` that occur in `text`. `digestSupplied`: the suite itself
78
+ * passed `sha256(C)` as the run's `expectedSha256` input, which a host may
79
+ * legitimately echo as an input, so the digest forms are not a leak for that
80
+ * run and are not scanned. They are scanned for every value whose digest the
81
+ * suite never sent (the mismatch run, and the retried value `C′`).
82
+ */
83
+ export function hits(text: string, c: string, digestSupplied = false): string[] {
84
+ return forms(c).filter((f) => !(digestSupplied && f.name.toLowerCase().startsWith('sha256'))).filter((f) => text.includes(f.text)).map((f) => f.name);
85
+ }
86
+
87
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
88
+ try { return await fn(); } catch { return null; }
89
+ }
90
+
91
+ export class WitnessHost {
92
+ constructor(readonly major: Major) {}
93
+
94
+ private p(path: string): string {
95
+ return this.major === 1 ? `/v1${path}` : path;
96
+ }
97
+
98
+ private enc(id: string): string {
99
+ return encodeURIComponent(id);
100
+ }
101
+
102
+ async discovery(): Promise<Record<string, unknown> | null> {
103
+ const res = await http(() => driver.get('/.well-known/openwop', { authenticated: false }));
104
+ return res?.status === 200 && res.json && typeof res.json === 'object' ? discoveryFamilies(res.json) : null;
105
+ }
106
+
107
+ /**
108
+ * RFC 0229 §F dispositions: facet absent ⇒ `inapplicable` (the host offers
109
+ * no run-supplied secrets); facet advertised without the fixture ⇒ `blocked`
110
+ * (§C requires it). Discovery unreadable ⇒ `blocked`.
111
+ */
112
+ async gate(): Promise<Gate> {
113
+ const doc = await this.discovery();
114
+ if (doc === null) return { ok: false, kind: 'blocked', reason: '/.well-known/openwop did not answer 200 with a JSON body' };
115
+ const secrets = doc['secrets'];
116
+ const rs = secrets && typeof secrets === 'object' ? (secrets as Record<string, unknown>)['runSecrets'] : undefined;
117
+ if (rs === undefined || rs === null || typeof rs !== 'object') {
118
+ return { ok: false, kind: 'inapplicable', reason: 'secrets.runSecrets is not advertised — the host offers no run-supplied secrets (RFC 0229 §D)' };
119
+ }
120
+ if (!isFixtureAdvertised(WITNESS_FIXTURE)) {
121
+ return { ok: false, kind: 'blocked', reason: `secrets.runSecrets is advertised but the fixture ${WITNESS_FIXTURE} is not — RFC 0229 §C requires it, so the witness cannot run` };
122
+ }
123
+ const maxEntries = Number((rs as Record<string, unknown>)['maxEntries']);
124
+ const replay = doc['replay'];
125
+ const modes = replay && typeof replay === 'object' ? (replay as Record<string, unknown>)['modes'] : undefined;
126
+ const supported = this.major === 1 ? (replay as Record<string, unknown> | undefined)?.['supported'] === true : replay !== undefined;
127
+ return { ok: true, maxEntries: Number.isFinite(maxEntries) ? maxEntries : 1, branchFork: supported && Array.isArray(modes) && modes.includes('branch') };
128
+ }
129
+
130
+ /** `createRun` of the witness fixture. `runSecrets` is omitted when `undefined`. */
131
+ create(inputs: { ref: string; expectedSha256: string }, runSecrets?: ReadonlyArray<{ ref: string; value: string }>, idempotencyKey?: string): Promise<OpenWOPResponse | null> {
132
+ const body: Record<string, unknown> = { workflowId: WITNESS_FIXTURE, inputs };
133
+ if (runSecrets !== undefined) body['runSecrets'] = runSecrets;
134
+ return http(() => driver.post(this.p('/runs'), body, idempotencyKey === undefined ? {} : { headers: { 'Idempotency-Key': idempotencyKey } }));
135
+ }
136
+
137
+ async snapshot(runId: string): Promise<OpenWOPResponse | null> {
138
+ return http(() => driver.get(this.p(`/runs/${this.enc(runId)}`)));
139
+ }
140
+
141
+ async waitTerminal(runId: string, timeoutMs = 20_000): Promise<Record<string, unknown> | null> {
142
+ const deadline = Date.now() + timeoutMs;
143
+ for (;;) {
144
+ const res = await this.snapshot(runId);
145
+ const snap = res?.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : null;
146
+ if (snap !== null && TERMINAL.has(String(snap['status']))) return snap;
147
+ if (Date.now() > deadline) return snap;
148
+ await new Promise((r) => setTimeout(r, 250));
149
+ }
150
+ }
151
+
152
+ async events(runId: string): Promise<{ text: string; events: Array<Record<string, unknown>> }> {
153
+ const res = await http(() => driver.get(this.p(`/runs/${this.enc(runId)}/events/poll?timeout=1`)));
154
+ let events: Array<Record<string, unknown>> = [];
155
+ const body = res?.json as { events?: unknown } | undefined;
156
+ if (Array.isArray(body?.events)) events = body.events as Array<Record<string, unknown>>;
157
+ if (events.length === 0 && this.major === 1) {
158
+ const alt = await http(() => driver.get(this.p(`/runs/${this.enc(runId)}/events`)));
159
+ const b = alt?.json as { events?: unknown } | undefined;
160
+ if (Array.isArray(b?.events)) return { text: `${res?.text ?? ''}\n${alt?.text ?? ''}`, events: b.events as Array<Record<string, unknown>> };
161
+ }
162
+ return { text: res?.text ?? '', events };
163
+ }
164
+
165
+ /** The raw SSE text under `streamMode=debug` — the most verbose mode (events.md §Stream modes). */
166
+ async debugStream(runId: string, timeoutMs = 6_000): Promise<string> {
167
+ const env = loadEnv();
168
+ const ctl = new AbortController();
169
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
170
+ try {
171
+ const res = await fetch(`${env.baseUrl}${this.p(`/runs/${this.enc(runId)}/events`)}?streamMode=debug`, {
172
+ headers: { Accept: 'text/event-stream', Authorization: `Bearer ${env.apiKey}`, ...(this.major === 2 ? { 'OpenWOP-Version': '2.0' } : {}) },
173
+ signal: ctl.signal,
174
+ });
175
+ if (res.body === null) return '';
176
+ try { return await res.text(); } catch { return ''; }
177
+ } catch {
178
+ return '';
179
+ } finally {
180
+ clearTimeout(timer);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Every readable surface of a terminal run, as raw text: the create answer,
186
+ * the snapshot, the polled events, the `debug` stream, the run list (where
187
+ * served), and the debug bundle (where the seam serves one).
188
+ */
189
+ async surfaces(runId: string, extra: readonly string[] = []): Promise<{ text: string; read: string[] }> {
190
+ const read: string[] = [];
191
+ const parts: string[] = [...extra];
192
+ if (extra.length > 0) read.push('createRun response');
193
+ const snap = await this.snapshot(runId);
194
+ if (snap !== null) { parts.push(snap.text); read.push('snapshot'); }
195
+ const ev = await this.events(runId);
196
+ parts.push(ev.text); read.push('events (poll)');
197
+ const dbg = await this.debugStream(runId);
198
+ if (dbg.length > 0) { parts.push(dbg); read.push('events (streamMode=debug)'); }
199
+ const list = await http(() => driver.get(this.p('/runs?limit=50')));
200
+ if (list?.status === 200) { parts.push(list.text); read.push('listRuns'); }
201
+ const bundle = await http(() => driver.post('/v1/host/sample/test/debug-bundle/export', { runId }));
202
+ if (bundle?.status === 200) { parts.push(bundle.text); read.push('debug bundle'); }
203
+ return { text: parts.join('\n'), read };
204
+ }
205
+
206
+ /** `witness`'s `matched`, from `node.completed` outputs or the snapshot; `undefined` when not observable. */
207
+ async matched(runId: string, snap: Record<string, unknown> | null): Promise<boolean | undefined> {
208
+ const { events } = await this.events(runId);
209
+ for (const e of events) {
210
+ if (e['type'] !== 'node.completed') continue;
211
+ const payload = (e['payload'] ?? e['data'] ?? {}) as Record<string, unknown>;
212
+ if ((payload['nodeId'] ?? e['nodeId']) !== WITNESS_NODE) continue;
213
+ const outputs = (payload['outputs'] ?? e['outputs']) as Record<string, unknown> | undefined;
214
+ if (typeof outputs?.['matched'] === 'boolean') return outputs['matched'] as boolean;
215
+ }
216
+ for (const key of ['outputs', 'variables', 'nodeOutputs']) {
217
+ const bag = snap?.[key] as Record<string, unknown> | undefined;
218
+ const node = bag?.[WITNESS_NODE] as Record<string, unknown> | undefined;
219
+ if (typeof node?.['matched'] === 'boolean') return node['matched'] as boolean;
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ /** `witness`'s `node.failed` error code; `null` when no `node.failed` names it. */
225
+ async nodeFailedCode(runId: string): Promise<string | null> {
226
+ const { events } = await this.events(runId);
227
+ for (const e of events) {
228
+ if (e['type'] !== 'node.failed') continue;
229
+ const payload = (e['payload'] ?? e['data'] ?? {}) as Record<string, unknown>;
230
+ if ((payload['nodeId'] ?? e['nodeId']) !== WITNESS_NODE) continue;
231
+ const err = (payload['error'] ?? e['error']) as Record<string, unknown> | undefined;
232
+ return typeof err?.['code'] === 'string' ? (err['code'] as string) : '';
233
+ }
234
+ return null;
235
+ }
236
+
237
+ fork(runId: string): Promise<OpenWOPResponse | null> {
238
+ return http(() => driver.post(this.p(`/runs/${this.enc(runId)}:fork`), { mode: 'branch', fromSeq: 0 }));
239
+ }
240
+ }