@openwop/openwop-conformance 2.39.2 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## [2.39.2] — 2026-09-25 — RFC 0208's unreadable-task leg records its own row
4
10
 
5
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.
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.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
  ```
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.39.2",
4
- "stampSha256": "d3e9417fe0ae348a07cf66c1167cefe100d1dcd43e053c238b82195c77699e9f"
3
+ "version": "2.39.3",
4
+ "stampSha256": "16b07c57b04e10df5de4d4bcf3cfbac8fbda75c1410aac9348f14a823ba31f68"
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.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.2"
59
+ "@openwop/spec-artifacts": "2.39.3"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -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.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": "bac1af1953801e2e3e80cf3aa006b168611cb5b20ccb70c52674815437204e85",
13
- "api/v2/asyncapi.yaml": "77f9311485f7220c28fb81937670ba415b9a3edc0adf73ad3c77e1e288247548",
14
- "api/v2/openapi.yaml": "5bf2dd1dfd7762e0c108ffe51367933108d701d8dd8549aa6e21f1cbece7291b",
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": "4b144e92eae7afe2e541888d89c5461853f0cf1b929e9dca6a253fbe78d3e591",
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": "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": "a8988f480ec268554cdc9cb0a6019172c89aa09cd588f81bfd6166ab5c65a260",
296
296
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
297
- "spec/v2/surface-baseline.json": "314e72a7687918b11a89875d0b735ea4ec1589bd6ccb8abcf574d5e2fbba5333"
297
+ "spec/v2/surface-baseline.json": "7a67a1a935db83145b1fffad3828e09c38f89170d360d2a793ae0634c3164c11"
298
298
  },
299
- "corpusCommit": "57cc8d88f96abb980c605cc386167861b227ff97"
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
- 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
 
@@ -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 bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
179
+ const isPinned = Number.isInteger(pinned) && pinned > 0 && pinned < 65536;
136
180
  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);
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: addr.port,
149
- foreign: () => foreign,
150
- close: () =>
151
- new Promise<void>((resolve) => {
152
- unregisterBehindFront(WEBHOOK_FRONT_ENV, nonce);
153
- server.close(() => resolve());
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
 
@@ -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',
@@ -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