@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.
- package/CHANGELOG.md +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +342 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +20 -4
- package/schemas/run-event.schema.json +2 -1
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +107 -0
- package/src/lib/env.ts +37 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +222 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +59 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- 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
|
|
791
|
-
python: PYTHON_TYPES_PATH
|
|
792
|
-
go: GO_TYPES_PATH
|
|
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
|
-
|
|
1076
|
-
|
|
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 =
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
+
});
|