@openwop/openwop-conformance 2.39.1 → 2.39.3
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 +12 -0
- package/README.md +1 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +40 -16
- package/schemas/CORPUS-STAMP.json +10 -10
- package/src/lib/driver.ts +10 -1
- package/src/lib/scoped-receiver.ts +94 -30
- package/src/lib/v2-projection.ts +34 -5
- package/src/scenarios/interrupt-auth-required-resume.test.ts +2 -1
- package/src/scenarios/v2-a2a-operation-map.test.ts +21 -0
- package/src/scenarios/workspace-cross-tenant-isolation-blackbox.test.ts +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.39.3] — 2026-09-25 — a pinned receiver port is shared, and tenant B really is tenant B
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
9
|
+
## [2.39.2] — 2026-09-25 — RFC 0208's unreadable-task leg records its own row
|
|
10
|
+
|
|
11
|
+
- **`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.
|
|
12
|
+
- **`src/lib/v2-projection.ts`: `stripSupported` is now `stripSupportedFlag`, named for the one thing it does** (TODO S1). It removes the retired `supported` flag and nothing else. The corpus generator's function of the same old name (now `projectV1FacetSchema`) also drops `tier`/`experimentalUntil`, folds `supported`-gated if/then into `required`, closes objects and rewrites prose. Under one name, a host reading either assumed the other matched. Each now documents the other. **`stripSupported` stays as a `@deprecated` alias**, because openwop-app's `whd7-v2-projection-parity.test.ts` imports it by that name; it is removed at the next suite major. `carriesUnspliceablePayload` is unchanged, and a self-test now holds it equal to the generator's copy (`scripts/v2-unspliceable.mjs`) on every node of the v1 capabilities schema.
|
|
13
|
+
- **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.39.2` because `2.39.1` is tagged and publishing. Not tagged, not published.
|
|
14
|
+
|
|
3
15
|
## [2.39.1] — 2026-09-25 — the RFC 0214 delivery legs survive a tunnel, and each one leaves its own row
|
|
4
16
|
|
|
5
17
|
- **`v2-a2a-push-delivery` — every requirement now records a row, and the budgets fit a public front.** The 2.39.0 public push-on cut of the v2 reference host lost three legs (§A after-Delete, §B 3xx redirect, §D replay fork) to vitest's per-test timeout, not to an assertion — deliveries were arriving through the front (`no-openwop-signature` passed). Budgets now scale when `OPENWOP_WEBHOOK_RECEIVER_URL` is wired (arrival 60 s, settle 45 s, leg 240 s, fork leg 300 s; loopback unchanged), the #1537 precedent. Separately, `0214.a2a-push-delivery-authenticated` vanished from that bundle because its `it()` also cited `0214.a2a-push-no-openwop-signature` and the ledger keeps the LAST id an `it()` cites: the delivery is now made once and read by two legs, one id each, and when the delivery cannot be made the signature leg records `blocked` with the reason instead of disappearing.
|
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.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.39.3 @openwop/spec-artifacts@2.39.3
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.39.
|
|
3
|
+
"version": "2.39.3",
|
|
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.
|
|
59
|
+
"@openwop/spec-artifacts": "2.39.3"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
5
|
"files": 633,
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
6
|
+
"tests": 2424,
|
|
7
|
+
"withStableId": 2424,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
9
|
"explicitIds": 2304
|
|
10
10
|
},
|
|
@@ -26690,6 +26690,30 @@
|
|
|
26690
26690
|
"line": 180,
|
|
26691
26691
|
"title": "an unknown task and another tenant's task are answered by the same TaskNotFoundError",
|
|
26692
26692
|
"explicitId": null,
|
|
26693
|
+
"citations": [
|
|
26694
|
+
{
|
|
26695
|
+
"section": "identity.md §5; a2a.operations GetTask",
|
|
26696
|
+
"requirement": null,
|
|
26697
|
+
"interpolated": true
|
|
26698
|
+
},
|
|
26699
|
+
{
|
|
26700
|
+
"section": "a2a.errors TaskNotFoundError",
|
|
26701
|
+
"requirement": null,
|
|
26702
|
+
"interpolated": true
|
|
26703
|
+
},
|
|
26704
|
+
{
|
|
26705
|
+
"section": "interop.md §\"The operation mappings\" Isolation",
|
|
26706
|
+
"requirement": null,
|
|
26707
|
+
"interpolated": true
|
|
26708
|
+
}
|
|
26709
|
+
]
|
|
26710
|
+
},
|
|
26711
|
+
{
|
|
26712
|
+
"id": "openwop.it.v2-a2a-operation-map.an-unknown-task-and-another-tenant-s-task-carry-equivalent-errorinfo-details-rfc",
|
|
26713
|
+
"file": "v2-a2a-operation-map.test.ts",
|
|
26714
|
+
"line": 201,
|
|
26715
|
+
"title": "an unknown task and another tenant's task carry equivalent ErrorInfo details (RFC 0211 §D)",
|
|
26716
|
+
"explicitId": null,
|
|
26693
26717
|
"citations": [
|
|
26694
26718
|
{
|
|
26695
26719
|
"section": "identity.md §5; a2a.operations GetTask",
|
|
@@ -26720,7 +26744,7 @@
|
|
|
26720
26744
|
{
|
|
26721
26745
|
"id": "openwop.it.v2-a2a-operation-map.task-status-state-is-the-map-s-projection-of-the-run-status-at-every-observed-st",
|
|
26722
26746
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26723
|
-
"line":
|
|
26747
|
+
"line": 223,
|
|
26724
26748
|
"title": "Task.status.state is the map's projection of the run status at every observed state",
|
|
26725
26749
|
"explicitId": null,
|
|
26726
26750
|
"citations": [
|
|
@@ -26743,7 +26767,7 @@
|
|
|
26743
26767
|
{
|
|
26744
26768
|
"id": "openwop.it.v2-a2a-operation-map.the-json-rpc-error-codes-the-suite-can-cause-are-the-a2a-1-0-1-codes",
|
|
26745
26769
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26746
|
-
"line":
|
|
26770
|
+
"line": 248,
|
|
26747
26771
|
"title": "the JSON-RPC error codes the suite can cause are the A2A 1.0.1 codes",
|
|
26748
26772
|
"explicitId": null,
|
|
26749
26773
|
"citations": [
|
|
@@ -26766,7 +26790,7 @@
|
|
|
26766
26790
|
{
|
|
26767
26791
|
"id": "openwop.it.v2-a2a-operation-map.a-row-whose-requires-facet-is-not-advertised-is-refused-with-the-row-s-error",
|
|
26768
26792
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26769
|
-
"line":
|
|
26793
|
+
"line": 262,
|
|
26770
26794
|
"title": "a row whose requires facet is not advertised is refused with the row's error",
|
|
26771
26795
|
"explicitId": null,
|
|
26772
26796
|
"citations": [
|
|
@@ -26784,7 +26808,7 @@
|
|
|
26784
26808
|
{
|
|
26785
26809
|
"id": "openwop.it.v2-a2a-operation-map.a-host-not-advertising-a2a-pushnotifications-refuses-all-four-push-config-operat",
|
|
26786
26810
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26787
|
-
"line":
|
|
26811
|
+
"line": 275,
|
|
26788
26812
|
"title": "a host not advertising a2a.pushNotifications refuses all four push-config operations with PushNotificationNotSupportedError",
|
|
26789
26813
|
"explicitId": null,
|
|
26790
26814
|
"citations": [
|
|
@@ -26808,7 +26832,7 @@
|
|
|
26808
26832
|
{
|
|
26809
26833
|
"id": "openwop.it.v2-a2a-operation-map.rfc-0214-createtaskpushnotificationconfig-refuses-a-non-https-or-private-destina",
|
|
26810
26834
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26811
|
-
"line":
|
|
26835
|
+
"line": 306,
|
|
26812
26836
|
"title": "RFC 0214 — CreateTaskPushNotificationConfig refuses a non-https or private destination",
|
|
26813
26837
|
"explicitId": null,
|
|
26814
26838
|
"citations": [
|
|
@@ -26832,7 +26856,7 @@
|
|
|
26832
26856
|
{
|
|
26833
26857
|
"id": "openwop.it.v2-a2a-operation-map.rfc-0214-e-a-push-config-of-another-tenant-s-task-of-another-task-or-unknown-is",
|
|
26834
26858
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26835
|
-
"line":
|
|
26859
|
+
"line": 324,
|
|
26836
26860
|
"title": "RFC 0214 §E — a push config of another tenant's task, of another task, or unknown, is answered identically",
|
|
26837
26861
|
"explicitId": null,
|
|
26838
26862
|
"citations": [
|
|
@@ -26880,7 +26904,7 @@
|
|
|
26880
26904
|
{
|
|
26881
26905
|
"id": "openwop.it.v2-a2a-operation-map.rfc-0214-a-a-push-config-s-token-and-credentials-are-never-returned-and-never-re",
|
|
26882
26906
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26883
|
-
"line":
|
|
26907
|
+
"line": 368,
|
|
26884
26908
|
"title": "RFC 0214 §A — a push config's token and credentials are never returned, and never reach task, events or state",
|
|
26885
26909
|
"explicitId": null,
|
|
26886
26910
|
"citations": [
|
|
@@ -26904,7 +26928,7 @@
|
|
|
26904
26928
|
{
|
|
26905
26929
|
"id": "openwop.it.v2-a2a-operation-map.listtasks-returns-only-what-listruns-returns-to-the-same-subject-tenant-and-cont",
|
|
26906
26930
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26907
|
-
"line":
|
|
26931
|
+
"line": 402,
|
|
26908
26932
|
"title": "ListTasks returns only what listRuns returns to the same Subject; tenant and contextId never select",
|
|
26909
26933
|
"explicitId": null,
|
|
26910
26934
|
"citations": [
|
|
@@ -26941,7 +26965,7 @@
|
|
|
26941
26965
|
{
|
|
26942
26966
|
"id": "openwop.it.v2-a2a-operation-map.an-a2a-error-s-data-is-an-array-carrying-one-errorinfo-with-the-map-row-s-reason",
|
|
26943
26967
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26944
|
-
"line":
|
|
26968
|
+
"line": 435,
|
|
26945
26969
|
"title": "an A2A error's data is an array carrying one ErrorInfo with the map row's reason and the A2A domain",
|
|
26946
26970
|
"explicitId": null,
|
|
26947
26971
|
"citations": [
|
|
@@ -26975,7 +26999,7 @@
|
|
|
26975
26999
|
{
|
|
26976
27000
|
"id": "openwop.it.v2-a2a-operation-map.no-response-on-the-card-s-json-rpc-interface-url-is-the-openwop-error-envelope",
|
|
26977
27001
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26978
|
-
"line":
|
|
27002
|
+
"line": 456,
|
|
26979
27003
|
"title": "no response on the card's JSON-RPC interface URL is the OpenWOP error envelope",
|
|
26980
27004
|
"explicitId": null,
|
|
26981
27005
|
"citations": [
|
|
@@ -26993,7 +27017,7 @@
|
|
|
26993
27017
|
{
|
|
26994
27018
|
"id": "openwop.it.v2-a2a-operation-map.versionnotsupportederror-carries-errorinfo-version-not-supported-and-supportedve",
|
|
26995
27019
|
"file": "v2-a2a-operation-map.test.ts",
|
|
26996
|
-
"line":
|
|
27020
|
+
"line": 476,
|
|
26997
27021
|
"title": "VersionNotSupportedError carries ErrorInfo VERSION_NOT_SUPPORTED, and supportedVersions is a comma-joined subset of the card",
|
|
26998
27022
|
"explicitId": null,
|
|
26999
27023
|
"citations": [
|
|
@@ -27017,7 +27041,7 @@
|
|
|
27017
27041
|
{
|
|
27018
27042
|
"id": "openwop.it.v2-a2a-operation-map.an-http-json-interface-answers-an-a2a-error-with-google-rpc-status-carrying-the",
|
|
27019
27043
|
"file": "v2-a2a-operation-map.test.ts",
|
|
27020
|
-
"line":
|
|
27044
|
+
"line": 494,
|
|
27021
27045
|
"title": "an HTTP+JSON interface answers an A2A error with google.rpc.Status carrying the ErrorInfo",
|
|
27022
27046
|
"explicitId": null,
|
|
27023
27047
|
"citations": [
|
|
@@ -27036,7 +27060,7 @@
|
|
|
27036
27060
|
{
|
|
27037
27061
|
"id": "openwop.it.v2-a2a-operation-map.a-run-suspended-on-a-credential-interrupt-projects-to-task-state-auth-required-n",
|
|
27038
27062
|
"file": "v2-a2a-operation-map.test.ts",
|
|
27039
|
-
"line":
|
|
27063
|
+
"line": 509,
|
|
27040
27064
|
"title": "a run suspended on a credential interrupt projects to TASK_STATE_AUTH_REQUIRED, naming the provider and carrying connectUrl",
|
|
27041
27065
|
"explicitId": null,
|
|
27042
27066
|
"citations": [
|
|
@@ -35830,7 +35854,7 @@
|
|
|
35830
35854
|
{
|
|
35831
35855
|
"id": "openwop.it.workspace-cross-tenant-isolation-blackbox.a-file-written-by-owner-a-is-unreadable-un-enumerable-by-a-second-tenant-credent",
|
|
35832
35856
|
"file": "workspace-cross-tenant-isolation-blackbox.test.ts",
|
|
35833
|
-
"line":
|
|
35857
|
+
"line": 52,
|
|
35834
35858
|
"title": "a file written by owner A is unreadable + un-enumerable by a second-tenant credential",
|
|
35835
35859
|
"explicitId": "openwop.it.workspace-cross-tenant-isolation-blackbox.a-file-written-by-owner-a-is-unreadable-un-enumerable-by-a-second-tenant-credent",
|
|
35836
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.
|
|
5
|
-
"corpusTag": "v2.39.
|
|
4
|
+
"version": "2.39.3",
|
|
5
|
+
"corpusTag": "v2.39.3",
|
|
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": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "e51c61c5d0fe876b5851e6583a08a61e0563d44985654299db98dbcc5d7ff6ce",
|
|
13
|
+
"api/v2/asyncapi.yaml": "150a48f15f5c3b984aff61055304f30299a75f5b9561ff1b4c788c4a40b64325",
|
|
14
|
+
"api/v2/openapi.yaml": "c3969b2fbfde19dfd9a42af4a67da8d34cf3135ad8314fe7211173a37d58ed96",
|
|
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": "
|
|
207
|
+
"spec/v1/core-standard-manifest.json": "796de9d8793ff2243fee9c5ffe3b9adc49b0f2e663147b965043de92594352e9",
|
|
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": "
|
|
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": "
|
|
295
|
+
"spec/v2/release.json": "a8988f480ec268554cdc9cb0a6019172c89aa09cd588f81bfd6166ab5c65a260",
|
|
296
296
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
297
|
-
"spec/v2/surface-baseline.json": "
|
|
297
|
+
"spec/v2/surface-baseline.json": "7a67a1a935db83145b1fffad3828e09c38f89170d360d2a793ae0634c3164c11"
|
|
298
298
|
},
|
|
299
|
-
"corpusCommit": "
|
|
299
|
+
"corpusCommit": "c62b05884b9b83f4016890582e8606e3bc74ad6a"
|
|
300
300
|
}
|
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
|
-
|
|
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
|
|
|
@@ -97,11 +97,69 @@ 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>; refs: number }
|
|
115
|
+
interface Member { nonce: string; foreign: number }
|
|
116
|
+
const sharedByPort = new Map<string, Promise<Listener>>();
|
|
117
|
+
|
|
118
|
+
function handlerFor(members: Set<Member>) {
|
|
119
|
+
return (request: IncomingMessage, res: ServerResponse): void => {
|
|
120
|
+
// Counted BEFORE routing, per live receiver: `routeFronted` answers an
|
|
121
|
+
// unknown nonce itself and cannot report that it did, and a request that is
|
|
122
|
+
// not an exercise's own must still be visible in that exercise's failure detail.
|
|
123
|
+
const path = withoutFrontPath(WEBHOOK_FRONT_ENV, request.url ?? '/');
|
|
124
|
+
for (const m of members) if (!path.startsWith(`${FRONT_MUX_PREFIX}${m.nonce}`)) m.foreign += 1;
|
|
125
|
+
if (routeFronted(WEBHOOK_FRONT_ENV, request, res)) return;
|
|
126
|
+
// Not an `/fx/` path at all: a stranger, or a host that dropped the path it
|
|
127
|
+
// was given. Answered, never recorded — a receiver that counts what it was
|
|
128
|
+
// not addressed to is the defect this helper exists to remove.
|
|
129
|
+
request.resume();
|
|
130
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
131
|
+
res.end(JSON.stringify({ error: 'this receiver serves one conformance exercise; address its nonce path' }));
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function listen(port: number, binding: { bind: string; advertise: string }): Promise<Listener> {
|
|
136
|
+
const members = new Set<Member>();
|
|
137
|
+
const server = createServer(handlerFor(members));
|
|
138
|
+
await new Promise<void>((resolve, reject) => {
|
|
139
|
+
const onError = (err: Error): void => {
|
|
140
|
+
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?)`));
|
|
141
|
+
};
|
|
142
|
+
server.once('error', onError);
|
|
143
|
+
server.listen(port, binding.bind, () => { server.off('error', onError); resolve(); });
|
|
144
|
+
});
|
|
145
|
+
const addr = server.address();
|
|
146
|
+
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
147
|
+
return { server, port: addr.port, origin: `http://${binding.advertise}:${addr.port}`, members, refs: 0 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function shut(server: Server): Promise<void> {
|
|
151
|
+
return new Promise<void>((resolve) => {
|
|
152
|
+
// A keep-alive socket held open through a tunnel must not keep close() pending.
|
|
153
|
+
(server as Server & { closeIdleConnections?: () => void }).closeIdleConnections?.();
|
|
154
|
+
server.close(() => resolve());
|
|
155
|
+
(server as Server & { closeAllConnections?: () => void }).closeAllConnections?.();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
100
159
|
export async function startScopedReceiver(
|
|
101
160
|
respond: (hit: ScopedHit, res: ServerResponse) => void,
|
|
102
161
|
): Promise<ScopedReceiver> {
|
|
103
162
|
const nonce = randomBytes(9).toString('hex');
|
|
104
|
-
let foreign = 0;
|
|
105
163
|
const own = (request: IncomingMessage, res: ServerResponse): void => {
|
|
106
164
|
const chunks: Buffer[] = [];
|
|
107
165
|
request.on('data', (c: Buffer) => chunks.push(c));
|
|
@@ -117,41 +175,47 @@ export async function startScopedReceiver(
|
|
|
117
175
|
);
|
|
118
176
|
});
|
|
119
177
|
};
|
|
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
178
|
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
135
|
-
const
|
|
179
|
+
const isPinned = Number.isInteger(pinned) && pinned > 0 && pinned < 65536;
|
|
136
180
|
const binding = receiverBinding();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
181
|
+
let listener: Listener;
|
|
182
|
+
let key: string | undefined;
|
|
183
|
+
if (isPinned) {
|
|
184
|
+
key = `${binding.bind}:${pinned}`;
|
|
185
|
+
let p = sharedByPort.get(key);
|
|
186
|
+
if (!p) {
|
|
187
|
+
p = listen(pinned, binding);
|
|
188
|
+
sharedByPort.set(key, p);
|
|
189
|
+
p.catch(() => { if (sharedByPort.get(key!) === p) sharedByPort.delete(key!); });
|
|
190
|
+
}
|
|
191
|
+
listener = await p;
|
|
192
|
+
} else {
|
|
193
|
+
listener = await listen(0, binding);
|
|
194
|
+
}
|
|
195
|
+
const member: Member = { nonce, foreign: 0 };
|
|
196
|
+
listener.members.add(member);
|
|
197
|
+
listener.refs += 1;
|
|
198
|
+
registerBehindFront(WEBHOOK_FRONT_ENV, nonce, own);
|
|
199
|
+
const front = resolvePublicFront(WEBHOOK_FRONT_ENV, listener.origin);
|
|
200
|
+
let closed = false;
|
|
142
201
|
return {
|
|
143
|
-
server,
|
|
202
|
+
server: listener.server,
|
|
144
203
|
url: `${front.url.replace(/\/+$/, '')}${FRONT_MUX_PREFIX}${nonce}`,
|
|
145
204
|
tunnelled: front.tunnelled,
|
|
146
|
-
localUrl: `${origin}${FRONT_MUX_PREFIX}${nonce}`,
|
|
205
|
+
localUrl: `${listener.origin}${FRONT_MUX_PREFIX}${nonce}`,
|
|
147
206
|
nonce,
|
|
148
|
-
port:
|
|
149
|
-
foreign: () => foreign,
|
|
150
|
-
close: () =>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
207
|
+
port: listener.port,
|
|
208
|
+
foreign: () => member.foreign,
|
|
209
|
+
close: async () => {
|
|
210
|
+
if (closed) return;
|
|
211
|
+
closed = true;
|
|
212
|
+
unregisterBehindFront(WEBHOOK_FRONT_ENV, nonce);
|
|
213
|
+
listener.members.delete(member);
|
|
214
|
+
listener.refs -= 1;
|
|
215
|
+
if (listener.refs > 0) return;
|
|
216
|
+
if (key !== undefined && sharedByPort.get(key) !== undefined) sharedByPort.delete(key);
|
|
217
|
+
await shut(listener.server);
|
|
218
|
+
},
|
|
155
219
|
};
|
|
156
220
|
}
|
|
157
221
|
|
package/src/lib/v2-projection.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Remove the retired `supported` flag from a v1 capability schema fragment
|
|
3
|
+
* (or an advertised value), and flag payload a v2 record cannot splice.
|
|
3
4
|
*
|
|
4
5
|
* WHY THIS EXISTS, in three measured instances from one week:
|
|
5
6
|
*
|
|
@@ -24,17 +25,37 @@
|
|
|
24
25
|
* It deliberately does NOT invent shape. If a v1 value carries payload a v2
|
|
25
26
|
* record cannot hold, that is RFC 0193's named-seat problem and needs a person
|
|
26
27
|
* to name the seat; this function will not paper over it.
|
|
28
|
+
*
|
|
29
|
+
* ── What it is NOT ──────────────────────────────────────────────────────────
|
|
30
|
+
* It is not the corpus's v2 facet projection. `projectV1FacetSchema` in
|
|
31
|
+
* scripts/generate-from-declaration.mjs builds schemas/v2/capabilities.schema.json
|
|
32
|
+
* and does four more things this does not:
|
|
33
|
+
*
|
|
34
|
+
* - drops `tier` and `experimentalUntil` (the record's `status`/`until`
|
|
35
|
+
* absorb them — mapping a v1 tier onto a v2 status is a decision, not a strip);
|
|
36
|
+
* - folds `supported`-gated if/then into unconditional `required` (RFC 0192 §A);
|
|
37
|
+
* here the conditional stays, its `if` reduced to an always-true
|
|
38
|
+
* `{ properties: {} }` — the same verdict, but not the same schema;
|
|
39
|
+
* - closes objects (`additionalProperties: false`);
|
|
40
|
+
* - rewrites `supported` prose in descriptions (RFC 0192 §B).
|
|
41
|
+
*
|
|
42
|
+
* So `multiAgent.executionModel` passed through this still carries `tier` and
|
|
43
|
+
* `experimentalUntil`. Both functions were named `stripSupported` until suite
|
|
44
|
+
* 2.38.x, which hid that difference (the old name survives as a deprecated
|
|
45
|
+
* alias for openwop-app's parity test); do not merge them — they have different
|
|
46
|
+
* contracts. `carriesUnspliceablePayload` IS the same predicate as
|
|
47
|
+
* scripts/v2-unspliceable.mjs, and a self-test holds the two copies equal.
|
|
27
48
|
*/
|
|
28
49
|
|
|
29
|
-
/** Recursively drop `supported` — at every depth, and out of any `required[]`. */
|
|
30
|
-
export function
|
|
31
|
-
if (Array.isArray(value)) return value.map((v) =>
|
|
50
|
+
/** Recursively drop `supported` — at every depth, and out of any `required[]`. Nothing else. */
|
|
51
|
+
export function stripSupportedFlag<T>(value: T): T {
|
|
52
|
+
if (Array.isArray(value)) return value.map((v) => stripSupportedFlag(v)) as unknown as T;
|
|
32
53
|
if (value === null || typeof value !== 'object') return value;
|
|
33
54
|
|
|
34
55
|
const out: Record<string, unknown> = {};
|
|
35
56
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
36
57
|
if (k === 'supported') continue;
|
|
37
|
-
out[k] =
|
|
58
|
+
out[k] = stripSupportedFlag(v);
|
|
38
59
|
}
|
|
39
60
|
// A `required` list naming `supported` keeps a retired field mandatory, which
|
|
40
61
|
// is how a closed v2 record ends up unsatisfiable by any honest host.
|
|
@@ -46,6 +67,14 @@ export function stripSupported<T>(value: T): T {
|
|
|
46
67
|
return out as T;
|
|
47
68
|
}
|
|
48
69
|
|
|
70
|
+
/**
|
|
71
|
+
* @deprecated Renamed `stripSupportedFlag` in suite 2.38.x; the old name was shared
|
|
72
|
+
* with the generator's full schema projection and hid the difference. Kept because
|
|
73
|
+
* openwop-app's `backend/typescript/test/whd7-v2-projection-parity.test.ts` imports
|
|
74
|
+
* it by this name. Remove at the next suite major.
|
|
75
|
+
*/
|
|
76
|
+
export const stripSupported = stripSupportedFlag;
|
|
77
|
+
|
|
49
78
|
/**
|
|
50
79
|
* True when a v1 value carries payload a uniform v2 record cannot splice —
|
|
51
80
|
* an array, a map, an enum or a scalar rather than an object with properties.
|
|
@@ -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
|
-
|
|
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',
|
|
@@ -190,6 +190,27 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
|
|
|
190
190
|
const b = await rpc(t.url, 'GetTask', { id: foreign });
|
|
191
191
|
expect(a.error?.code, req(R('a2a-unreadable-not-found'), 'a2a.errors TaskNotFoundError', `an unknown task MUST be TaskNotFoundError -32001 (got ${JSON.stringify(a.error ?? a.result)})`)).toBe(-32001);
|
|
192
192
|
expect(b.error?.code, req(R('a2a-unreadable-not-found'), 'interop.md §"The operation mappings" Isolation', `a task the caller cannot read — here a foreign tenant segment REST refuses 403 — MUST be answered exactly as a nonexistent one, -32001 (got ${JSON.stringify(b.error ?? b.result)}, HTTP ${b.status})`)).toBe(-32001);
|
|
193
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// RFC 0211's detail comparison gets its OWN it(): an it() that cites two ids
|
|
197
|
+
// records only the LAST one in the ledger, and until 2.39.2 this setup lived in
|
|
198
|
+
// one it() with 0208's assertions — so `0208.a2a-unreadable-not-found` never
|
|
199
|
+
// reached a bundle (absent from the certified 2.38.0 v2-reference cut, the only
|
|
200
|
+
// row keeping RFC 0208 from Accepted). Same class as openwop#1552.
|
|
201
|
+
it('an unknown task and another tenant\'s task carry equivalent ErrorInfo details (RFC 0211 §D)', async () => {
|
|
202
|
+
const t = await target(true);
|
|
203
|
+
if (!t.ok) return skip(t, R11('a2a-unreadable-not-found-details'));
|
|
204
|
+
const first = await startApprovalTask(t.url);
|
|
205
|
+
const id = first.task?.id;
|
|
206
|
+
expect(typeof id === 'string' && id.includes('/'), req(R11('a2a-unreadable-not-found-details'), 'identity.md §5; a2a.operations GetTask', `Task.id is the tenant-bound runId <tenant>/<opaque> (got ${JSON.stringify(id)})`)).toBe(true);
|
|
207
|
+
const [tenant, opaque] = [id!.slice(0, id!.indexOf('/')), id!.slice(id!.indexOf('/') + 1)];
|
|
208
|
+
const fabricated = `${tenant}/${randomBytes(16).toString('base64url').replace(/[^A-Za-z0-9]/g, 'x').slice(0, 22)}`;
|
|
209
|
+
const foreign = `zz-conformance-foreign/${opaque}`;
|
|
210
|
+
const a = await rpc(t.url, 'GetTask', { id: fabricated });
|
|
211
|
+
const b = await rpc(t.url, 'GetTask', { id: foreign });
|
|
212
|
+
expect(a.error?.code, req(R11('a2a-unreadable-not-found-details'), 'a2a.errors TaskNotFoundError', `an unknown task MUST be TaskNotFoundError -32001 (got ${JSON.stringify(a.error ?? a.result)})`)).toBe(-32001);
|
|
213
|
+
expect(b.error?.code, req(R11('a2a-unreadable-not-found-details'), 'interop.md §"The operation mappings" Isolation', `a task the caller cannot read — here a foreign tenant segment REST refuses 403 — MUST be answered exactly as a nonexistent one, -32001 (got ${JSON.stringify(b.error ?? b.result)}, HTTP ${b.status})`)).toBe(-32001);
|
|
193
214
|
// RFC 0211 §D: compare the details per element. `Object.keys(data)` of a one-element
|
|
194
215
|
// array is ["0"] whatever the element discloses, so a key comparison is vacuous.
|
|
195
216
|
const na = normaliseErrorData(a.error?.data, fabricated);
|
|
@@ -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
|
-
|
|
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
|
|