@openwop/openwop-conformance 1.0.0 → 1.1.1

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 (86) 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 +342 -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 +20 -4
  19. package/schemas/run-event.schema.json +2 -1
  20. package/schemas/security-advisory.schema.json +109 -0
  21. package/src/lib/a2a-fake-peer.ts +143 -56
  22. package/src/lib/behavior-gate.ts +107 -0
  23. package/src/lib/env.ts +37 -0
  24. package/src/lib/grpc-framing.test.ts +96 -0
  25. package/src/lib/grpc-framing.ts +76 -0
  26. package/src/lib/oidc-issuer.test.ts +328 -0
  27. package/src/lib/oidc-issuer.ts +241 -0
  28. package/src/lib/otel-collector-grpc.test.ts +191 -0
  29. package/src/lib/otel-collector.test.ts +303 -0
  30. package/src/lib/otel-collector.ts +318 -14
  31. package/src/lib/otlp-protobuf.test.ts +461 -0
  32. package/src/lib/otlp-protobuf.ts +529 -0
  33. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  34. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  37. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  38. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  39. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  40. package/src/scenarios/agentMetadata.test.ts +1 -0
  41. package/src/scenarios/agentPackExport.test.ts +1 -0
  42. package/src/scenarios/agentPackInstall.test.ts +1 -0
  43. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  44. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  45. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  46. package/src/scenarios/auth-mtls.test.ts +274 -0
  47. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  48. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  49. package/src/scenarios/bulk-cancel.test.ts +111 -0
  50. package/src/scenarios/configurable-schema.test.ts +48 -0
  51. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  52. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  53. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  54. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  55. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  56. package/src/scenarios/discovery.test.ts +183 -0
  57. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  58. package/src/scenarios/idempotency.test.ts +6 -0
  59. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  60. package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
  61. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  62. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  63. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  64. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  65. package/src/scenarios/metric-emission.test.ts +113 -0
  66. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  67. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  68. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  69. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  70. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  71. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  72. package/src/scenarios/pause-resume.test.ts +119 -0
  73. package/src/scenarios/production-backpressure.test.ts +342 -0
  74. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  75. package/src/scenarios/registry-public.test.ts +222 -0
  76. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  77. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  78. package/src/scenarios/restart-during-run.test.ts +177 -0
  79. package/src/scenarios/spec-corpus-validity.test.ts +59 -26
  80. package/src/scenarios/staleClaim.test.ts +3 -0
  81. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  82. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  83. package/src/scenarios/webhook-negative.test.ts +90 -0
  84. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  85. package/src/setup.ts +25 -1
  86. package/vitest.config.ts +5 -1
@@ -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,16 @@ 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. The sentinel is
793
+ // intentionally an obviously-invalid path so a stack trace from any future code that DOES
794
+ // dereference it points the reader at this comment.
795
+ const UNUSED_IN_PUBLISHED_LAYOUT = '/__sdk_paths_unused_in_published_layout__';
789
796
  const sdkSources = {
790
- typescript: TYPESCRIPT_RUN_HELPERS_PATH as string,
791
- python: PYTHON_TYPES_PATH as string,
792
- go: GO_TYPES_PATH as string,
797
+ typescript: TYPESCRIPT_RUN_HELPERS_PATH ?? UNUSED_IN_PUBLISHED_LAYOUT,
798
+ python: PYTHON_TYPES_PATH ?? UNUSED_IN_PUBLISHED_LAYOUT,
799
+ go: GO_TYPES_PATH ?? UNUSED_IN_PUBLISHED_LAYOUT,
793
800
  };
794
801
  const sdkReadmes = {
795
802
  typescript: pathResolve(dirname(sdkSources.typescript), '..', 'README.md'),
@@ -1072,12 +1079,18 @@ describe.skipIf(V1_DIR === null)('spec-corpus: prose docs carry a Status: legend
1072
1079
  });
1073
1080
 
1074
1081
  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;
1082
+ // describe.skipIf skips test execution but still evaluates the describe callback at registration
1083
+ // time. Guard each side-effecting read against null so the body registers cleanly under the
1084
+ // published-tarball layout where V1_DIR / README_PATH resolve to null.
1085
+ const v1Dir = V1_DIR;
1086
+ const readmePath = README_PATH ?? '';
1077
1087
 
1078
- const proseFiles = readdirSync(v1Dir)
1079
- .filter((f) => f.endsWith('.md'))
1080
- .sort();
1088
+ const proseFiles =
1089
+ v1Dir === null
1090
+ ? []
1091
+ : readdirSync(v1Dir)
1092
+ .filter((f) => f.endsWith('.md'))
1093
+ .sort();
1081
1094
 
1082
1095
  it('README Total count equals the number of spec/v1 prose docs', () => {
1083
1096
  const index = extractReadmeDocumentIndex(readFileSync(readmePath, 'utf8'));
@@ -1109,8 +1122,10 @@ describe.skipIf(V1_DIR === null || README_PATH === null)('spec-corpus: README do
1109
1122
  });
1110
1123
 
1111
1124
  describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve', () => {
1112
- const repoRoot = dirname(README_PATH as string);
1113
- const markdownFiles = listMarkdownFilesRecursive(repoRoot);
1125
+ // describe.skipIf skips test execution but still evaluates the body for registration; default
1126
+ // to '.' so dirname() never receives null in the published-tarball layout.
1127
+ const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
1128
+ const markdownFiles = README_PATH === null ? [] : listMarkdownFilesRecursive(repoRoot);
1114
1129
 
1115
1130
  it('finds Markdown files to check', () => {
1116
1131
  expect(markdownFiles.length, 'repo checkout should contain Markdown docs').toBeGreaterThan(0);
@@ -1132,6 +1147,11 @@ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve
1132
1147
  }
1133
1148
 
1134
1149
  const target = pathResolve(dirname(file), decoded);
1150
+ // Published-tarball layout: the conformance README references ../spec/v1/... and other paths
1151
+ // that resolve OUTSIDE the package boundary. Repo layout has the full tree available. The
1152
+ // `target === repoRoot || target.startsWith(repoRoot + sep)` form avoids a sibling-path
1153
+ // false-negative when repoRoot=/foo/bar and target=/foo/barbaz.
1154
+ if (LAYOUT === 'published' && target !== repoRoot && !target.startsWith(repoRoot + '/')) continue;
1135
1155
  expect(
1136
1156
  existsSync(target),
1137
1157
  `${relFile} links to missing local target: ${link}`,
@@ -1142,21 +1162,29 @@ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve
1142
1162
  });
1143
1163
 
1144
1164
  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));
1165
+ // describe.skipIf skips test execution but still evaluates the body for registration; guard each
1166
+ // path read against null/missing-dir so the body never throws under the published-tarball layout.
1167
+ const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
1168
+ const securityDir = join(repoRoot, 'SECURITY');
1169
+ const publicTextFiles =
1170
+ README_PATH === null
1171
+ ? []
1172
+ : [
1173
+ README_PATH,
1174
+ join(repoRoot, 'QUICKSTART.md'),
1175
+ join(repoRoot, 'QUICKSTART-10MIN.md'),
1176
+ join(repoRoot, 'sdk', 'typescript', 'README.md'),
1177
+ join(repoRoot, 'sdk', 'python', 'README.md'),
1178
+ join(repoRoot, 'sdk', 'go', 'README.md'),
1179
+ ...(CONFORMANCE_README_PATH ? [CONFORMANCE_README_PATH] : []),
1180
+ ...(FIXTURES_DOC_PATH ? [FIXTURES_DOC_PATH] : []),
1181
+ ...listTextFilesRecursive(join(repoRoot, 'examples'), new Set(['.md', 'package.json'])),
1182
+ ...(existsSync(securityDir)
1183
+ ? readdirSync(securityDir).filter((f) => f.endsWith('.md') || f.endsWith('.yaml')).map((f) => join(securityDir, f))
1184
+ : []),
1185
+ ...(V1_DIR !== null ? ((v1Dir: string) => readdirSync(v1Dir).filter((f) => f.endsWith('.md')).map((f) => join(v1Dir, f)))(V1_DIR) : []),
1186
+ ...readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.json')).map((f) => join(SCHEMAS_DIR, f)),
1187
+ ].filter((path) => existsSync(path));
1160
1188
 
1161
1189
  const banned = [
1162
1190
  { label: 'private workflow-runtime paths', pattern: /services\/workflow-runtime/ },
@@ -1231,7 +1259,12 @@ describe.skipIf(FIXTURES_DOC_PATH === null)('spec-corpus: fixtures.json catalog
1231
1259
  /^\|[^\n]*(PROPOSED|impl pending)[^\n]*\n/gm,
1232
1260
  '',
1233
1261
  );
1234
- const idRegex = /\bconformance-[a-z][a-z0-9-]*\b/g;
1262
+ // Match `conformance-<id>` only at a real fixture-id boundary —
1263
+ // require the preceding character to NOT be `[a-z0-9-]`, so that
1264
+ // longer strings like `openwop-conformance-canary-secret` do NOT
1265
+ // false-match `conformance-canary-secret` as a fixture id. The
1266
+ // negative lookbehind keeps the regex JS-compatible.
1267
+ const idRegex = /(?<![a-z0-9-])conformance-[a-z][a-z0-9-]*\b/g;
1235
1268
  const cited = new Set<string>();
1236
1269
  let m: RegExpExecArray | null;
1237
1270
  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
+ });
@@ -1,23 +1,33 @@
1
1
  /**
2
2
  * RFC 0008 §Conformance — scenario 5/6: memory cap enforcement.
3
3
  *
4
- * Verifies that a host enforces `capabilities.nodePackRuntimes.wasm.maxMemoryBytes`
5
- * by trapping (or otherwise terminating) a module that exceeds the cap
6
- * and emitting `cap.breached` with `kind: 'wasm-memory'`.
4
+ * Two-part scenario per RFCS/0008-wasm-abi.md §K (resource limits):
7
5
  *
8
- * The reference rust-hello pack is well-behaved and does not allocate
9
- * excessively, so this scenario is OBSERVATIONAL: it asserts the cap
10
- * is *declared* (the protocol requires it) and that if the host
11
- * advertises a value, the value is plausible.
6
+ * 1. **Discovery shape (observational, always-on)** host advertises
7
+ * `capabilities.nodePackRuntimes.wasm.maxMemoryBytes` as a plausible
8
+ * integer when WASM is supported.
9
+ * 2. **Positive path (fixture-gated)** when the deliberately-
10
+ * misbehaving Rust pack at `examples/packs/rust-misbehaving-memory/`
11
+ * is built and the host advertises the
12
+ * `conformance-wasm-pack-memory-cap-breach` fixture, invoking the
13
+ * `vendor.openwop.misbehaving.memory-bomb` typeId MUST emit
14
+ * `cap.breached` with `kind: 'wasm-memory'` and drive the run to
15
+ * terminal `failed`.
12
16
  *
13
- * Driving a real OOM requires a deliberately misbehaving pack. Such a
14
- * pack is filed as v1.x follow-up; the framework lives here.
17
+ * Until the misbehaving pack ships in a host's fixture set, the positive
18
+ * path soft-skips so the scenario stays green on hosts that haven't
19
+ * wired up the cap-emit behavior yet.
15
20
  *
16
21
  * @see RFCS/0008-wasm-abi.md §K (resource limits)
22
+ * @see schemas/run-event-payloads.schema.json §"capBreached" (kind enum includes wasm-memory)
17
23
  */
18
24
 
19
25
  import { describe, it, expect } from 'vitest';
20
26
  import { driver } from '../lib/driver.js';
27
+ import { pollUntilTerminal } from '../lib/polling.js';
28
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
29
+
30
+ const CAP_BREACH_FIXTURE = 'conformance-wasm-pack-memory-cap-breach';
21
31
 
22
32
  describe('wasm-pack-memory-cap: host advertises maxMemoryBytes', () => {
23
33
  it('capabilities.nodePackRuntimes.wasm.maxMemoryBytes is a plausible number', async () => {
@@ -41,3 +51,48 @@ describe('wasm-pack-memory-cap: host advertises maxMemoryBytes', () => {
41
51
  }
42
52
  });
43
53
  });
54
+
55
+ describe('wasm-pack-memory-cap: positive path via misbehaving pack', () => {
56
+ it('misbehaving pack triggers cap.breached with kind=wasm-memory and terminal failed', async () => {
57
+ if (!isFixtureAdvertised(CAP_BREACH_FIXTURE)) {
58
+ // eslint-disable-next-line no-console
59
+ console.warn(
60
+ `[wasm-pack-memory-cap] fixture ${CAP_BREACH_FIXTURE} not advertised; skipping positive path. ` +
61
+ 'Build the misbehaving pack at examples/packs/rust-misbehaving-memory/ and ensure the host serves the fixture.',
62
+ );
63
+ return;
64
+ }
65
+ const disco = await driver.get('/.well-known/openwop');
66
+ const wasm =
67
+ (disco.json as {
68
+ capabilities?: { nodePackRuntimes?: { wasm?: { supported?: boolean } } };
69
+ }).capabilities?.nodePackRuntimes?.wasm;
70
+ if (!wasm?.supported) return;
71
+
72
+ const create = await driver.post('/v1/runs', {
73
+ workflowId: CAP_BREACH_FIXTURE,
74
+ inputs: {},
75
+ });
76
+ expect(create.status).toBe(201);
77
+ const runId = (create.json as { runId: string }).runId;
78
+
79
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 15_000 });
80
+ expect(terminal.status, driver.describe(
81
+ 'RFCS/0008-wasm-abi.md §K',
82
+ 'WASM memory-cap breach MUST drive terminal `failed` (RFC 0008 §K: "kill the instance, emit cap.breached")',
83
+ )).toBe('failed');
84
+
85
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
86
+ const list = (events.json as { events?: Array<{ type: string; data?: unknown }> }).events ?? [];
87
+ const breachEvent = list.find((e) => e.type === 'cap.breached');
88
+ expect(breachEvent, driver.describe(
89
+ 'RFCS/0008-wasm-abi.md §K',
90
+ 'host MUST emit cap.breached when a WASM module exceeds its memory ceiling',
91
+ )).toBeDefined();
92
+ const breachKind = (breachEvent?.data as { kind?: string } | undefined)?.kind;
93
+ expect(breachKind, driver.describe(
94
+ 'RFCS/0008-wasm-abi.md §K',
95
+ 'cap.breached payload MUST carry kind: "wasm-memory" for memory-ceiling breaches',
96
+ )).toBe('wasm-memory');
97
+ });
98
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Webhook negative-path contract (webhooks.md + review hardening).
3
+ *
4
+ * Exercises three failure surfaces that the positive `webhook-signed-
5
+ * delivery.test.ts` doesn't cover:
6
+ * 1. SSRF guard — `POST /v1/webhooks` with a private-IP destination
7
+ * returns 400 `webhook_url_rejected` on hosts that enforce it.
8
+ * 2. URL validation — malformed `url` returns 400 `validation_error`.
9
+ * 3. Unregister of unknown subscription — `DELETE /v1/webhooks/{id}`
10
+ * returns 404 `subscription_not_found`.
11
+ *
12
+ * Capability-gated: skips when the host does not advertise
13
+ * `capabilities.webhooks.supported = true`.
14
+ *
15
+ * SSRF gating: hosts that don't implement the guard (or bypass it via
16
+ * `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true`) will accept the loopback URL
17
+ * with 201 — that's acceptable spec behavior, so the SSRF subtest
18
+ * soft-skips with a warning rather than failing.
19
+ *
20
+ * @see spec/v1/webhooks.md
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { driver } from '../lib/driver.js';
25
+
26
+ async function isWebhookSupported(): Promise<boolean> {
27
+ const disco = await driver.get('/.well-known/openwop');
28
+ const caps = (disco.json as { capabilities?: { webhooks?: { supported?: boolean } } })
29
+ .capabilities;
30
+ return caps?.webhooks?.supported === true;
31
+ }
32
+
33
+ describe('webhook-negative: SSRF guard rejects private destinations', () => {
34
+ it('host with SSRF guard returns 400 webhook_url_rejected for loopback', async () => {
35
+ if (!(await isWebhookSupported())) {
36
+ // eslint-disable-next-line no-console
37
+ console.warn('[webhook-negative] host does not advertise webhook support; skipping');
38
+ return;
39
+ }
40
+ const reg = await driver.post('/v1/webhooks', { url: 'http://127.0.0.1:65535/' });
41
+ if (reg.status === 201) {
42
+ // Host accepted — SSRF guard not implemented or bypassed.
43
+ // Soft-skip; this is acceptable per spec.
44
+ // eslint-disable-next-line no-console
45
+ console.warn(
46
+ '[webhook-negative] host accepts loopback destinations; SSRF guard not enforced',
47
+ );
48
+ // Cleanup the subscription so we don't leak state.
49
+ const body = reg.json as { subscriptionId?: string };
50
+ if (body.subscriptionId) {
51
+ await driver.delete(`/v1/webhooks/${encodeURIComponent(body.subscriptionId)}`);
52
+ }
53
+ return;
54
+ }
55
+ expect(reg.status, driver.describe(
56
+ 'webhooks.md + review §"Webhook SSRF guard"',
57
+ 'host with SSRF guard MUST return 400 for loopback / RFC1918 / link-local destinations',
58
+ )).toBe(400);
59
+ const body = reg.json as { error?: string };
60
+ expect(body.error).toBe('webhook_url_rejected');
61
+ });
62
+ });
63
+
64
+ describe('webhook-negative: validation errors', () => {
65
+ it('malformed url returns 400 validation_error', async () => {
66
+ if (!(await isWebhookSupported())) return;
67
+ const reg = await driver.post('/v1/webhooks', { url: 'not a url' });
68
+ expect([400, 422]).toContain(reg.status);
69
+ const body = reg.json as { error?: string };
70
+ expect(['validation_error', 'webhook_url_rejected']).toContain(body.error);
71
+ });
72
+
73
+ it('missing url returns 400 validation_error', async () => {
74
+ if (!(await isWebhookSupported())) return;
75
+ const reg = await driver.post('/v1/webhooks', { eventTypes: ['run.completed'] });
76
+ expect(reg.status).toBe(400);
77
+ const body = reg.json as { error?: string };
78
+ expect(body.error).toBe('validation_error');
79
+ });
80
+ });
81
+
82
+ describe('webhook-negative: unregister of unknown subscription', () => {
83
+ it('DELETE /v1/webhooks/{unknown} returns 404 subscription_not_found', async () => {
84
+ if (!(await isWebhookSupported())) return;
85
+ const del = await driver.delete('/v1/webhooks/wh-does-not-exist');
86
+ expect(del.status).toBe(404);
87
+ const body = del.json as { error?: string };
88
+ expect(body.error).toBe('subscription_not_found');
89
+ });
90
+ });