@openwop/openwop-conformance 1.0.0 → 1.1.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +293 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +2 -2
  19. package/schemas/security-advisory.schema.json +109 -0
  20. package/src/lib/a2a-fake-peer.ts +143 -56
  21. package/src/lib/behavior-gate.ts +68 -0
  22. package/src/lib/env.ts +10 -0
  23. package/src/lib/grpc-framing.test.ts +96 -0
  24. package/src/lib/grpc-framing.ts +76 -0
  25. package/src/lib/oidc-issuer.test.ts +328 -0
  26. package/src/lib/oidc-issuer.ts +241 -0
  27. package/src/lib/otel-collector-grpc.test.ts +191 -0
  28. package/src/lib/otel-collector.test.ts +303 -0
  29. package/src/lib/otel-collector.ts +318 -14
  30. package/src/lib/otlp-protobuf.test.ts +461 -0
  31. package/src/lib/otlp-protobuf.ts +529 -0
  32. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  33. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  34. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  37. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  38. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  39. package/src/scenarios/agentMetadata.test.ts +1 -0
  40. package/src/scenarios/agentPackExport.test.ts +1 -0
  41. package/src/scenarios/agentPackInstall.test.ts +1 -0
  42. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  43. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  44. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  45. package/src/scenarios/auth-mtls.test.ts +274 -0
  46. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  47. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  48. package/src/scenarios/bulk-cancel.test.ts +111 -0
  49. package/src/scenarios/configurable-schema.test.ts +48 -0
  50. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  51. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  52. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  53. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  54. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  55. package/src/scenarios/discovery.test.ts +183 -0
  56. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  57. package/src/scenarios/idempotency.test.ts +6 -0
  58. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  59. package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
  60. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  61. package/src/scenarios/metric-emission.test.ts +113 -0
  62. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  63. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  64. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  65. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  66. package/src/scenarios/pause-resume.test.ts +119 -0
  67. package/src/scenarios/production-backpressure.test.ts +342 -0
  68. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  69. package/src/scenarios/registry-public.test.ts +131 -0
  70. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  71. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  72. package/src/scenarios/restart-during-run.test.ts +177 -0
  73. package/src/scenarios/spec-corpus-validity.test.ts +54 -26
  74. package/src/scenarios/staleClaim.test.ts +3 -0
  75. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  76. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  77. package/src/scenarios/webhook-negative.test.ts +90 -0
  78. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  79. package/src/setup.ts +25 -1
  80. package/vitest.config.ts +5 -1
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Replay retention-expiry scenario per `spec/v1/replay.md` §"Retention
3
+ * and garbage collection."
4
+ *
5
+ * Verifies the normative `replay.md:246` requirement:
6
+ *
7
+ * > If the source run still exists but the event range needed for
8
+ * > `fromSeq` has expired, the host MUST reject the fork with
9
+ * > `410 Gone` or `422 Unprocessable Entity` using the canonical
10
+ * > error envelope. The error `details` SHOULD include `sourceRunId`,
11
+ * > `fromSeq`, and the retention boundary when known.
12
+ *
13
+ * Forcing expiry is environmental (hosts don't standardize a force-
14
+ * expire endpoint — that's the same RFC 0009 Q#1 surface area). The
15
+ * scenario reads two operator-supplied env vars:
16
+ *
17
+ * - `OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID` — runId of a run whose
18
+ * events past `fromSeq` are known-expired on the host under test.
19
+ * - `OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ` — the `fromSeq` to
20
+ * fork against (defaults to 0; pre-expired ranges typically
21
+ * start from the earliest event).
22
+ *
23
+ * When neither is supplied, the scenario asserts only that the
24
+ * `replay` capability advertisement is well-formed and that
25
+ * `retention` metadata (when present) types correctly. The
26
+ * 410/422 envelope assertion soft-skips.
27
+ *
28
+ * @see spec/v1/replay.md §"Retention and garbage collection"
29
+ * @see RFCS/0009-production-profile-conformance.md §C (parallel
30
+ * retention-expiry pattern for run snapshots)
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest';
34
+ import { driver } from '../lib/driver.js';
35
+ import { behaviorGate } from '../lib/behavior-gate.js';
36
+
37
+ interface ReplayRetentionCaps {
38
+ windowSeconds?: number;
39
+ }
40
+
41
+ interface ReplayCaps {
42
+ supported?: boolean;
43
+ modes?: string[];
44
+ retention?: ReplayRetentionCaps;
45
+ }
46
+
47
+ const PROFILE = 'openwop-replay-fork';
48
+
49
+ async function readReplayCaps(): Promise<ReplayCaps | undefined> {
50
+ // Per existing replay-fork.test.ts convention: `replay` lives at the
51
+ // top level of the discovery body, not under capabilities.*.
52
+ const disco = await driver.get('/.well-known/openwop', { authenticated: false });
53
+ if (disco.status !== 200) return undefined;
54
+ return (disco.json as { replay?: ReplayCaps }).replay;
55
+ }
56
+
57
+ function isProfileAdvertised(replay: ReplayCaps | undefined): boolean {
58
+ return replay?.supported === true && Array.isArray(replay.modes) && replay.modes.length > 0;
59
+ }
60
+
61
+ describe('replay-retention-expiry: capability shape', () => {
62
+ it('host advertising replay surfaces well-formed retention metadata when present', async () => {
63
+ const replay = await readReplayCaps();
64
+
65
+ if (!behaviorGate(PROFILE, isProfileAdvertised(replay))) {
66
+ return;
67
+ }
68
+
69
+ expect(replay?.supported, driver.describe(
70
+ 'replay.md §"Retention and garbage collection"',
71
+ 'replay.supported MUST be true when the host claims the openwop-replay-fork profile',
72
+ )).toBe(true);
73
+
74
+ expect(
75
+ Array.isArray(replay?.modes) && (replay?.modes?.length ?? 0) > 0,
76
+ driver.describe(
77
+ 'profiles.md §`openwop-replay-fork`',
78
+ 'replay.modes MUST be a non-empty array',
79
+ ),
80
+ ).toBe(true);
81
+
82
+ // retention metadata is OPTIONAL per replay.md (the spec requires
83
+ // hosts document retention; it doesn't yet require advertising
84
+ // the window in discovery). When advertised, type strictly.
85
+ if (replay?.retention?.windowSeconds !== undefined) {
86
+ expect(
87
+ Number.isInteger(replay.retention.windowSeconds) &&
88
+ replay.retention.windowSeconds >= 0,
89
+ driver.describe(
90
+ 'replay.md §"Retention and garbage collection"',
91
+ 'replay.retention.windowSeconds MUST be a non-negative integer when advertised',
92
+ ),
93
+ ).toBe(true);
94
+ }
95
+ });
96
+ });
97
+
98
+ describe('replay-retention-expiry: 410/422 on expired-range fork', () => {
99
+ it('POST /v1/runs/{expiredRunId}:fork returns 410 or 422 with canonical envelope', async () => {
100
+ const replay = await readReplayCaps();
101
+
102
+ if (!behaviorGate(PROFILE, isProfileAdvertised(replay))) {
103
+ return;
104
+ }
105
+
106
+ const expiredRunId = process.env.OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID;
107
+ if (!expiredRunId) {
108
+ // eslint-disable-next-line no-console
109
+ console.warn(
110
+ '[replay-retention-expiry] OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID not supplied; skipping envelope assertion (operator must produce a known-expired run id and pass it via env)',
111
+ );
112
+ return;
113
+ }
114
+
115
+ const fromSeqEnv = process.env.OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ;
116
+ const fromSeq = fromSeqEnv ? Number.parseInt(fromSeqEnv, 10) : 0;
117
+ if (!Number.isFinite(fromSeq) || fromSeq < 0) {
118
+ // eslint-disable-next-line no-console
119
+ console.warn(
120
+ `[replay-retention-expiry] OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ=${String(fromSeqEnv)} is not a non-negative integer; skipping`,
121
+ );
122
+ return;
123
+ }
124
+
125
+ // Pick a mode the host advertises. Per replay.md the envelope
126
+ // assertion applies regardless of mode — replay and branch both
127
+ // depend on the source event log past fromSeq.
128
+ const mode = replay?.modes?.[0] ?? 'replay';
129
+
130
+ const res = await driver.post(
131
+ `/v1/runs/${encodeURIComponent(expiredRunId)}:fork`,
132
+ { mode, fromSeq },
133
+ );
134
+
135
+ expect(
136
+ res.status === 410 || res.status === 422,
137
+ driver.describe(
138
+ 'replay.md §"Retention and garbage collection"',
139
+ 'fork against expired event range MUST return 410 Gone or 422 Unprocessable Entity',
140
+ ),
141
+ ).toBe(true);
142
+
143
+ const body = res.json as {
144
+ error?: string;
145
+ message?: string;
146
+ details?: {
147
+ sourceRunId?: string;
148
+ fromSeq?: number;
149
+ retentionBoundary?: string | number;
150
+ };
151
+ };
152
+
153
+ expect(typeof body.error, driver.describe(
154
+ 'replay.md §"Retention and garbage collection"',
155
+ 'expired-fork response MUST use the canonical error envelope ({error, message, details?})',
156
+ )).toBe('string');
157
+ expect((body.error ?? '').length).toBeGreaterThan(0);
158
+
159
+ expect(typeof body.message).toBe('string');
160
+ expect((body.message ?? '').length).toBeGreaterThan(0);
161
+
162
+ // details.{sourceRunId, fromSeq, retentionBoundary} are SHOULD —
163
+ // soft-check when present, MUST NOT mismatch when present.
164
+ if (body.details?.sourceRunId !== undefined) {
165
+ expect(body.details.sourceRunId, driver.describe(
166
+ 'replay.md §"Retention and garbage collection"',
167
+ 'details.sourceRunId (when present) MUST match the runId in the request path',
168
+ )).toBe(expiredRunId);
169
+ }
170
+
171
+ if (body.details?.fromSeq !== undefined) {
172
+ expect(body.details.fromSeq, driver.describe(
173
+ 'replay.md §"Retention and garbage collection"',
174
+ 'details.fromSeq (when present) MUST match the fromSeq supplied in the request body',
175
+ )).toBe(fromSeq);
176
+ }
177
+ });
178
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Restart-during-run scenario per spec/v1/scale-profiles.md
3
+ * §"Replay semantics" + spec/v1/storage-adapters.md §"Claim acquisition."
4
+ *
5
+ * Distinct from `staleClaim.test.ts` (which exercises *cross-process*
6
+ * claim transfer where two host processes share a DB):
7
+ *
8
+ * - **staleClaim:** process A starts a run → SIGKILL → process B
9
+ * (different PID, different port, same DB) takes over after TTL.
10
+ * Models multi-host scale-out.
11
+ * - **restart-during-run** (this file): process A starts a run →
12
+ * SIGKILL → process A' (same port, same DB, fresh PID) takes
13
+ * over after TTL. Models the single-host crash + supervisor-
14
+ * restart pattern that most production deployments rely on.
15
+ *
16
+ * Both reduce to the same primitive — resume-on-startup picks up an
17
+ * orphaned claim — but the contract this scenario asserts is that the
18
+ * SECOND boot at the SAME port works, which is the more common
19
+ * production failure mode (a node-level supervisor like systemd, k8s,
20
+ * pm2 restarts the host process on crash).
21
+ *
22
+ * **`@multi-process`** — spawns child host processes via
23
+ * `child_process.spawn`. Opt-in via `OPENWOP_RUN_RESTART_DURING_RUN=1`.
24
+ *
25
+ * **`@timing-sensitive`** — relies on a short claim TTL.
26
+ *
27
+ * @see lib/multiProcess.ts — spawnHost helper
28
+ * @see examples/hosts/sqlite/src/server.ts — resume-on-startup
29
+ * @see spec/v1/production-profile.md §Durability (RFC 0009 — this
30
+ * scenario satisfies the supervisor-restart predicate when the
31
+ * host advertises capabilities.production.supported: true)
32
+ */
33
+
34
+ import { describe, it, expect, afterEach } from 'vitest';
35
+ import { mkdtempSync, rmSync } from 'node:fs';
36
+ import { tmpdir } from 'node:os';
37
+ import { join } from 'node:path';
38
+ import { spawnHost, type SpawnedHost } from '../lib/multiProcess.js';
39
+
40
+ const HOST_PACKAGE_DIR =
41
+ process.env.OPENWOP_RESTART_DURING_RUN_HOST_DIR ?? 'examples/hosts/sqlite';
42
+ const RUN_THIS_SCENARIO = process.env.OPENWOP_RUN_RESTART_DURING_RUN === '1';
43
+
44
+ const APIKEY = 'openwop-restart-during-run';
45
+ const PORT = 4803;
46
+ const CLAIM_TTL_MS = 2000;
47
+ const HEARTBEAT_INTERVAL_MS = 500;
48
+
49
+ interface RunSnapshot {
50
+ status?: string;
51
+ runId?: string;
52
+ endedAt?: string | null;
53
+ }
54
+
55
+ async function fetchSnapshot(
56
+ baseUrl: string,
57
+ apiKey: string,
58
+ runId: string,
59
+ ): Promise<RunSnapshot> {
60
+ const res = await fetch(`${baseUrl}/v1/runs/${encodeURIComponent(runId)}`, {
61
+ headers: { Authorization: `Bearer ${apiKey}` },
62
+ });
63
+ if (!res.ok) throw new Error(`GET /v1/runs/${runId} failed: ${res.status}`);
64
+ return (await res.json()) as RunSnapshot;
65
+ }
66
+
67
+ let workdir: string | null = null;
68
+ const activeHosts: SpawnedHost[] = [];
69
+
70
+ afterEach(async () => {
71
+ for (const h of activeHosts.splice(0)) {
72
+ await h.shutdown().catch(() => h.kill().catch(() => undefined));
73
+ }
74
+ if (workdir) {
75
+ rmSync(workdir, { recursive: true, force: true });
76
+ workdir = null;
77
+ }
78
+ });
79
+
80
+ describe.skipIf(!RUN_THIS_SCENARIO)(
81
+ 'restart-during-run: SIGKILL + same-port restart resumes orphaned run',
82
+ () => {
83
+ it(
84
+ 'mid-run SIGKILL on the host process; restart at the same port + DB resumes to terminal',
85
+ async () => {
86
+ workdir = mkdtempSync(join(tmpdir(), 'openwop-restart-during-run-'));
87
+ const dbPath = join(workdir, 'openwop-host.sqlite');
88
+
89
+ // ── Boot host A (the one we'll kill).
90
+ const hostA = await spawnHost({
91
+ packageDir: HOST_PACKAGE_DIR,
92
+ port: PORT,
93
+ apiKey: APIKEY,
94
+ dbPath,
95
+ claimTtlMs: CLAIM_TTL_MS,
96
+ heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
97
+ });
98
+ activeHosts.push(hostA);
99
+ await hostA.ready();
100
+
101
+ // Start a long-running run (uses conformance-cancellable so we
102
+ // have time between create and kill).
103
+ const create = await fetch(`${hostA.baseUrl}/v1/runs`, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/json',
107
+ Authorization: `Bearer ${APIKEY}`,
108
+ },
109
+ body: JSON.stringify({
110
+ workflowId: 'conformance-cancellable',
111
+ inputs: { delaySeconds: 8 },
112
+ }),
113
+ });
114
+ expect(create.status).toBe(201);
115
+ const runId = (await create.json() as { runId: string }).runId;
116
+ expect(runId).toMatch(/^run-/);
117
+
118
+ // Wait a beat so host A writes `run.started` and at least one
119
+ // node.started before we kill it.
120
+ await new Promise((r) => setTimeout(r, 300));
121
+
122
+ // SIGKILL host A. Claim remains held in the DB until TTL
123
+ // expires.
124
+ await hostA.kill();
125
+ activeHosts.length = 0; // hostA is dead; don't try to shut down again
126
+
127
+ // Wait for the claim to go stale.
128
+ await new Promise((r) => setTimeout(r, CLAIM_TTL_MS + 500));
129
+
130
+ // ── Boot the replacement at the SAME port + SAME DB. This is
131
+ // the supervisor-restart pattern.
132
+ const hostA2 = await spawnHost({
133
+ packageDir: HOST_PACKAGE_DIR,
134
+ port: PORT,
135
+ apiKey: APIKEY,
136
+ dbPath,
137
+ claimTtlMs: CLAIM_TTL_MS,
138
+ heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
139
+ });
140
+ activeHosts.push(hostA2);
141
+ await hostA2.ready();
142
+
143
+ // Poll until terminal. A successful restart-recovery means
144
+ // resume-on-startup re-acquired the claim and the executor
145
+ // completed the run.
146
+ const deadline = Date.now() + 30_000;
147
+ let terminal: RunSnapshot | null = null;
148
+ while (Date.now() < deadline) {
149
+ const snap = await fetchSnapshot(hostA2.baseUrl, APIKEY, runId);
150
+ if (
151
+ snap.status === 'completed' ||
152
+ snap.status === 'failed' ||
153
+ snap.status === 'cancelled'
154
+ ) {
155
+ terminal = snap;
156
+ break;
157
+ }
158
+ await new Promise((r) => setTimeout(r, 200));
159
+ }
160
+
161
+ expect(
162
+ terminal,
163
+ 'run MUST reach terminal status on the restarted host within 30s',
164
+ ).not.toBeNull();
165
+ expect(
166
+ terminal?.status,
167
+ 'restart-recovery MUST drive the run to a non-pending terminal status',
168
+ ).toMatch(/^(completed|failed|cancelled)$/);
169
+ // For the cancellable fixture, completed is the expected
170
+ // outcome — the workflow just exits the delay node and finishes.
171
+ expect(terminal?.status).toBe('completed');
172
+ expect(typeof terminal?.endedAt).toBe('string');
173
+ },
174
+ 45_000,
175
+ );
176
+ },
177
+ );
@@ -45,6 +45,7 @@ import {
45
45
  FIXTURES_DIR,
46
46
  FIXTURES_DOC_PATH,
47
47
  GO_TYPES_PATH,
48
+ LAYOUT,
48
49
  PYTHON_TYPES_PATH,
49
50
  README_PATH,
50
51
  SCENARIOS_DIR,
@@ -786,10 +787,13 @@ describe.skipIf(
786
787
  )(
787
788
  'spec-corpus: SDK HTTP error helpers match canonical REST vocabulary',
788
789
  () => {
790
+ // describe.skipIf still evaluates the body for test registration; defaults guard against null
791
+ // dirname() when sources are missing under the published-tarball layout. it() blocks below are
792
+ // skipped at run time, so the path values are never actually read.
789
793
  const sdkSources = {
790
- typescript: TYPESCRIPT_RUN_HELPERS_PATH as string,
791
- python: PYTHON_TYPES_PATH as string,
792
- go: GO_TYPES_PATH as string,
794
+ typescript: TYPESCRIPT_RUN_HELPERS_PATH ?? '.',
795
+ python: PYTHON_TYPES_PATH ?? '.',
796
+ go: GO_TYPES_PATH ?? '.',
793
797
  };
794
798
  const sdkReadmes = {
795
799
  typescript: pathResolve(dirname(sdkSources.typescript), '..', 'README.md'),
@@ -1072,12 +1076,18 @@ describe.skipIf(V1_DIR === null)('spec-corpus: prose docs carry a Status: legend
1072
1076
  });
1073
1077
 
1074
1078
  describe.skipIf(V1_DIR === null || README_PATH === null)('spec-corpus: README document index matches spec/v1', () => {
1075
- const v1Dir = V1_DIR as string;
1076
- const readmePath = README_PATH as string;
1079
+ // describe.skipIf skips test execution but still evaluates the describe callback at registration
1080
+ // time. Guard each side-effecting read against null so the body registers cleanly under the
1081
+ // published-tarball layout where V1_DIR / README_PATH resolve to null.
1082
+ const v1Dir = V1_DIR;
1083
+ const readmePath = README_PATH ?? '';
1077
1084
 
1078
- const proseFiles = readdirSync(v1Dir)
1079
- .filter((f) => f.endsWith('.md'))
1080
- .sort();
1085
+ const proseFiles =
1086
+ v1Dir === null
1087
+ ? []
1088
+ : readdirSync(v1Dir)
1089
+ .filter((f) => f.endsWith('.md'))
1090
+ .sort();
1081
1091
 
1082
1092
  it('README Total count equals the number of spec/v1 prose docs', () => {
1083
1093
  const index = extractReadmeDocumentIndex(readFileSync(readmePath, 'utf8'));
@@ -1109,8 +1119,10 @@ describe.skipIf(V1_DIR === null || README_PATH === null)('spec-corpus: README do
1109
1119
  });
1110
1120
 
1111
1121
  describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve', () => {
1112
- const repoRoot = dirname(README_PATH as string);
1113
- const markdownFiles = listMarkdownFilesRecursive(repoRoot);
1122
+ // describe.skipIf skips test execution but still evaluates the body for registration; default
1123
+ // to '.' so dirname() never receives null in the published-tarball layout.
1124
+ const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
1125
+ const markdownFiles = README_PATH === null ? [] : listMarkdownFilesRecursive(repoRoot);
1114
1126
 
1115
1127
  it('finds Markdown files to check', () => {
1116
1128
  expect(markdownFiles.length, 'repo checkout should contain Markdown docs').toBeGreaterThan(0);
@@ -1132,6 +1144,9 @@ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve
1132
1144
  }
1133
1145
 
1134
1146
  const target = pathResolve(dirname(file), decoded);
1147
+ // Published-tarball layout: the conformance README references ../spec/v1/... and other paths
1148
+ // that resolve OUTSIDE the package boundary. Repo layout has the full tree available.
1149
+ if (LAYOUT === 'published' && !target.startsWith(repoRoot)) continue;
1135
1150
  expect(
1136
1151
  existsSync(target),
1137
1152
  `${relFile} links to missing local target: ${link}`,
@@ -1142,21 +1157,29 @@ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve
1142
1157
  });
1143
1158
 
1144
1159
  describe.skipIf(README_PATH === null)('spec-corpus: public docs avoid private implementation breadcrumbs', () => {
1145
- const repoRoot = dirname(README_PATH as string);
1146
- const publicTextFiles = [
1147
- README_PATH as string,
1148
- join(repoRoot, 'QUICKSTART.md'),
1149
- join(repoRoot, 'QUICKSTART-10MIN.md'),
1150
- join(repoRoot, 'sdk', 'typescript', 'README.md'),
1151
- join(repoRoot, 'sdk', 'python', 'README.md'),
1152
- join(repoRoot, 'sdk', 'go', 'README.md'),
1153
- ...(CONFORMANCE_README_PATH ? [CONFORMANCE_README_PATH] : []),
1154
- ...(FIXTURES_DOC_PATH ? [FIXTURES_DOC_PATH] : []),
1155
- ...listTextFilesRecursive(join(repoRoot, 'examples'), new Set(['.md', 'package.json'])),
1156
- ...readdirSync(join(repoRoot, 'SECURITY')).filter((f) => f.endsWith('.md') || f.endsWith('.yaml')).map((f) => join(repoRoot, 'SECURITY', f)),
1157
- ...(V1_DIR !== null ? readdirSync(V1_DIR).filter((f) => f.endsWith('.md')).map((f) => join(V1_DIR as string, f)) : []),
1158
- ...readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.json')).map((f) => join(SCHEMAS_DIR, f)),
1159
- ].filter((path) => existsSync(path));
1160
+ // describe.skipIf skips test execution but still evaluates the body for registration; guard each
1161
+ // path read against null/missing-dir so the body never throws under the published-tarball layout.
1162
+ const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
1163
+ const securityDir = join(repoRoot, 'SECURITY');
1164
+ const publicTextFiles =
1165
+ README_PATH === null
1166
+ ? []
1167
+ : [
1168
+ README_PATH,
1169
+ join(repoRoot, 'QUICKSTART.md'),
1170
+ join(repoRoot, 'QUICKSTART-10MIN.md'),
1171
+ join(repoRoot, 'sdk', 'typescript', 'README.md'),
1172
+ join(repoRoot, 'sdk', 'python', 'README.md'),
1173
+ join(repoRoot, 'sdk', 'go', 'README.md'),
1174
+ ...(CONFORMANCE_README_PATH ? [CONFORMANCE_README_PATH] : []),
1175
+ ...(FIXTURES_DOC_PATH ? [FIXTURES_DOC_PATH] : []),
1176
+ ...listTextFilesRecursive(join(repoRoot, 'examples'), new Set(['.md', 'package.json'])),
1177
+ ...(existsSync(securityDir)
1178
+ ? readdirSync(securityDir).filter((f) => f.endsWith('.md') || f.endsWith('.yaml')).map((f) => join(securityDir, f))
1179
+ : []),
1180
+ ...(V1_DIR !== null ? ((v1Dir: string) => readdirSync(v1Dir).filter((f) => f.endsWith('.md')).map((f) => join(v1Dir, f)))(V1_DIR) : []),
1181
+ ...readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.json')).map((f) => join(SCHEMAS_DIR, f)),
1182
+ ].filter((path) => existsSync(path));
1160
1183
 
1161
1184
  const banned = [
1162
1185
  { label: 'private workflow-runtime paths', pattern: /services\/workflow-runtime/ },
@@ -1231,7 +1254,12 @@ describe.skipIf(FIXTURES_DOC_PATH === null)('spec-corpus: fixtures.json catalog
1231
1254
  /^\|[^\n]*(PROPOSED|impl pending)[^\n]*\n/gm,
1232
1255
  '',
1233
1256
  );
1234
- const idRegex = /\bconformance-[a-z][a-z0-9-]*\b/g;
1257
+ // Match `conformance-<id>` only at a real fixture-id boundary —
1258
+ // require the preceding character to NOT be `[a-z0-9-]`, so that
1259
+ // longer strings like `openwop-conformance-canary-secret` do NOT
1260
+ // false-match `conformance-canary-secret` as a fixture id. The
1261
+ // negative lookbehind keeps the regex JS-compatible.
1262
+ const idRegex = /(?<![a-z0-9-])conformance-[a-z][a-z0-9-]*\b/g;
1235
1263
  const cited = new Set<string>();
1236
1264
  let m: RegExpExecArray | null;
1237
1265
  while ((m = idRegex.exec(docWithoutProposed)) !== null) {
@@ -25,6 +25,9 @@
25
25
  *
26
26
  * @see lib/multiProcess.ts — spawnHost helper
27
27
  * @see examples/hosts/sqlite/src/server.ts — heartbeat + resume
28
+ * @see spec/v1/production-profile.md §Durability (RFC 0009 — this
29
+ * scenario satisfies the durable-restart predicate when the
30
+ * host advertises capabilities.production.supported: true)
28
31
  */
29
32
 
30
33
  import { describe, it, expect, afterEach } from 'vitest';
@@ -1,23 +1,35 @@
1
1
  /**
2
2
  * RFC 0008 §Conformance — scenario 6/6: ABI version mismatch.
3
3
  *
4
- * Verifies that a host refuses to load a WASM pack whose declared ABI
5
- * version is not in the host's advertised `abiVersions[]`. The host's
6
- * loader MUST surface a recognizable `unsupported_abi_version` error
7
- * (or equivalent) and MUST NOT silently dispatch to the pack's
8
- * `openwop_node_invoke`.
4
+ * Two-part scenario per RFCS/0008-wasm-abi.md §H (ABI version handshake):
9
5
  *
10
- * Driving this end-to-end requires a pack with a deliberately wrong
11
- * ABI version. That pack is filed as v1.x follow-up (an
12
- * `examples/packs/abi-mismatch/`). The framework here asserts the
13
- * shape of the host's advertisement so future scenarios can rely on it.
6
+ * 1. **Discovery shape (observational, always-on)** host advertises
7
+ * `capabilities.nodePackRuntimes.wasm.abiVersions[]` as a non-empty
8
+ * array of positive integers including v1.
9
+ * 2. **Positive path (loadedPacks-gated)** when the host advertises
10
+ * `capabilities.nodePackRuntimes.wasm.loadedPacks[]` AND lists the
11
+ * well-behaved reference pack (proving the loader pipeline runs),
12
+ * the misbehaving-abi pack at `examples/packs/rust-misbehaving-abi/`
13
+ * — which declares `openwop_abi_version() == 999` — MUST NOT
14
+ * appear in `loadedPacks[]`. ABI 999 is outside any host's
15
+ * `abiVersions[]`, so a conformant host MUST reject the pack at
16
+ * load time per RFC 0008 §H.
14
17
  *
15
- * @see RFCS/0008-wasm-abi.md §H (abiVersions array)
18
+ * The `loadedPacks[]` discovery field is the wire-observable signal:
19
+ * load-time rejection happens before any node-invoke surface, so the
20
+ * conformance suite needs a discovery-level affordance to verify the
21
+ * rejection took effect. Hosts that don't advertise `loadedPacks[]`
22
+ * soft-skip the positive path.
23
+ *
24
+ * @see RFCS/0008-wasm-abi.md §H (ABI version handshake)
16
25
  */
17
26
 
18
27
  import { describe, it, expect } from 'vitest';
19
28
  import { driver } from '../lib/driver.js';
20
29
 
30
+ const MISBEHAVING_PACK_NAME = 'vendor.openwop.misbehaving-abi';
31
+ const WELL_BEHAVED_PACK_NAME = 'vendor.openwop.rust-hello';
32
+
21
33
  describe('wasm-pack-abi-version-rejection: host advertises supported ABI versions', () => {
22
34
  it('abiVersions[] contains positive integers; loader rejects unsupported versions', async () => {
23
35
  const disco = await driver.get('/.well-known/openwop');
@@ -45,3 +57,48 @@ describe('wasm-pack-abi-version-rejection: host advertises supported ABI version
45
57
  }
46
58
  });
47
59
  });
60
+
61
+ describe('wasm-pack-abi-version-rejection: positive path via misbehaving pack', () => {
62
+ it('misbehaving-abi pack (declares ABI 999) MUST NOT appear in loadedPacks[]', async () => {
63
+ const disco = await driver.get('/.well-known/openwop');
64
+ const wasm =
65
+ (disco.json as {
66
+ capabilities?: {
67
+ nodePackRuntimes?: {
68
+ wasm?: { supported?: boolean; loadedPacks?: unknown };
69
+ };
70
+ };
71
+ }).capabilities?.nodePackRuntimes?.wasm;
72
+
73
+ if (!wasm?.supported) return;
74
+
75
+ if (!Array.isArray(wasm.loadedPacks)) {
76
+ // eslint-disable-next-line no-console
77
+ console.warn(
78
+ '[wasm-pack-abi-version-rejection] host does not advertise capabilities.nodePackRuntimes.wasm.loadedPacks[]; skipping positive path. ' +
79
+ 'Hosts MAY advertise loadedPacks[] to surface ABI rejection observably (RFC 0008 §H + Track 7).',
80
+ );
81
+ return;
82
+ }
83
+
84
+ // Soft-skip when the well-behaved reference pack isn't loaded. If
85
+ // loadedPacks[] doesn't include rust-hello, we can't distinguish
86
+ // "the loader pipeline runs but rejected misbehaving-abi" from
87
+ // "the loader doesn't run at all on this host."
88
+ if (!wasm.loadedPacks.includes(WELL_BEHAVED_PACK_NAME)) {
89
+ // eslint-disable-next-line no-console
90
+ console.warn(
91
+ `[wasm-pack-abi-version-rejection] reference pack ${WELL_BEHAVED_PACK_NAME} not in loadedPacks[]; ` +
92
+ 'skipping positive path (build examples/packs/rust-hello first or run against a host that ships it).',
93
+ );
94
+ return;
95
+ }
96
+
97
+ // Core assertion: ABI 999 pack MUST NOT be in loadedPacks[]
98
+ // regardless of filesystem state. The host's loader rejected it.
99
+ expect(wasm.loadedPacks.includes(MISBEHAVING_PACK_NAME), driver.describe(
100
+ 'RFCS/0008-wasm-abi.md §H',
101
+ `host MUST reject packs whose declared ABI is not in abiVersions[]; ${MISBEHAVING_PACK_NAME} declares 999 and MUST NOT appear in loadedPacks[]`,
102
+ )).toBe(false);
103
+ });
104
+ });