@openwop/openwop-conformance 2.39.2 → 2.39.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.39.4] — 2026-09-25 — RFC 0199 can be witnessed behind a public front, and the pinned-port claim is race-free
4
+
5
+ - **`0199.credential-interrupt` can be witnessed on a host with a public front: `OPENWOP_HOST_PUBLIC_URL`.** RFC 0199 §C.2 requires `connectUrl` to be an https URL "on the host's own origin". The leg compared it to `--base-url`, but a certification cut drives the host over loopback, which can never be the https origin a user agent is sent to. So the v2 reference host, which implements all of §C, could never advertise `oauth.credentialInterrupt` in a cut, and RFC 0199's four credential rows never ran. New `src/lib/host-public-origin.ts` reads an operator-declared front for the host, validated like every other suite front (https, publicly resolvable). It counts as the host's own origin only once the discovery document fetched through it equals the one over `--base-url`; otherwise the leg records `blocked`, so a front pointing at another host can never let a `connectUrl` on that host's origin pass. Unset, behaviour is unchanged (`--base-url`'s origin, and no fetch). Self-test: 5 cases, including a front that serves a different host.
6
+ - **The synthetic OIDC issuer binds a local port the operator names, not the port parsed from the URL the host trusts.** `v2-lane-exp-only-bound`, `v2-oidc-id-token-audience` and `auth-oidc-user-bearer` bound `127.0.0.1` at the port parsed from `OPENWOP_TEST_OIDC_ISSUER_URL`. For a remote host that URL is a public https front with no port, so the suite bound :80, and a non-root operator got `EACCES`. This was measured by openwop-app-ce on 2026-09-25 against a remote openwop-app instance. No remote host could execute the RFC 0200 / RFC 0210 issuer rows, so none could certify. New `issuerListenPort()` (`lib/oidc-issuer.ts`) reads `OPENWOP_TEST_OIDC_ISSUER_PORT` when set: the local port the front forwards to, the same split the webhook receiver has always had (`_URL` + `_PORT`). Unset, the old rule is unchanged. A malformed value is refused rather than silently falling back. The variable is added to `PINNED_PORT_ENVS`, so a multi-worker `--certify` that pins it is refused. Host-free legs in `oidc-issuer.test.ts` and `pinned-ports.test.ts` go red with the fix removed.
7
+ - **The shared pinned-port listener's claim is taken before the first await.** In 2.39.3 a scoped receiver read the shared listener's promise and awaited it; if the last holder closed in that window it dropped the count to zero and shut the server, and the starting receiver then counted itself onto a closed listener — its deliveries could never arrive. The claim (a per-port entry's `refs`) is now incremented synchronously, and released if the bind fails. New self-test `a sibling that is STARTING while the last holder closes still gets a live listener` reproduces the race (ECONNREFUSED before the fix; sabotage-proven by moving the claim back after the await). Found in review of #1559; it was NOT the cause of the 2.39.3 public-cut failures — a pinned-port loopback A/B on 2.39.3 matched the ephemeral rehearsal exactly (376 / 7 / 4, no new non-pass), which points those at tunnel/load starvation.
8
+ - **`--certify` refuses pinned fixture ports unless `--max-workers 1`.** Every fixture reached through an operator's public front listens on a pinned `*_PORT`, and the registry that routes a nonce-pathed request to its exercise lives in one vitest worker. With more than one worker, only one can own each port, and an exercise in another worker loses the host's traffic. `cut-bundle.sh` always passed `--max-workers 1`, but nothing enforced it: an openwop-app production cut run with 2 workers recorded `0173.webhook-durable-delivery` and `0187.bound-id-kinds.webhook-emitted` as timeouts for deliveries the host had made, and the same host passed both single-worker (measured by openwop-app-ce, 2026-09-25). The CLI now exits 2 before running (`src/lib/pinned-ports.ts`, with a self-test), naming the pinned variables it found, including the OAuth doubles' runtime-derived ports.
9
+ - **`v2-webhook-durable-delivery` refuses only as many attempts as the host's advertised policy allows.** The receiver refused 2 attempts regardless, but `webhooks.retryPolicy.maxAttempts` is schema-valid from 1, so a host honestly advertising `maxAttempts: 2` never reached its 204 and failed a leg it conformed to. It now refuses `maxAttempts − 1`, capped at 2, with a minimum of 1. A host with no policy is measured as before, and `maxAttempts: 1` still refuses the first attempt, so a host that never retries still fails (webhooks.md §Durability: best-effort delivery is not a conforming mode).
10
+ - **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.39.4` because `2.39.3` is tagged and published. Not tagged, not published.
11
+
12
+ ## [2.39.3] — 2026-09-25 — a pinned receiver port is shared, and tenant B really is tenant B
13
+
14
+ - **A pinned receiver port is shared, never re-bound — the public-cut hang behind three RFC 0214 legs.** On a public cut `OPENWOP_WEBHOOK_RECEIVER_PORT` is pinned (the port the front forwards to), and `lib/scoped-receiver.ts` had every receiver call `server.listen` on it. A second receiver alive at the same time — the §B redirect and §A after-Delete legs start two, and the §A/§C delivery receiver stays open until `afterAll` — hit `EADDRINUSE` with no `error` listener attached, so its `listen` promise never settled and the leg hung to vitest's timeout: `v2-a2a-push-delivery` §A-delete, §B-redirect and §D-fork on both the 2.39.0 and 2.39.2 public cuts of the v2 reference host. Loopback binds ephemeral ports, which is why no rehearsal showed it. All receivers on a pinned port now share one ref-counted listener (routing was already per nonce); a bind failure now rejects at once with a message naming the port; `close()` no longer waits on keep-alive sockets. Three lib self-tests pin it (two concurrent receivers; close one, the sibling keeps serving; a squatted port rejects in < 5 s) — reverting to per-receiver `listen` fails the first two in milliseconds.
15
+ - **A scenario's second credential is the one that gets sent: `workspace-cross-tenant-isolation-blackbox` never authenticated as tenant B.** It passed `OPENWOP_TEST_TENANT_B_API_KEY` as an `Authorization` header without `authenticated: false`. `lib/driver.ts` then overwrote that header with the default `OPENWOP_API_KEY`, so every "tenant B" request was the owner reading its own file. That returns `200`, which the leg records as a cross-tenant leak. openwop-app measured it as a false `executed-fail` on its in-process lane (openwop-app#4107), while a direct HTTP repro with the same two keys answered `404`. The fix is in the driver: a caller-supplied `Authorization` header (any case) is now sent as given and is the only credential on the request. The scenario also passes `authenticated: false` explicitly. It records `blocked` before any assertion when the key is absent, equals `OPENWOP_API_KEY`, or is refused `401`. A sweep of every tenant-B reader found the rest correct: `auth-challenge-no-oracle`, `v2-sse-last-event-id-cursor`, `v2-mcp-mount-map`, `v2-a2a-agent-cards`, `v2-a2a-operation-map` and `v2-oauth-client-pkce-state-iss` (via `bearerInit`) pass `authenticated: false`, and `v2-artifact-a2a-shape` and `v2-mcp-tasks` use raw `fetch`. One scenario outside the tenant-B set had the same defect: `interrupt-auth-required-resume` sent its `OPENWOP_TEST_LOW_SCOPE_KEY` the same way. Its "low-scope" resolve was really a full-scope one, a false miss on the required `403`. That is fixed by the same driver change and made explicit in the scenario. New suite self-test `src/lib/driver-caller-credential.test.ts` stubs `fetch` and pins the rule. It is sabotage-proved: on the old driver its caller-header leg fails.
16
+ - **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.39.3` because `2.39.2` is tagged and published, and RFC 0215's six gap rows change `spec/v1/gaps.json`, which `@openwop/spec-artifacts` ships. No scenario change.
17
+
3
18
  ## [2.39.2] — 2026-09-25 — RFC 0208's unreadable-task leg records its own row
4
19
 
5
20
  - **`0208.a2a-unreadable-not-found` records its own row.** In `v2-a2a-operation-map`, one `it()` cited both RFC 0208's `a2a-unreadable-not-found` and RFC 0211's `a2a-unreadable-not-found-details`. The per-`it` ledger keeps only the last id, so the 0208 row never reached a bundle: it is absent from the certified 2.38.0 v2-reference cut, and it is the one row keeping RFC 0208 from `Accepted` (the other 18 pass). This is the same class as openwop#1552. RFC 0211's detail comparison now has its own `it()` with its own setup, so each id records once. `scripts/check-req-only.mjs` already failed an `it()` citing two LITERAL ids, but it read helper-built ids (`R('x')`, `R11('x')`) as no id at all, which is how this one passed. It now counts each (helper, slug) as an id, fails against the 2.39.1 scenario, and finds no other case in 633 scenario files.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  # --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
12
12
  # default resolver refuses. npm 10.9 fails outright with
13
13
  # "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
14
- npm install --legacy-peer-deps @openwop/openwop-conformance@2.39.2 @openwop/spec-artifacts@2.39.2
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.39.4 @openwop/spec-artifacts@2.39.4
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
package/dist/cli.js CHANGED
@@ -31,6 +31,7 @@
31
31
  * 1 one or more scenarios failed
32
32
  * 2 suite couldn't start (missing required args, etc)
33
33
  */
34
+ import { pinnedPortWorkerConflict } from './lib/pinned-ports.js';
34
35
  import { spawnSync } from 'node:child_process';
35
36
  import { fileURLToPath } from 'node:url';
36
37
  import { dirname, resolve as resolvePath, join } from 'node:path';
@@ -855,6 +856,13 @@ async function runCertify(args, baseUrl, apiKey) {
855
856
  }
856
857
  async function main() {
857
858
  const args = parseArgs(process.argv.slice(2));
859
+ // Refused before anything runs: a pinned-port certification that is not
860
+ // single-worker loses the host's traffic to a worker nobody reads (lib/pinned-ports.ts).
861
+ const pinnedConflict = pinnedPortWorkerConflict(process.env, args.maxWorkers, args.certify !== undefined);
862
+ if (pinnedConflict !== null) {
863
+ process.stderr.write(`openwop-conformance: ${pinnedConflict}\n`);
864
+ process.exit(2);
865
+ }
858
866
  if (args.help) {
859
867
  process.stdout.write(HELP_TEXT);
860
868
  process.exit(0);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A certification run with pinned fixture ports MUST be single-worker.
3
+ *
4
+ * Every suite fixture reached through an operator's public front listens on a
5
+ * pinned port (`*_PORT`), and the fixture registry that routes a nonce-pathed
6
+ * request to the exercise that minted it (`front-mux.ts`, `scoped-receiver.ts`)
7
+ * lives in ONE vitest worker's memory. With two workers only one can own each
8
+ * pinned port; an exercise in the other worker is unreachable, or its traffic is
9
+ * answered by a registry nobody reads. `cut-bundle.sh` has always passed
10
+ * `--max-workers 1`, and `effect-receiver.ts` assumes it — but nothing enforced
11
+ * it. Measured 2026-09-25: an openwop-app production cut run with
12
+ * `--max-workers 2` recorded `0173.webhook-durable-delivery` and
13
+ * `0187.bound-id-kinds.webhook-emitted` as test timeouts while the host had in
14
+ * fact delivered; the same host passed both single-worker. A certification that
15
+ * silently loses the host's traffic convicts the host, so the CLI refuses it.
16
+ */
17
+ /** Every pinned-port variable a suite fixture honours. The OAuth doubles derive
18
+ * theirs from `<FRONT>_URL` at runtime, so they are listed rather than grepped. */
19
+ export const PINNED_PORT_ENVS = [
20
+ 'OPENWOP_WEBHOOK_RECEIVER_PORT',
21
+ 'OPENWOP_A2A_FAKE_PEER_PORT',
22
+ 'OPENWOP_MCP_FAKE_SERVER_PORT',
23
+ 'OPENWOP_OAUTH_AS_PORT',
24
+ 'OPENWOP_OAUTH_AS2_PORT',
25
+ 'OPENWOP_OAUTH_RESOURCE_PORT',
26
+ 'OPENWOP_OTEL_COLLECTOR_PORT',
27
+ 'OPENWOP_OTEL_COLLECTOR_GRPC_PORT',
28
+ // 2.39.4: the synthetic OIDC issuer's local bind port (`issuerListenPort`,
29
+ // oidc-issuer.ts). Two scenarios stand an issuer up on it.
30
+ 'OPENWOP_TEST_OIDC_ISSUER_PORT',
31
+ ];
32
+ /**
33
+ * The refusal message when a `--certify` run pins fixture ports without being
34
+ * single-worker, or `null` when the run is acceptable. `maxWorkers` undefined is
35
+ * vitest's default — one worker per CPU — so it is refused too.
36
+ */
37
+ export function pinnedPortWorkerConflict(env, maxWorkers, certifying) {
38
+ if (!certifying)
39
+ return null;
40
+ const pinned = PINNED_PORT_ENVS.filter((k) => (env[k] ?? '').trim() !== '');
41
+ if (pinned.length === 0 || maxWorkers === 1)
42
+ return null;
43
+ return `--certify with pinned fixture ports (${pinned.join(', ')}) requires --max-workers 1 (got ${maxWorkers === undefined ? "vitest's default, one per CPU" : maxWorkers}): a pinned port is owned by one worker, so an exercise in another worker cannot receive the host's traffic and its rows would convict the host of a delivery it made`;
44
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.39.2",
4
- "stampSha256": "d3e9417fe0ae348a07cf66c1167cefe100d1dcd43e053c238b82195c77699e9f"
3
+ "version": "2.39.4",
4
+ "stampSha256": "7f6c8bfaa20466e744bdab3e23ab0b27653b6e74e115ca4dccdfd8a4ba4fa70a"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.39.2",
3
+ "version": "2.39.4",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.39.2"
59
+ "@openwop/spec-artifacts": "2.39.4"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -5706,7 +5706,7 @@
5706
5706
  {
5707
5707
  "id": "openwop.it.auth-oidc-user-bearer.host-claiming-oidc-profile-advertises-required-fields",
5708
5708
  "file": "auth-oidc-user-bearer.test.ts",
5709
- "line": 92,
5709
+ "line": 93,
5710
5710
  "title": "host claiming OIDC profile advertises required fields",
5711
5711
  "explicitId": "openwop.it.auth-oidc-user-bearer.host-claiming-oidc-profile-advertises-required-fields",
5712
5712
  "citations": [
@@ -28290,7 +28290,7 @@
28290
28290
  {
28291
28291
  "id": "openwop.it.v2-credential-interrupt.a-node-with-no-credential-suspends-on-a-credential-interrupt-with-a-closed-crede",
28292
28292
  "file": "v2-credential-interrupt.test.ts",
28293
- "line": 108,
28293
+ "line": 109,
28294
28294
  "title": "a node with no credential suspends on a credential interrupt with a closed CredentialData, status waiting-input",
28295
28295
  "explicitId": null,
28296
28296
  "citations": [
@@ -28327,7 +28327,7 @@
28327
28327
  {
28328
28328
  "id": "openwop.it.v2-credential-interrupt.a-caller-resolve-of-authorized-is-refused-while-no-credential-resolves-and-a-res",
28329
28329
  "file": "v2-credential-interrupt.test.ts",
28330
- "line": 128,
28330
+ "line": 135,
28331
28331
  "title": "a caller resolve of authorized is refused while no credential resolves, and a resume value cannot carry a credential",
28332
28332
  "explicitId": null,
28333
28333
  "citations": [
@@ -28358,7 +28358,7 @@
28358
28358
  {
28359
28359
  "id": "openwop.it.v2-credential-interrupt.connecturl-is-not-pre-authenticated-and-begins-the-grant-only-for-the-initiating",
28360
28360
  "file": "v2-credential-interrupt.test.ts",
28361
- "line": 145,
28361
+ "line": 152,
28362
28362
  "title": "connectUrl is not pre-authenticated and begins the grant only for the initiating Subject",
28363
28363
  "explicitId": null,
28364
28364
  "citations": [
@@ -28391,7 +28391,7 @@
28391
28391
  {
28392
28392
  "id": "openwop.it.v2-credential-interrupt.the-grant-completes-through-connecturl-the-host-resolves-the-interrupt-itself-an",
28393
28393
  "file": "v2-credential-interrupt.test.ts",
28394
- "line": 168,
28394
+ "line": 175,
28395
28395
  "title": "the grant completes through connectUrl, the host resolves the interrupt itself and the run continues",
28396
28396
  "explicitId": null,
28397
28397
  "citations": [
@@ -28423,7 +28423,7 @@
28423
28423
  {
28424
28424
  "id": "openwop.it.v2-credential-interrupt.declined-fails-the-node-with-connector-auth-declined",
28425
28425
  "file": "v2-credential-interrupt.test.ts",
28426
- "line": 188,
28426
+ "line": 195,
28427
28427
  "title": "declined fails the node with connector_auth_declined",
28428
28428
  "explicitId": null,
28429
28429
  "citations": [
@@ -28450,7 +28450,7 @@
28450
28450
  {
28451
28451
  "id": "openwop.it.v2-credential-interrupt.a-terminal-refresh-failure-emits-connector-auth-expired-then-suspends-with-reaso",
28452
28452
  "file": "v2-credential-interrupt.test.ts",
28453
- "line": 203,
28453
+ "line": 210,
28454
28454
  "title": "a terminal refresh failure emits connector.auth-expired, then suspends with reason expired",
28455
28455
  "explicitId": null,
28456
28456
  "citations": [
@@ -28487,7 +28487,7 @@
28487
28487
  {
28488
28488
  "id": "openwop.it.v2-credential-interrupt.without-oauth-credentialinterrupt-a-terminal-refresh-failure-fails-the-node-conn",
28489
28489
  "file": "v2-credential-interrupt.test.ts",
28490
- "line": 231,
28490
+ "line": 238,
28491
28491
  "title": "without oauth.credentialInterrupt, a terminal refresh failure fails the node connector_auth_expired and raises no interrupt",
28492
28492
  "explicitId": null,
28493
28493
  "citations": [
@@ -29585,7 +29585,7 @@
29585
29585
  {
29586
29586
  "id": "openwop.it.v2-lane-exp-only-bound.a-credential-inside-the-advertised-window-is-accepted-the-control-the-two-refusa",
29587
29587
  "file": "v2-lane-exp-only-bound.test.ts",
29588
- "line": 150,
29588
+ "line": 149,
29589
29589
  "title": "a credential inside the advertised window is accepted — the control the two refusals rest on",
29590
29590
  "explicitId": "openwop.requirement.0210.exp-only-control-accepted",
29591
29591
  "citations": [
@@ -29599,7 +29599,7 @@
29599
29599
  {
29600
29600
  "id": "openwop.it.v2-lane-exp-only-bound.a-credential-whose-total-lifetime-exceeds-the-window-is-refused-401-and-so-is-on",
29601
29601
  "file": "v2-lane-exp-only-bound.test.ts",
29602
- "line": 165,
29602
+ "line": 164,
29603
29603
  "title": "a credential whose TOTAL lifetime exceeds the window is refused 401, and so is one carrying no iat",
29604
29604
  "explicitId": "openwop.requirement.0210.exp-only-lifetime-bound",
29605
29605
  "citations": [
@@ -29618,7 +29618,7 @@
29618
29618
  {
29619
29619
  "id": "openwop.it.v2-lane-exp-only-bound.the-over-lifetime-refusal-names-credential-lifetime-exceeded-not-a-generic-unaut",
29620
29620
  "file": "v2-lane-exp-only-bound.test.ts",
29621
- "line": 200,
29621
+ "line": 199,
29622
29622
  "title": "the over-lifetime refusal names credential_lifetime_exceeded, not a generic unauthenticated",
29623
29623
  "explicitId": "openwop.requirement.0210.credential-lifetime-code",
29624
29624
  "citations": [
@@ -29632,7 +29632,7 @@
29632
29632
  {
29633
29633
  "id": "openwop.it.v2-lane-exp-only-bound.a-credential-whose-remaining-lifetime-exceeds-the-window-is-refused-though-its-t",
29634
29634
  "file": "v2-lane-exp-only-bound.test.ts",
29635
- "line": 218,
29635
+ "line": 217,
29636
29636
  "title": "a credential whose REMAINING lifetime exceeds the window is refused, though its total lifetime does not",
29637
29637
  "explicitId": "openwop.requirement.0210.exp-only-remaining-bound",
29638
29638
  "citations": [
@@ -31179,7 +31179,7 @@
31179
31179
  {
31180
31180
  "id": "openwop.it.v2-oidc-id-token-audience.a-same-audience-id-token-is-admissible-and-one-minted-for-another-relying-party",
31181
31181
  "file": "v2-oidc-id-token-audience.test.ts",
31182
- "line": 75,
31182
+ "line": 74,
31183
31183
  "title": "a same-audience ID token is admissible, and one minted for another relying party is audience_mismatch",
31184
31184
  "explicitId": "openwop.requirement.0200.id-token-aud",
31185
31185
  "citations": [
@@ -33188,7 +33188,7 @@
33188
33188
  {
33189
33189
  "id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
33190
33190
  "file": "v2-webhook-durable-delivery.test.ts",
33191
- "line": 238,
33191
+ "line": 256,
33192
33192
  "title": "a failed attempt is retried and the event is delivered at least once",
33193
33193
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery",
33194
33194
  "citations": [
@@ -33220,7 +33220,7 @@
33220
33220
  {
33221
33221
  "id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
33222
33222
  "file": "v2-webhook-durable-delivery.test.ts",
33223
- "line": 339,
33223
+ "line": 358,
33224
33224
  "title": "an exhausted delivery is dead-lettered, never dropped",
33225
33225
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
33226
33226
  "citations": [
@@ -33254,7 +33254,7 @@
33254
33254
  {
33255
33255
  "id": "openwop.it.v2-webhook-durable-delivery.the-dead-letter-read-is-served-and-its-records-carry-no-payload",
33256
33256
  "file": "v2-webhook-durable-delivery.test.ts",
33257
- "line": 433,
33257
+ "line": 452,
33258
33258
  "title": "the dead-letter read is served and its records carry no payload",
33259
33259
  "explicitId": "openwop.requirement.0188.dead-letter-read",
33260
33260
  "citations": [
@@ -33267,7 +33267,7 @@
33267
33267
  {
33268
33268
  "id": "openwop.it.v2-webhook-durable-delivery.a-dead-letter-record-carries-no-delivered-payload",
33269
33269
  "file": "v2-webhook-durable-delivery.test.ts",
33270
- "line": 454,
33270
+ "line": 473,
33271
33271
  "title": "a dead-letter record carries no delivered payload",
33272
33272
  "explicitId": "openwop.requirement.0188.dead-letter-content-free",
33273
33273
  "citations": [
@@ -35854,7 +35854,7 @@
35854
35854
  {
35855
35855
  "id": "openwop.it.workspace-cross-tenant-isolation-blackbox.a-file-written-by-owner-a-is-unreadable-un-enumerable-by-a-second-tenant-credent",
35856
35856
  "file": "workspace-cross-tenant-isolation-blackbox.test.ts",
35857
- "line": 46,
35857
+ "line": 52,
35858
35858
  "title": "a file written by owner A is unreadable + un-enumerable by a second-tenant credential",
35859
35859
  "explicitId": "openwop.it.workspace-cross-tenant-isolation-blackbox.a-file-written-by-owner-a-is-unreadable-un-enumerable-by-a-second-tenant-credent",
35860
35860
  "citations": [
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.39.2",
5
- "corpusTag": "v2.39.2",
4
+ "version": "2.39.4",
5
+ "corpusTag": "v2.39.4",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "6366b5514be71098e9ea41fc81ff52bc39db1a547b353c0b344cc2a2bf5370aa",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "bac1af1953801e2e3e80cf3aa006b168611cb5b20ccb70c52674815437204e85",
13
- "api/v2/asyncapi.yaml": "77f9311485f7220c28fb81937670ba415b9a3edc0adf73ad3c77e1e288247548",
14
- "api/v2/openapi.yaml": "5bf2dd1dfd7762e0c108ffe51367933108d701d8dd8549aa6e21f1cbece7291b",
12
+ "api/seams-v2.yaml": "4d94700c79e047b6cc215e6fef77fb80d0ffaabf94c23d41b30d17061f4405f6",
13
+ "api/v2/asyncapi.yaml": "7189ece9f1c5170d53b81b83ddb173173df3c7ddaec650b3edc696896c1aa01a",
14
+ "api/v2/openapi.yaml": "873705c51c0fe43bc21e88c53b21d9e7689617c7a6e77135a967f642564e7266",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -204,13 +204,13 @@
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "2401fcb1c18cdd688c018b3d716ae6bca85c5e356220ee2b793bd9d81872412d",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "4b144e92eae7afe2e541888d89c5461853f0cf1b929e9dca6a253fbe78d3e591",
207
+ "spec/v1/core-standard-manifest.json": "b221f615c14c340e2a17143a75f88256b7ac1f31cf7a3aeb237a277724ff445c",
208
208
  "spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
211
211
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
212
212
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
213
- "spec/v1/gaps.json": "c20fe027514c8778b34c8fadde8bc7d1db5c4034638d76d297cc89a5bcae7ba2",
213
+ "spec/v1/gaps.json": "76b986a5d12be1c5dc9368bc79c6d8aa99a5e916b3cca3fc3eb6a84577f1f32a",
214
214
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
215
215
  "spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
216
216
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -292,9 +292,9 @@
292
292
  "spec/v2/path-manifest.json": "ae9b56d03a701061fd48ad0b73cb0373493f6a4547da92763b422295b5e19535",
293
293
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
294
294
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
295
- "spec/v2/release.json": "00fba2903eb7f34d8c98a7cd11fee02590f5390f3109e5bfdba85f9ceeeddf83",
295
+ "spec/v2/release.json": "8fc2cf9fee817def51a3fc494b3bb602fc80ab270cdc5c94f682851468a65503",
296
296
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
297
- "spec/v2/surface-baseline.json": "314e72a7687918b11a89875d0b735ea4ec1589bd6ccb8abcf574d5e2fbba5333"
297
+ "spec/v2/surface-baseline.json": "68f4a5b6b121d58be3d8baf0d3a58fa7cb4b1c0067e989f036f8f0dd129b46b7"
298
298
  },
299
- "corpusCommit": "57cc8d88f96abb980c605cc386167861b227ff97"
299
+ "corpusCommit": "dd6d1218b745d600af9095a0a7458ba6cdeab3d1"
300
300
  }
package/src/cli.ts CHANGED
@@ -32,6 +32,7 @@
32
32
  * 2 suite couldn't start (missing required args, etc)
33
33
  */
34
34
 
35
+ import { pinnedPortWorkerConflict } from './lib/pinned-ports.js';
35
36
  import { spawnSync } from 'node:child_process';
36
37
  import { fileURLToPath } from 'node:url';
37
38
  import { dirname, resolve as resolvePath, join } from 'node:path';
@@ -907,6 +908,13 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
907
908
 
908
909
  async function main(): Promise<never> {
909
910
  const args = parseArgs(process.argv.slice(2));
911
+ // Refused before anything runs: a pinned-port certification that is not
912
+ // single-worker loses the host's traffic to a worker nobody reads (lib/pinned-ports.ts).
913
+ const pinnedConflict = pinnedPortWorkerConflict(process.env, args.maxWorkers, args.certify !== undefined);
914
+ if (pinnedConflict !== null) {
915
+ process.stderr.write(`openwop-conformance: ${pinnedConflict}\n`);
916
+ process.exit(2);
917
+ }
910
918
 
911
919
  if (args.help) {
912
920
  process.stdout.write(HELP_TEXT);
package/src/lib/driver.ts CHANGED
@@ -22,6 +22,8 @@ export interface OpenWOPResponse {
22
22
  export interface OpenWOPRequestInit {
23
23
  readonly headers?: Record<string, string>;
24
24
  readonly body?: unknown;
25
+ /** `false` sends no default credential. A caller-supplied `Authorization`
26
+ * header is always sent as given, whatever this says. */
25
27
  readonly authenticated?: boolean;
26
28
  }
27
29
 
@@ -54,7 +56,14 @@ class OpenWOPDriver {
54
56
  if (init.body !== undefined && headers['Content-Type'] === undefined) {
55
57
  headers['Content-Type'] = 'application/json';
56
58
  }
57
- if (init.authenticated !== false) {
59
+ // A caller-supplied Authorization header (any case) is the credential the
60
+ // scenario chose — a second tenant's key, a low-scope key — and is sent as
61
+ // given. Until 2.39.3 the default key overwrote it unless the caller also
62
+ // passed `authenticated: false`, so "tenant B reads A's file" was really the
63
+ // owner reading its own file (200 → a false cross-tenant leak), and a
64
+ // low-scope resolve was really a full-scope one (a false 403 miss).
65
+ const callerAuth = Object.keys(headers).some((h) => h.toLowerCase() === 'authorization');
66
+ if (init.authenticated !== false && !callerAuth) {
58
67
  headers.Authorization = `Bearer ${env.apiKey}`;
59
68
  }
60
69
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The origin a USER AGENT reaches the host under test on — for rows whose
3
+ * normative text is about the host's own origin rather than the suite's
4
+ * transport (RFC 0199 §C.2: `connectUrl` "MUST be an `https` URL on the host's
5
+ * own origin").
6
+ *
7
+ * Until 2.39.4 those rows compared against `--base-url`. A certification cut
8
+ * drives the host over loopback (`http://127.0.0.1:…`), which can never be the
9
+ * https origin a user agent is sent to — so a host with a public front could not
10
+ * pass, and the v2 reference host, which implements all of §C, advertised none of
11
+ * it (it needs an https public base to advertise `oauth.credentialInterrupt`).
12
+ *
13
+ * `OPENWOP_HOST_PUBLIC_URL` names that front, validated like every other suite
14
+ * front (`resolvePublicFront`: https, publicly resolvable). A declared front is
15
+ * EVIDENCE only once it is shown to serve THIS host: the discovery document
16
+ * fetched through it must equal the one fetched over `--base-url`. Otherwise the
17
+ * rows that depend on it record `blocked` with that cause — a front pointing at
18
+ * some other host must not let a `connectUrl` on that other origin pass.
19
+ *
20
+ * Unset, this returns `--base-url`'s origin exactly as before: no behaviour
21
+ * changes for a run that does not declare a front.
22
+ */
23
+ import { resolvePublicFront } from './webhook-receiver.js';
24
+
25
+ export const HOST_FRONT_ENV = 'OPENWOP_HOST_PUBLIC_URL';
26
+
27
+ export type HostOrigin =
28
+ | { readonly ok: true; readonly origin: string; readonly declared: boolean }
29
+ | { readonly ok: false; readonly reason: string };
30
+
31
+ type Fetcher = (url: string) => Promise<{ status: number; text: string }>;
32
+
33
+ const defaultFetch: Fetcher = async (url) => {
34
+ const res = await fetch(url, { headers: { accept: 'application/json', 'OpenWOP-Version': '2.0' }, signal: AbortSignal.timeout(15_000) });
35
+ return { status: res.status, text: await res.text() };
36
+ };
37
+
38
+ /** Key-order-independent JSON equality — the two fetches may serialise differently. */
39
+ function canonical(v: unknown): string {
40
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
41
+ if (v !== null && typeof v === 'object') return `{${Object.keys(v as Record<string, unknown>).sort().map((k) => `${JSON.stringify(k)}:${canonical((v as Record<string, unknown>)[k])}`).join(',')}}`;
42
+ return JSON.stringify(v);
43
+ }
44
+
45
+ /**
46
+ * Resolve the host's own origin. `baseUrl` is the suite's `--base-url`; `fetcher`
47
+ * is injectable so the self-test can run without a host.
48
+ */
49
+ export async function hostPublicOrigin(baseUrl: string, fetcher: Fetcher = defaultFetch): Promise<HostOrigin> {
50
+ const base = new URL(baseUrl);
51
+ const front = resolvePublicFront(HOST_FRONT_ENV, base.origin);
52
+ if (!front.tunnelled) return { ok: true, origin: base.origin, declared: false };
53
+ const frontOrigin = new URL(front.url).origin;
54
+ const path = '/.well-known/openwop';
55
+ let viaFront: { status: number; text: string };
56
+ let viaBase: { status: number; text: string };
57
+ try {
58
+ [viaFront, viaBase] = await Promise.all([fetcher(`${frontOrigin}${path}`), fetcher(`${base.origin}${path}`)]);
59
+ } catch (e) {
60
+ return { ok: false, reason: `${HOST_FRONT_ENV}=${frontOrigin} could not be fetched (${(e as Error).message}) — the declared front is not shown to serve the host under test` };
61
+ }
62
+ if (viaFront.status !== 200 || viaBase.status !== 200) {
63
+ return { ok: false, reason: `discovery answered ${viaFront.status} through ${HOST_FRONT_ENV}=${frontOrigin} and ${viaBase.status} over --base-url — the declared front is not shown to serve the host under test` };
64
+ }
65
+ let same = false;
66
+ try { same = canonical(JSON.parse(viaFront.text)) === canonical(JSON.parse(viaBase.text)); } catch { same = false; }
67
+ if (!same) {
68
+ return { ok: false, reason: `the discovery document through ${HOST_FRONT_ENV}=${frontOrigin} differs from the one over --base-url — the front does not serve the host under test, so an origin claim through it would witness some other host` };
69
+ }
70
+ return { ok: true, origin: frontOrigin, declared: true };
71
+ }
@@ -152,6 +152,33 @@ function signCompact(
152
152
  return base64UrlEncode(signature);
153
153
  }
154
154
 
155
+ /**
156
+ * The LOCAL port a scenario binds the synthetic issuer's JWKS/discovery
157
+ * listener on (2.39.4).
158
+ *
159
+ * `OPENWOP_TEST_OIDC_ISSUER_URL` is the URL the HOST is told to trust — for a
160
+ * remote host a public https front (a tunnel), which carries no port. Until
161
+ * 2.39.4 every scenario bound `127.0.0.1` at the port PARSED from that URL, so a
162
+ * tunnelled issuer (`https://x.trycloudflare.com`) bound :80 and a non-root
163
+ * operator got EACCES: measured by openwop-app-ce on 2026-09-25 against a remote
164
+ * openwop-app instance. No remote host could then execute the RFC 0200 / 0210
165
+ * issuer rows, and none could certify. The webhook receiver has always
166
+ * separated the two (`OPENWOP_WEBHOOK_RECEIVER_URL` + `_PORT`); this is the same
167
+ * split: `OPENWOP_TEST_OIDC_ISSUER_PORT`, when set, is the local port the
168
+ * operator's front forwards to. Unset, the old rule stands unchanged (the URL's
169
+ * port, else 80), so a loopback operator whose URL names its port is unaffected.
170
+ */
171
+ export function issuerListenPort(issuerUrl: string): number {
172
+ const raw = process.env['OPENWOP_TEST_OIDC_ISSUER_PORT']?.trim();
173
+ if (raw !== undefined && raw !== '') {
174
+ const n = Number(raw);
175
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`OPENWOP_TEST_OIDC_ISSUER_PORT must be an integer port 1..65535 (got ${JSON.stringify(raw)})`);
176
+ return n;
177
+ }
178
+ const parsed = new URL(issuerUrl);
179
+ return parsed.port ? Number.parseInt(parsed.port, 10) : 80;
180
+ }
181
+
155
182
  export function createSyntheticOIDCIssuer(
156
183
  opts: SyntheticOIDCIssuerOptions,
157
184
  ): SyntheticOIDCIssuer {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A certification run with pinned fixture ports MUST be single-worker.
3
+ *
4
+ * Every suite fixture reached through an operator's public front listens on a
5
+ * pinned port (`*_PORT`), and the fixture registry that routes a nonce-pathed
6
+ * request to the exercise that minted it (`front-mux.ts`, `scoped-receiver.ts`)
7
+ * lives in ONE vitest worker's memory. With two workers only one can own each
8
+ * pinned port; an exercise in the other worker is unreachable, or its traffic is
9
+ * answered by a registry nobody reads. `cut-bundle.sh` has always passed
10
+ * `--max-workers 1`, and `effect-receiver.ts` assumes it — but nothing enforced
11
+ * it. Measured 2026-09-25: an openwop-app production cut run with
12
+ * `--max-workers 2` recorded `0173.webhook-durable-delivery` and
13
+ * `0187.bound-id-kinds.webhook-emitted` as test timeouts while the host had in
14
+ * fact delivered; the same host passed both single-worker. A certification that
15
+ * silently loses the host's traffic convicts the host, so the CLI refuses it.
16
+ */
17
+
18
+ /** Every pinned-port variable a suite fixture honours. The OAuth doubles derive
19
+ * theirs from `<FRONT>_URL` at runtime, so they are listed rather than grepped. */
20
+ export const PINNED_PORT_ENVS = [
21
+ 'OPENWOP_WEBHOOK_RECEIVER_PORT',
22
+ 'OPENWOP_A2A_FAKE_PEER_PORT',
23
+ 'OPENWOP_MCP_FAKE_SERVER_PORT',
24
+ 'OPENWOP_OAUTH_AS_PORT',
25
+ 'OPENWOP_OAUTH_AS2_PORT',
26
+ 'OPENWOP_OAUTH_RESOURCE_PORT',
27
+ 'OPENWOP_OTEL_COLLECTOR_PORT',
28
+ 'OPENWOP_OTEL_COLLECTOR_GRPC_PORT',
29
+ // 2.39.4: the synthetic OIDC issuer's local bind port (`issuerListenPort`,
30
+ // oidc-issuer.ts). Two scenarios stand an issuer up on it.
31
+ 'OPENWOP_TEST_OIDC_ISSUER_PORT',
32
+ ] as const;
33
+
34
+ /**
35
+ * The refusal message when a `--certify` run pins fixture ports without being
36
+ * single-worker, or `null` when the run is acceptable. `maxWorkers` undefined is
37
+ * vitest's default — one worker per CPU — so it is refused too.
38
+ */
39
+ export function pinnedPortWorkerConflict(
40
+ env: Readonly<Record<string, string | undefined>>,
41
+ maxWorkers: number | undefined,
42
+ certifying: boolean,
43
+ ): string | null {
44
+ if (!certifying) return null;
45
+ const pinned = PINNED_PORT_ENVS.filter((k) => (env[k] ?? '').trim() !== '');
46
+ if (pinned.length === 0 || maxWorkers === 1) return null;
47
+ return `--certify with pinned fixture ports (${pinned.join(', ')}) requires --max-workers 1 (got ${maxWorkers === undefined ? "vitest's default, one per CPU" : maxWorkers}): a pinned port is owned by one worker, so an exercise in another worker cannot receive the host's traffic and its rows would convict the host of a delivery it made`;
48
+ }
@@ -97,11 +97,76 @@ export interface ScopedReceiver {
97
97
  * with the nonce prefix stripped — exactly what it would see on a listener of
98
98
  * its own. It owns the response.
99
99
  */
100
+ /**
101
+ * The listener behind every scoped receiver.
102
+ *
103
+ * On a public cut the operator PINS the receiver port (`OPENWOP_WEBHOOK_RECEIVER_PORT`,
104
+ * the one the front forwards to), so every receiver alive at once must share ONE
105
+ * server on it: routing is already per-nonce (`routeFronted`), so the listener
106
+ * only has to exist. Until 2.39.3 each receiver bound the pinned port itself; a
107
+ * second concurrent receiver hit EADDRINUSE with no `error` listener attached,
108
+ * its `listen` promise never settled, and the leg hung to vitest's timeout — the
109
+ * three `v2-a2a-push-delivery` legs lost on the 2.39.0 and 2.39.2 public cuts.
110
+ * Loopback runs bind ephemeral ports, which is why no rehearsal ever showed it.
111
+ *
112
+ * Unpinned, each receiver keeps a listener of its own (ephemeral port).
113
+ */
114
+ interface Listener { server: Server; port: number; origin: string; members: Set<Member> }
115
+ interface Member { nonce: string; foreign: number }
116
+ /**
117
+ * A pinned port's shared listener and its claim count. The claim is taken
118
+ * SYNCHRONOUSLY, before the first await (2.39.4): a sibling that read the entry
119
+ * and was still awaiting the bind used to be invisible to a closing holder, which
120
+ * dropped the count to zero and shut the server under it.
121
+ */
122
+ interface Shared { listening: Promise<Listener>; refs: number }
123
+ const sharedByPort = new Map<string, Shared>();
124
+
125
+ function handlerFor(members: Set<Member>) {
126
+ return (request: IncomingMessage, res: ServerResponse): void => {
127
+ // Counted BEFORE routing, per live receiver: `routeFronted` answers an
128
+ // unknown nonce itself and cannot report that it did, and a request that is
129
+ // not an exercise's own must still be visible in that exercise's failure detail.
130
+ const path = withoutFrontPath(WEBHOOK_FRONT_ENV, request.url ?? '/');
131
+ for (const m of members) if (!path.startsWith(`${FRONT_MUX_PREFIX}${m.nonce}`)) m.foreign += 1;
132
+ if (routeFronted(WEBHOOK_FRONT_ENV, request, res)) return;
133
+ // Not an `/fx/` path at all: a stranger, or a host that dropped the path it
134
+ // was given. Answered, never recorded — a receiver that counts what it was
135
+ // not addressed to is the defect this helper exists to remove.
136
+ request.resume();
137
+ res.writeHead(404, { 'Content-Type': 'application/json' });
138
+ res.end(JSON.stringify({ error: 'this receiver serves one conformance exercise; address its nonce path' }));
139
+ };
140
+ }
141
+
142
+ async function listen(port: number, binding: { bind: string; advertise: string }): Promise<Listener> {
143
+ const members = new Set<Member>();
144
+ const server = createServer(handlerFor(members));
145
+ await new Promise<void>((resolve, reject) => {
146
+ const onError = (err: Error): void => {
147
+ reject(new Error(`scoped receiver could not bind ${binding.bind}:${port} — ${err.message}. A pinned OPENWOP_WEBHOOK_RECEIVER_PORT must be free for this process (another process holds it?)`));
148
+ };
149
+ server.once('error', onError);
150
+ server.listen(port, binding.bind, () => { server.off('error', onError); resolve(); });
151
+ });
152
+ const addr = server.address();
153
+ if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
154
+ return { server, port: addr.port, origin: `http://${binding.advertise}:${addr.port}`, members };
155
+ }
156
+
157
+ function shut(server: Server): Promise<void> {
158
+ return new Promise<void>((resolve) => {
159
+ // A keep-alive socket held open through a tunnel must not keep close() pending.
160
+ (server as Server & { closeIdleConnections?: () => void }).closeIdleConnections?.();
161
+ server.close(() => resolve());
162
+ (server as Server & { closeAllConnections?: () => void }).closeAllConnections?.();
163
+ });
164
+ }
165
+
100
166
  export async function startScopedReceiver(
101
167
  respond: (hit: ScopedHit, res: ServerResponse) => void,
102
168
  ): Promise<ScopedReceiver> {
103
169
  const nonce = randomBytes(9).toString('hex');
104
- let foreign = 0;
105
170
  const own = (request: IncomingMessage, res: ServerResponse): void => {
106
171
  const chunks: Buffer[] = [];
107
172
  request.on('data', (c: Buffer) => chunks.push(c));
@@ -117,41 +182,53 @@ export async function startScopedReceiver(
117
182
  );
118
183
  });
119
184
  };
120
- registerBehindFront(WEBHOOK_FRONT_ENV, nonce, own);
121
- const server = createServer((request: IncomingMessage, res: ServerResponse) => {
122
- // Counted BEFORE routing: `routeFronted` answers an unknown nonce itself and
123
- // cannot report that it did, and a request that is not this exercise's must
124
- // still be visible in the failure detail.
125
- if (!withoutFrontPath(WEBHOOK_FRONT_ENV, request.url ?? '/').startsWith(`${FRONT_MUX_PREFIX}${nonce}`)) foreign += 1;
126
- if (routeFronted(WEBHOOK_FRONT_ENV, request, res)) return;
127
- // Not an `/fx/` path at all: a stranger, or a host that dropped the path it
128
- // was given. Answered, never recorded — a receiver that counts what it was
129
- // not addressed to is the defect this helper exists to remove.
130
- request.resume();
131
- res.writeHead(404, { 'Content-Type': 'application/json' });
132
- res.end(JSON.stringify({ error: 'this receiver serves one conformance exercise; address its nonce path' }));
133
- });
134
185
  const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
135
- const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
186
+ const isPinned = Number.isInteger(pinned) && pinned > 0 && pinned < 65536;
136
187
  const binding = receiverBinding();
137
- await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
138
- const addr = server.address();
139
- if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
140
- const origin = `http://${binding.advertise}:${addr.port}`;
141
- const front = resolvePublicFront(WEBHOOK_FRONT_ENV, origin);
188
+ let listener: Listener;
189
+ let shared: Shared | undefined;
190
+ let key: string | undefined;
191
+ if (isPinned) {
192
+ key = `${binding.bind}:${pinned}`;
193
+ shared = sharedByPort.get(key);
194
+ if (!shared) {
195
+ const listening = listen(pinned, binding);
196
+ shared = { listening, refs: 0 };
197
+ sharedByPort.set(key, shared);
198
+ const entry = shared;
199
+ listening.catch(() => { if (sharedByPort.get(key!) === entry) sharedByPort.delete(key!); });
200
+ }
201
+ shared.refs += 1; // claimed before any await — a closing sibling now sees this receiver
202
+ try { listener = await shared.listening; }
203
+ catch (err) { shared.refs -= 1; throw err; }
204
+ } else {
205
+ listener = await listen(0, binding);
206
+ }
207
+ const member: Member = { nonce, foreign: 0 };
208
+ listener.members.add(member);
209
+ registerBehindFront(WEBHOOK_FRONT_ENV, nonce, own);
210
+ const front = resolvePublicFront(WEBHOOK_FRONT_ENV, listener.origin);
211
+ let closed = false;
142
212
  return {
143
- server,
213
+ server: listener.server,
144
214
  url: `${front.url.replace(/\/+$/, '')}${FRONT_MUX_PREFIX}${nonce}`,
145
215
  tunnelled: front.tunnelled,
146
- localUrl: `${origin}${FRONT_MUX_PREFIX}${nonce}`,
216
+ localUrl: `${listener.origin}${FRONT_MUX_PREFIX}${nonce}`,
147
217
  nonce,
148
- port: addr.port,
149
- foreign: () => foreign,
150
- close: () =>
151
- new Promise<void>((resolve) => {
152
- unregisterBehindFront(WEBHOOK_FRONT_ENV, nonce);
153
- server.close(() => resolve());
154
- }),
218
+ port: listener.port,
219
+ foreign: () => member.foreign,
220
+ close: async () => {
221
+ if (closed) return;
222
+ closed = true;
223
+ unregisterBehindFront(WEBHOOK_FRONT_ENV, nonce);
224
+ listener.members.delete(member);
225
+ if (shared !== undefined) {
226
+ shared.refs -= 1;
227
+ if (shared.refs > 0) return;
228
+ if (key !== undefined && sharedByPort.get(key) === shared) sharedByPort.delete(key);
229
+ }
230
+ await shut(listener.server);
231
+ },
155
232
  };
156
233
  }
157
234
 
@@ -46,6 +46,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
46
46
  import {
47
47
  createSyntheticOIDCIssuer,
48
48
  type SyntheticOIDCIssuer,
49
+ issuerListenPort,
49
50
  } from '../lib/oidc-issuer.js';
50
51
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
51
52
  import { req } from '../lib/requirement-ids.js';
@@ -174,8 +175,7 @@ describe('auth-oidc-user-bearer: harness-driven token validation', () => {
174
175
 
175
176
  // Bind the harness's JWKS + discovery endpoints so the host can
176
177
  // fetch them when validating tokens.
177
- const parsed = new URL(harnessUrl);
178
- const port = parsed.port ? Number.parseInt(parsed.port, 10) : 80;
178
+ const port = issuerListenPort(harnessUrl);
179
179
 
180
180
  server = createServer((reqBody, res) => {
181
181
  if (!issuer) {
@@ -76,7 +76,8 @@ describe.skipIf(SKIP)('interrupt: auth-required — insufficient scope returns 4
76
76
  const resolve = await driver.post(
77
77
  `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
78
78
  { resumeValue: { action: 'accept' } },
79
- { headers: { Authorization: `Bearer ${lowScopeKey}` } },
79
+ // `authenticated: false`: the low-scope key alone, never the full-scope default.
80
+ { authenticated: false, headers: { Authorization: `Bearer ${lowScopeKey}` } },
80
81
  );
81
82
  expect(resolve.status, req('openwop.it.interrupt-auth-required-resume.bearer-without-approvals-respond-scope-is-rejected',
82
83
  'auth.md §scopes + interrupt-profiles.md §openwop-interrupt-auth-required',
@@ -27,6 +27,7 @@
27
27
  import { describe, it, expect, afterAll } from 'vitest';
28
28
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
29
29
  import { loadEnv } from '../lib/env.js';
30
+ import { hostPublicOrigin } from '../lib/host-public-origin.js';
30
31
  import { isFixtureAdvertised } from '../lib/fixtures.js';
31
32
  import { softSkip, seamAbsent } from '../lib/soft-skip.js';
32
33
  import { req } from '../lib/requirement-ids.js';
@@ -121,7 +122,13 @@ describe('RFC 0199 §C — v2-credential-interrupt (gated on oauth + provider sy
121
122
  expect([data['provider'], data['reason']], req(id, DOC, 'data names the provider and reason missing')).toEqual([PROVIDER, 'missing']);
122
123
  const connect = new URL(String(data['connectUrl']));
123
124
  expect(connect.protocol, req(id, DOC, 'connectUrl MUST be https')).toBe('https:');
124
- expect(connect.origin, req(id, `${DOC} (connectUrl is host-owned)`, `connectUrl MUST be on the host's own origin (${new URL(loadEnv().baseUrl).origin})`)).toBe(new URL(loadEnv().baseUrl).origin);
125
+ // The host's own origin is where a USER AGENT reaches it: --base-url, or the
126
+ // operator-declared OPENWOP_HOST_PUBLIC_URL once shown to serve this host
127
+ // (lib/host-public-origin.ts, 2.39.4). A declared front that is not shown to
128
+ // serve it records `blocked`, never a pass on some other origin.
129
+ const own = await hostPublicOrigin(loadEnv().baseUrl);
130
+ if (!own.ok) return softSkip('blocked', own.reason);
131
+ expect(connect.origin, req(id, `${DOC} (connectUrl is host-owned)`, `connectUrl MUST be on the host's own origin (${own.origin}${own.declared ? ', declared via OPENWOP_HOST_PUBLIC_URL and verified to serve this host' : ''})`)).toBe(own.origin);
125
132
  await driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}, auth(r.bearer));
126
133
  });
127
134
 
@@ -60,7 +60,7 @@ import { softSkip } from '../lib/soft-skip.js';
60
60
  import { req } from '../lib/requirement-ids.js';
61
61
  import { readErrorCode } from '../lib/error-envelope.js';
62
62
  import { v2Discovery, familyAdvertised } from '../lib/v2.js';
63
- import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
63
+ import { createSyntheticOIDCIssuer, issuerListenPort, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
64
64
 
65
65
  export const HOST_CALLBACK_NOT_REQUIRED =
66
66
  'the suite stands up the synthetic OIDC issuer and the host fetches its JWKS; no request returns to the suite\'s own API, so no host-reachable callback is needed';
@@ -120,7 +120,6 @@ async function gate(): Promise<Gate | { readonly kind: 'inapplicable' | 'blocked
120
120
  const audience = process.env['OPENWOP_TEST_OIDC_AUDIENCE']?.trim() ?? 'openwop-conformance';
121
121
  if (issuer === null) {
122
122
  const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
123
- const parsed = new URL(url);
124
123
  const srv = createServer((r, res) => {
125
124
  if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
126
125
  if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
@@ -128,7 +127,7 @@ async function gate(): Promise<Gate | { readonly kind: 'inapplicable' | 'blocked
128
127
  });
129
128
  await new Promise<void>((resolve, reject) => {
130
129
  srv.once('error', reject);
131
- srv.listen(parsed.port ? Number.parseInt(parsed.port, 10) : 80, '127.0.0.1', () => resolve());
130
+ srv.listen(issuerListenPort(url), '127.0.0.1', () => resolve());
132
131
  });
133
132
  server = srv;
134
133
  issuer = made;
@@ -34,7 +34,7 @@ import { driver } from '../lib/driver.js';
34
34
  import { softSkip } from '../lib/soft-skip.js';
35
35
  import { req } from '../lib/requirement-ids.js';
36
36
  import { readErrorCode } from '../lib/error-envelope.js';
37
- import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
37
+ import { createSyntheticOIDCIssuer, issuerListenPort, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
38
38
  import { prmGate } from '../lib/protected-resource.js';
39
39
 
40
40
  export const HOST_CALLBACK_NOT_REQUIRED =
@@ -56,7 +56,6 @@ async function harness(audience: string): Promise<{ url: string; issuer: Synthet
56
56
  if (!url) return null;
57
57
  if (issuer !== null) return { url, issuer };
58
58
  const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
59
- const parsed = new URL(url);
60
59
  const srv = createServer((r, res) => {
61
60
  if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
62
61
  if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
@@ -64,7 +63,7 @@ async function harness(audience: string): Promise<{ url: string; issuer: Synthet
64
63
  });
65
64
  await new Promise<void>((resolve, reject) => {
66
65
  srv.once('error', reject);
67
- srv.listen(parsed.port ? Number.parseInt(parsed.port, 10) : 80, '127.0.0.1', () => resolve());
66
+ srv.listen(issuerListenPort(url), '127.0.0.1', () => resolve());
68
67
  });
69
68
  server = srv;
70
69
  issuer = made;
@@ -208,6 +208,24 @@ function retryWaitMs(doc: Record<string, unknown>): number {
208
208
  */
209
209
  const FAIL_FIRST = 2;
210
210
 
211
+ /**
212
+ * How many attempts THIS host's advertised policy leaves room to refuse
213
+ * (suite 2.39.4). The constant above assumed at least three attempts, but
214
+ * `webhooks.retryPolicy.maxAttempts` is schema-valid from 1, so a host that
215
+ * honestly advertised `maxAttempts: 2` was refused twice, never reached its
216
+ * 204, and failed a leg it conformed to. The receiver now refuses
217
+ * `maxAttempts − 1` attempts, at most FAIL_FIRST and at least 1. A host with no
218
+ * advertised policy is measured exactly as before (FAIL_FIRST), and
219
+ * `maxAttempts: 1` still refuses the first attempt, so a host that never
220
+ * retries still fails: webhooks.md §Durability says best-effort delivery is
221
+ * not a conforming mode.
222
+ */
223
+ function failFirstFor(policy: { maxAttempts?: number } | null): number {
224
+ const m = policy?.maxAttempts;
225
+ if (typeof m !== 'number' || !Number.isInteger(m)) return FAIL_FIRST;
226
+ return Math.max(1, Math.min(FAIL_FIRST, m - 1));
227
+ }
228
+
211
229
  const WAIT_SLACK_MS = 30_000;
212
230
  /** One `retryWaitMs` wait (the retry leg). */
213
231
  const RETRY_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS + WAIT_SLACK_MS;
@@ -241,7 +259,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
241
259
  if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
242
260
  if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
243
261
 
244
- const receiver = await startReceiver(FAIL_FIRST); // 500, 500, then 204
262
+ const failFirst = failFirstFor(advertisedRetryPolicy(doc));
263
+ const receiver = await startReceiver(failFirst); // 500 × failFirst, then 204
245
264
  active = receiver;
246
265
  const sub = await register(receiver);
247
266
  if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
@@ -287,8 +306,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
287
306
  // inside our window this records `blocked`, NOT `executed-fail` — suite
288
307
  // 2.0.3, and this is the third time this file has had to learn it.
289
308
  //
290
- // The receiver answers 204 only on attempt `FAIL_FIRST + 1`, so reaching it
291
- // costs the SUM of the first FAIL_FIRST backoff intervals, not the largest
309
+ // The receiver answers 204 only on attempt `failFirst + 1`, so reaching it
310
+ // costs the SUM of the first failFirst backoff intervals, not the largest
292
311
  // one. On an exponential-from-30s policy that is 30 + 60 = 90 s, which is
293
312
  // exactly RETRY_WAIT_CAP_MS — a host loses by the width of one delivery.
294
313
  // The obvious fix is to derive the wait from the intervals, and it cannot
@@ -312,7 +331,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
312
331
  // only in a changelog.
313
332
  if (!retried) {
314
333
  const policyNote = advertisedRetryPolicy(doc);
315
- return softSkip('blocked', `the retry was observed (${attempts.length} attempts) but the receiver's 204 did not land inside the ${retryWaitMs(doc)}ms window: it answers 204 only on attempt ${FAIL_FIRST + 1}, which costs the SUM of the first ${FAIL_FIRST} backoff intervals, and webhooks.retryPolicy carries only { maxAttempts, backoff${policyNote ? `: ${String(policyNote.backoff)}` : ''} } — the base interval is not advertised, so the suite cannot derive how long to wait. Unmeasured, not unmet (RFC 0148 §A).`);
334
+ return softSkip('blocked', `the retry was observed (${attempts.length} attempts) but the receiver's 204 did not land inside the ${retryWaitMs(doc)}ms window: it answers 204 only on attempt ${failFirst + 1}, which costs the SUM of the first ${failFirst} backoff intervals, and webhooks.retryPolicy carries only { maxAttempts, backoff${policyNote ? `: ${String(policyNote.backoff)}` : ''} } — the base interval is not advertised, so the suite cannot derive how long to wait. Unmeasured, not unmet (RFC 0148 §A).`);
316
335
  }
317
336
  // Backoff: the retry MUST NOT be a tight loop — consecutive attempts for one
318
337
  // key are spaced. Only asserted when the host advertises a non-`none` backoff.
@@ -19,6 +19,8 @@
19
19
  * Gating: soft-skips unless `capabilities.workspace.supported` AND
20
20
  * `OPENWOP_TEST_TENANT_B_API_KEY` (a credential for a SECOND, distinct
21
21
  * tenant·workspace) is supplied — the suite cannot mint a second tenant itself.
22
+ * A key equal to OPENWOP_API_KEY, or one the host refuses 401, is `blocked`
23
+ * before any assertion: neither can measure a second-tenant read.
22
24
  *
23
25
  * @see RFCS/0059-agent-workspace.md §E WCT-1
24
26
  * @see SECURITY/invariants.yaml workspace-cross-tenant-isolation
@@ -27,6 +29,7 @@ import { describe, it, expect } from 'vitest';
27
29
  import { softSkip } from '../lib/soft-skip.js';
28
30
  import { randomUUID } from 'node:crypto';
29
31
  import { driver } from '../lib/driver.js';
32
+ import { loadEnv } from '../lib/env.js';
30
33
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
31
34
  import { req } from '../lib/requirement-ids.js';
32
35
 
@@ -40,7 +43,10 @@ async function workspaceSupported(): Promise<boolean> {
40
43
  }
41
44
 
42
45
  const tenantBKey = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
43
- const asTenantB = { headers: { Authorization: `Bearer ${tenantBKey ?? ''}` } };
46
+ // `authenticated: false` so the driver adds no default (owner-A) credential. Until
47
+ // 2.39.3 it overwrote this header, so every "tenant B" request below was owner A
48
+ // reading its own file — a false leak (200) on any host that set the key.
49
+ const asTenantB = { authenticated: false, headers: { Authorization: `Bearer ${tenantBKey ?? ''}` } };
44
50
 
45
51
  describe('workspace-cross-tenant-isolation (black-box): a §C file MUST NOT leak across owners (RFC 0059 §E WCT-1)', () => {
46
52
  it('a file written by owner A is unreadable + un-enumerable by a second-tenant credential', async () => {
@@ -51,6 +57,13 @@ describe('workspace-cross-tenant-isolation (black-box): a §C file MUST NOT leak
51
57
  return softSkip('blocked', '[workspace-cross-tenant-isolation-blackbox] OPENWOP_TEST_TENANT_B_API_KEY not supplied; skipping the production-path cross-tenant assertion');
52
58
  }
53
59
 
60
+ if (tenantBKey.trim() === loadEnv().apiKey) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the second credential must resolve to a DIFFERENT tenant·workspace, or the leg measures nothing');
61
+
62
+ // Decided before any assertion: a tenant-B key the host refuses 401 is a
63
+ // harness precondition, not an isolation verdict either way.
64
+ const probeB = await driver.get('/v1/host/workspace/files', asTenantB);
65
+ if (probeB.status === 401) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY was refused 401 — it does not authenticate on this host, so no second-tenant read can be measured');
66
+
54
67
  const path = `wct-blackbox-${randomUUID()}.md`;
55
68
  const secret = `WCT1-BLACKBOX-SECRET-${randomUUID()}`;
56
69