@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.
- package/CHANGELOG.md +77 -0
- package/README.md +2 -2
- package/dist/cli.js +1 -1
- package/dist/lib/certification-bundle-verify.js +22 -1
- package/dist/lib/profiles.js +17 -2
- package/dist/lib/requirement-registry.js +15 -0
- package/dist/lib/scenario-disposition.js +60 -9
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-budget-tool-calls.json +81 -0
- package/fixtures/conformance-fs-probe.json +25 -0
- package/fixtures/conformance-queue-consume.json +27 -0
- package/fixtures/conformance-queue-publish.json +29 -0
- package/fixtures/conformance-safefetch-probe.json +23 -0
- package/fixtures/conformance-secret-resolve-then-fail.json +33 -0
- package/fixtures/conformance-storage-probe.json +27 -0
- package/fixtures/conformance-tool-scope-probe.json +27 -0
- package/fixtures/openwop-secrets-run-witness.json +29 -0
- package/fixtures.md +113 -1
- package/package.json +2 -2
- package/requirement-aliases.json +72 -71
- package/requirements.json +1081 -43
- package/scenario-majors.json +45 -3
- package/schemas/CORPUS-STAMP.json +22 -22
- package/src/cli.ts +1 -1
- package/src/lib/backpressure-witness.ts +163 -0
- package/src/lib/budget-witness.ts +165 -0
- package/src/lib/certification-bundle-verify.ts +23 -2
- package/src/lib/driver.ts +61 -2
- package/src/lib/major-profile.ts +93 -0
- package/src/lib/memoryAttribution.ts +41 -6
- package/src/lib/polling.ts +2 -18
- package/src/lib/profiles.ts +26 -2
- package/src/lib/requirement-registry.ts +12 -0
- package/src/lib/run-secrets-witness.ts +240 -0
- package/src/lib/scenario-disposition.ts +54 -7
- package/src/lib/scratch-host.ts +130 -0
- package/src/lib/secret-scan.ts +141 -0
- package/src/lib/timeout-scale.ts +25 -0
- package/src/lib/triggerBridge.ts +69 -1
- package/src/scenarios/byok-roundtrip.test.ts +20 -4
- package/src/scenarios/runner-ledger.test.ts +2 -0
- package/src/scenarios/secrets-run-witness.test.ts +154 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +183 -126
- package/src/scenarios/trigger-refused-event-keeps-subscription.test.ts +141 -0
- package/src/scenarios/v2-budget-enforcement.test.ts +94 -0
- package/src/scenarios/v2-eval-mode-unadvertised-refused.test.ts +55 -0
- package/src/scenarios/v2-fs-sandbox-escape-refused.test.ts +161 -0
- package/src/scenarios/v2-memory-cross-tenant-isolation.test.ts +113 -0
- package/src/scenarios/v2-production-backpressure.test.ts +74 -0
- package/src/scenarios/v2-queue-cross-tenant-isolation.test.ts +118 -0
- package/src/scenarios/v2-safefetch-ssrf-refused.test.ts +198 -0
- package/src/scenarios/v2-secret-canary-absent.test.ts +211 -0
- package/src/scenarios/v2-secrets-run-witness.test.ts +167 -0
- package/src/scenarios/v2-storage-cross-tenant-isolation.test.ts +213 -0
- package/src/scenarios/v2-tool-authorization-fail-closed.test.ts +197 -0
- 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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
}
|
package/src/lib/polling.ts
CHANGED
|
@@ -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
|
package/src/lib/profiles.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|