@emseepea/testing 0.24.1 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -134,12 +134,35 @@ Native-client events are authoritative for the tools that were actually used:
134
134
  a turn is accepted only when every application MCP call belongs to the
135
135
  configured test server and its expected tools.
136
136
 
137
- Codex may make its two built-in resource-discovery calls through the CLI's
137
+ With resource capture left off, Codex may make its two built-in resource-discovery calls through the CLI's
138
138
  `codex` server. The runner accepts them only when their native results prove the
139
139
  resource and template inventories are empty, and excludes them from application
140
140
  assertions and raw evidence.
141
141
 
142
- Any non-empty, unrecognised, account-app, plugin, or other foreign call is
142
+ For a Codex conversation that reads resources, set `nativeResources: true` in
143
+ `createConversation` options. This option is off by default; other providers
144
+ reject it.
145
+
146
+ The runner obtains a bounded resource catalogue using the conversation's
147
+ authentication and records recognized native resource operations separately
148
+ from application-tool calls. `turn.resourceOperations` contains one operation
149
+ array per trial; `assertNoToolCalls` still refers to application tools.
150
+ Unscoped empty Codex discovery remains excluded from resource evidence.
151
+
152
+ Registered reads retain their actual returned contents and request order in
153
+ the evidence and meaning-judge context. Aggregate contents may have distinct
154
+ URIs, and successful empty reads remain empty. Returned URIs do not authorize
155
+ another read. Failed calls, unknown requested URIs, foreign servers and helper
156
+ names that collide with application tools still fail qualification.
157
+
158
+ The [retained genuine AI proof](https://github.com/emseepea/emseepea/blob/main/docs/reviews/native-resource-evidence-2026-10-13.json) covers a synthetic mixed tool/resource journey
159
+ across resumed and fresh model sessions. It does not qualify general resource-only
160
+ discovery, authenticated HTTP correlation or a production learner journey.
161
+
162
+ Fresh model sessions use the same running test application. This option does
163
+ not recover completed turns from a failed model invocation.
164
+
165
+ Any other non-empty, unrecognised, account-app, plugin, or foreign call is
143
166
  rejected without storing raw account metadata.
144
167
 
145
168
  Failed Codex runs retain only a fixed failure category: authentication,
@@ -647,6 +670,63 @@ The command verifies a
647
670
  signed-in control, not the account's identity; the human confirms that identity.
648
671
  If that control changes, pass an account-specific `--authenticated-selector`.
649
672
 
673
+ ### Use an already-authorized browser
674
+
675
+ If the launched browser repeats a security challenge, stop setup. Complete
676
+ login, MFA, CAPTCHA, and consent yourself in an ordinary browser. The setup
677
+ helper keeps Chromium's sandbox enabled, but cannot guarantee that an
678
+ automation-launched browser will be accepted by the login service.
679
+
680
+ An existing Playwright integration can pass its owner-authorized `Page` directly
681
+ to `runNativeChatGptJourney`. The adapter does not need to launch that browser.
682
+ It cannot attach to an arbitrary ordinary Chrome window without a supported
683
+ browser control interface.
684
+
685
+ Chrome versions with GUI remote debugging expose it at
686
+ `chrome://inspect/#remote-debugging`. The browser owner can select "Allow remote
687
+ debugging for this browser instance" and approve the connection in Chrome.
688
+ This needs no command-line browser launch.
689
+
690
+ The runner must still execute on
691
+ the browser's host; enabling this option does not give a cloud runner access
692
+ to another computer. Use a dedicated test browser and disable debugging when
693
+ finished.
694
+
695
+ For a Chromium session that the adopter has already opened and exposed through
696
+ Chrome DevTools Protocol (CDP), the execution command can attach locally:
697
+
698
+ ```sh
699
+ npx emseepea-chatgpt --enable-native-chatgpt --scenario ./native-chatgpt.mjs \
700
+ --connect-cdp 'ws://127.0.0.1:9222/devtools/browser/<browser-id>' \
701
+ --browser-owner '<test-account-owner>'
702
+ ```
703
+
704
+ This is an alternative to the setup command, not a way to solve a CAPTCHA.
705
+ The browser owner must confirm the signed-in account and complete connection
706
+ consent before attaching. `--browser-owner` must match the scenario's account
707
+ owner. Use a dedicated synthetic-test browser with one browser context.
708
+
709
+ The command accepts only a browser WebSocket on literal `127.0.0.1`, an explicit
710
+ port from 1024 to 65535, and `/devtools/browser` with an optional browser ID.
711
+ Remote hosts,
712
+ credentials, query strings, redirects, HTTP discovery, and profile options are
713
+ rejected.
714
+
715
+ WebSocket messages and the initial send queue are capped at 16 MiB.
716
+ Keep the debugging interface and address private, bound to loopback, and
717
+ available only while testing; CDP grants browser control and is not an account
718
+ authorization mechanism.
719
+
720
+ Attachment opens a separate test tab in the authorized context. It does not
721
+ read or export cookies or storage state, inspect existing tabs, launch Chrome,
722
+ or alter login behavior or the owner's browser defaults.
723
+
724
+ After the scenario's required cleanup, the runner
725
+ closes its tab and disconnects. The owner browser and its existing tabs remain
726
+ open. Missing browser access or incomplete authentication blocks verification.
727
+
728
+ ### Connect and run the owned scenario
729
+
650
730
  Connect your authorized HTTPS MCP endpoint through ChatGPT's current plugin
651
731
  installation flow. Complete account consent yourself. A local server requires
652
732
  a supported HTTPS tunnel before ChatGPT can reach it. See the
package/chatgpt/run.mjs CHANGED
@@ -4,12 +4,15 @@ import { readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
+ import { attachOwnedBrowser, validateBrowserAttachment } from "./session.mjs";
7
8
 
8
9
  const help = `Usage: emseepea-chatgpt --enable-native-chatgpt --scenario <owned-module.mjs>
9
10
  [--profile <dedicated-directory>] [--output <private-evidence.json>]
11
+ Or attach: --connect-cdp <loopback-browser-WebSocket> --browser-owner <owner>
10
12
 
11
13
  The module exports authorization and createJourney({ page, runId }). It owns
12
- its assertions, server observations, and cleanup. Complete human setup first.
14
+ its assertions, server observations, and cleanup. Complete human setup first,
15
+ or explicitly confirm ownership of an already-authorized local CDP browser.
13
16
  This command never logs in, grants consent, or exports authentication state.
14
17
  `;
15
18
 
@@ -21,37 +24,49 @@ async function main() {
21
24
  for (let index = 0; index < args.length; index += 1) {
22
25
  const arg = args[index];
23
26
  if (arg === "--enable-native-chatgpt") continue;
24
- if (!["--scenario", "--profile", "--output"].includes(arg) || values.has(arg) || !args[index + 1] || args[index + 1].startsWith("--")) throw new Error("invalid-run-options");
27
+ if (!["--scenario", "--profile", "--output", "--connect-cdp", "--browser-owner"].includes(arg) || values.has(arg) || !args[index + 1] || args[index + 1].startsWith("--")) throw new Error("invalid-run-options");
25
28
  values.set(arg, args[++index]);
26
29
  }
27
30
  if (!values.get("--scenario")) throw new Error("owned-scenario-required");
28
31
  const scenario = await import(pathToFileURL(resolve(values.get("--scenario"))).href);
29
32
  if (typeof scenario.createJourney !== "function" || scenario.authorization?.approved !== true || typeof scenario.authorization.accountOwner !== "string") throw new Error("owned-scenario-required");
30
- const profile = resolve(values.get("--profile") ?? join(homedir(), ".emseepea", "chatgpt-browser"));
31
- const record = await readFile(join(profile, "emseepea-profile.json"), "utf8").then(JSON.parse).catch(() => undefined);
32
- const ownerSha256 = createHash("sha256").update(scenario.authorization.accountOwner).digest("hex");
33
- if (record?.schemaVersion !== 1 || record?.humanConfirmed !== true || record?.ownerSha256 !== ownerSha256 || !["chromium", "chrome", "msedge"].includes(record?.channel)) throw new Error("human-setup-required");
33
+ const attached = values.has("--connect-cdp");
34
+ if ((attached && values.has("--profile")) || (!attached && values.has("--browser-owner"))) throw new Error("invalid-run-options");
35
+ if (attached) validateBrowserAttachment(values.get("--connect-cdp"), values.get("--browser-owner"), scenario.authorization.accountOwner);
36
+ let profile;
37
+ let record;
38
+ if (!attached) {
39
+ profile = resolve(values.get("--profile") ?? join(homedir(), ".emseepea", "chatgpt-browser"));
40
+ record = await readFile(join(profile, "emseepea-profile.json"), "utf8").then(JSON.parse).catch(() => undefined);
41
+ const ownerSha256 = createHash("sha256").update(scenario.authorization.accountOwner).digest("hex");
42
+ if (record?.schemaVersion !== 1 || record?.humanConfirmed !== true || record?.ownerSha256 !== ownerSha256 || !["chromium", "chrome", "msedge"].includes(record?.channel)) throw new Error("human-setup-required");
43
+ }
34
44
  let chromium;
35
45
  try { ({ chromium } = await import("playwright")); }
36
46
  catch { throw new Error("optional-playwright-dependency-required"); }
37
47
  const { runNativeChatGptJourney } = await import("../dist/chatgpt.js");
38
- let context;
39
- try { context = await chromium.launchPersistentContext(profile, { headless: false, ...(record.channel === "chromium" ? {} : { channel: record.channel }) }); }
40
- catch { throw new Error("headed-browser-unavailable"); }
48
+ let session;
49
+ if (attached) session = await attachOwnedBrowser({ chromium, endpoint: values.get("--connect-cdp"), browserOwner: values.get("--browser-owner"), accountOwner: scenario.authorization.accountOwner });
50
+ else {
51
+ let context;
52
+ try { context = await chromium.launchPersistentContext(profile, { headless: false, chromiumSandbox: true, ...(record.channel === "chromium" ? {} : { channel: record.channel }) }); }
53
+ catch { throw new Error("headed-browser-unavailable"); }
54
+ session = { page: context.pages()[0] ?? await context.newPage(), close: () => context.close() };
55
+ }
41
56
  try {
42
- const page = context.pages()[0] ?? await context.newPage();
43
- await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded" });
57
+ const page = session.page;
58
+ if (!attached) await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded" });
44
59
  const runId = randomUUID();
45
60
  const declared = await scenario.createJourney({ page, runId });
46
61
  const evidence = await runNativeChatGptJourney({ ...declared, enabled: true, page, runId, authorization: scenario.authorization },
47
62
  resolve(values.get("--output") ?? "artifacts/native-chatgpt/evidence.json"));
48
63
  console.log(`Native ChatGPT journey ${evidence.status}. Evidence written privately.`);
49
64
  if (evidence.status !== "passed") process.exitCode = evidence.status === "failed" ? 1 : 2;
50
- } finally { await context.close(); }
65
+ } finally { await session.close(); }
51
66
  }
52
67
 
53
68
  main().catch((error) => {
54
- const categories = new Set(["explicit-opt-in-required", "invalid-run-options", "owned-scenario-required", "human-setup-required", "optional-playwright-dependency-required", "headed-browser-unavailable"]);
69
+ const categories = new Set(["explicit-opt-in-required", "invalid-run-options", "owned-scenario-required", "human-setup-required", "optional-playwright-dependency-required", "headed-browser-unavailable", "browser-owner-confirmation-required", "loopback-browser-endpoint-required", "owned-browser-attachment-unavailable"]);
55
70
  console.error(`Native ChatGPT journey blocked: ${categories.has(error.message) ? error.message : "journey-unavailable"}`);
56
71
  process.exitCode = 2;
57
72
  });
@@ -0,0 +1,83 @@
1
+ // The owner exposes this local browser interface; this module never launches,
2
+ // authenticates, exports storage, or bypasses a browser challenge.
3
+ export function validateBrowserAttachment(endpoint, browserOwner, accountOwner) {
4
+ if (typeof accountOwner !== "string" || !accountOwner.trim() || browserOwner !== accountOwner) throw new Error("browser-owner-confirmation-required");
5
+ if (typeof endpoint !== "string" || endpoint.length > 256 || !/^ws:\/\/127\.0\.0\.1:[0-9]+\/devtools\/browser(?:\/[A-Za-z0-9-]+)?$/.test(endpoint)) throw new Error("loopback-browser-endpoint-required");
6
+ let url;
7
+ try { url = new URL(endpoint); }
8
+ catch { throw new Error("loopback-browser-endpoint-required"); }
9
+ if (Number(url.port) < 1024 || Number(url.port) > 65535 || !url.port || url.pathname.length > 150) throw new Error("loopback-browser-endpoint-required");
10
+ return url.href;
11
+ }
12
+
13
+ export async function attachOwnedBrowser({ chromium, endpoint, browserOwner, accountOwner }) {
14
+ const address = validateBrowserAttachment(endpoint, browserOwner, accountOwner);
15
+ const transport = browserTransport(address);
16
+ let browser;
17
+ let page;
18
+ try {
19
+ // Supported Playwright transport seam: Node's WebSocket rejects redirects,
20
+ // while Playwright's URL connector follows them. Keep browser defaults.
21
+ transport.open();
22
+ browser = await chromium.connectOverCDP(transport, { timeout: 30_000, noDefaults: true });
23
+ const contexts = browser.contexts();
24
+ if (contexts.length !== 1) throw new Error("owned-browser-context-required");
25
+ // A new tab shares the owner's authorized session without selecting,
26
+ // navigating, closing, or retaining any pre-existing private tab.
27
+ page = await contexts[0].newPage();
28
+ await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded", timeout: 30_000 });
29
+ return { page, async close() {
30
+ try { await page.close({ runBeforeUnload: false }); }
31
+ finally { await browser.close(); } // CDP-connected close disconnects, not browser termination.
32
+ } };
33
+ } catch {
34
+ try { await page?.close({ runBeforeUnload: false }); } catch { /* redacted */ }
35
+ try { await browser?.close(); } catch { /* redacted */ }
36
+ transport.close();
37
+ throw new Error("owned-browser-attachment-unavailable");
38
+ }
39
+ }
40
+
41
+ // Only JSON framing for Playwright's public ConnectOverCDPTransport interface.
42
+ // Playwright owns all CDP/browser semantics. No messages are retained or logged.
43
+ function browserTransport(address) {
44
+ let socket;
45
+ let ended = false;
46
+ const pending = [];
47
+ let pendingBytes = 0;
48
+ const finish = () => {
49
+ if (ended) return;
50
+ ended = true;
51
+ pending.length = 0;
52
+ pendingBytes = 0;
53
+ transport.onclose?.("browser-connection-closed");
54
+ };
55
+ const transport = {
56
+ open() {
57
+ socket = new WebSocket(address);
58
+ socket.onopen = () => {
59
+ try { for (const message of pending) socket.send(message); pending.length = 0; pendingBytes = 0; }
60
+ catch { transport.close(); finish(); }
61
+ };
62
+ socket.onmessage = (event) => {
63
+ try {
64
+ if (typeof event.data !== "string" || Buffer.byteLength(event.data) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
65
+ transport.onmessage?.(JSON.parse(event.data));
66
+ } catch { transport.close(); finish(); }
67
+ };
68
+ socket.onerror = finish;
69
+ socket.onclose = finish;
70
+ },
71
+ send(message) {
72
+ const encoded = JSON.stringify(message);
73
+ if (ended || Buffer.byteLength(encoded) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
74
+ if (socket.readyState === WebSocket.CONNECTING) {
75
+ if (pending.length >= 64 || pendingBytes + Buffer.byteLength(encoded) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
76
+ pending.push(encoded);
77
+ pendingBytes += Buffer.byteLength(encoded);
78
+ } else socket.send(encoded);
79
+ },
80
+ close() { socket?.close(); },
81
+ };
82
+ return transport;
83
+ }
package/chatgpt/setup.mjs CHANGED
@@ -49,7 +49,7 @@ async function main() {
49
49
  }
50
50
  await chmod(profile, 0o700);
51
51
  let context;
52
- try { context = await chromium.launchPersistentContext(profile, { headless: false, ...(channel === "chromium" ? {} : { channel }) }); }
52
+ try { context = await chromium.launchPersistentContext(profile, { headless: false, chromiumSandbox: true, ...(channel === "chromium" ? {} : { channel }) }); }
53
53
  catch { throw new Error("headed-browser-unavailable"); }
54
54
  const terminal = createInterface({ input: process.stdin, output: process.stdout });
55
55
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,7 +58,9 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@emseepea/server": "0.23.1",
61
- "@modelcontextprotocol/client": "2.2.0"
61
+ "@modelcontextprotocol/client": "2.2.0",
62
+ "@modelcontextprotocol/server": "2.0.0",
63
+ "@modelcontextprotocol/core": "2.0.0"
62
64
  },
63
65
  "devDependencies": {
64
66
  "playwright": "1.62.1"
package/semantic/case.mjs CHANGED
@@ -9,6 +9,9 @@ export function validateConversationOptions(value) {
9
9
  if (value.context !== undefined && typeof value.context !== "string") {
10
10
  throw new Error("context must be text");
11
11
  }
12
+ if (value.nativeResources !== undefined && typeof value.nativeResources !== "boolean") {
13
+ throw new Error("nativeResources must be boolean");
14
+ }
12
15
  for (const key of ["authToken", "authTokenEnvironment"]) {
13
16
  if (value[key] !== undefined && (typeof value[key] !== "string" || !value[key].trim())) {
14
17
  throw new Error(`${key} must be non-empty text`);
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { resourceHelperOperation } from "./resources.mjs";
2
3
  import {
3
4
  canonicalToolNames,
4
5
  normalizeToolSelectionContract,
@@ -21,6 +22,7 @@ const sha256 = (value) => createHash("sha256").update(JSON.stringify(value)).dig
21
22
  export function validRecord(record, evidence) {
22
23
  const { authoritative, smoke } = evidence;
23
24
  const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
25
+ if (record.nativeResources !== undefined && typeof record.nativeResources !== "boolean") return false;
24
26
  if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
25
27
  || record.provider !== evidence.provider || record.configuredModel !== evidence.configuredModel
26
28
  || record.modelEvidence !== evidence.modelEvidence
@@ -56,13 +58,57 @@ export function validRecord(record, evidence) {
56
58
  && validNegativeFeedbackAssertion(turn)
57
59
  && validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
58
60
  && turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
59
- && Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
60
- && turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }, index) => {
61
- const call = turn.toolCalls[index];
62
- return method === "tools/call" && target === call.name
63
- && requestSha256 === sha256({ method, name: call.name, arguments: call.arguments })
64
- && responseSha256 === sha256(call.result);
65
- })));
61
+ && validPathEvidence(turn, record, trial, isHash)));
62
+ }
63
+
64
+ function validPathEvidence(turn, record, trial, isHash) {
65
+ const operations = turn.resourceOperations ?? [];
66
+ if (!Array.isArray(operations)) return false;
67
+ if (record.nativeResources !== true) {
68
+ if (operations.length) return false;
69
+ } else {
70
+ const catalogue = trial.resourceCatalogue;
71
+ if (!record.provider.startsWith("codex-") || !catalogue
72
+ || !Array.isArray(catalogue.resources) || !Array.isArray(catalogue.resourceTemplates)
73
+ || catalogue.resources.length > 4096 || catalogue.resourceTemplates.length > 4096
74
+ || !catalogue.resources.every(entry => entry && typeof entry.uri === "string" && entry.uri.trim())
75
+ || !catalogue.resourceTemplates.every(entry => entry && typeof entry.uriTemplate === "string" && entry.uriTemplate.trim())
76
+ || Buffer.byteLength(JSON.stringify(catalogue)) > 2 * 1_048_576
77
+ || !isHash(turn.resourceCatalogueSha256) || turn.resourceCatalogueSha256 !== sha256(catalogue)
78
+ || turn.answerProviderResourceOperationCount !== operations.length
79
+ || turn.answerProviderTurnCount !== turn.toolCallCount + operations.length + 1
80
+ || !isHash(turn.materialSha256) || turn.materialSha256 !== sha256(turn.pathEvidence)) return false;
81
+ const helpers = { "resources/read": "read_mcp_resource", "resources/list": "list_mcp_resources",
82
+ "resources/templates/list": "list_mcp_resource_templates" };
83
+ try {
84
+ for (const operation of operations) {
85
+ if (!operation || operation.source !== "provider-native-helper"
86
+ || Object.keys(operation).some(key => !["method", "target", "arguments", "result", "source"].includes(key))) return false;
87
+ const checked = resourceHelperOperation({ server: "emseepea_eval", status: "completed",
88
+ tool: helpers[operation.method], arguments: operation.arguments,
89
+ result: { content: [{ type: "text", text: JSON.stringify(operation.result) }], structured_content: null } }, catalogue);
90
+ if (!checked || checked.method !== operation.method || checked.target !== operation.target) return false;
91
+ }
92
+ } catch { return false; }
93
+ }
94
+ if (!Array.isArray(turn.pathEvidence) || turn.pathEvidence.length !== turn.toolCallCount + operations.length) return false;
95
+ let toolIndex = 0, resourceIndex = 0;
96
+ for (const path of turn.pathEvidence) {
97
+ if (!path || typeof path !== "object" || Array.isArray(path)) return false;
98
+ if (path.method === "tools/call") {
99
+ const call = turn.toolCalls[toolIndex++];
100
+ if (!call || path.target !== call.name
101
+ || path.requestSha256 !== sha256({ method: path.method, name: call.name, arguments: call.arguments })
102
+ || path.responseSha256 !== sha256(call.result)) return false;
103
+ } else {
104
+ const operation = operations[resourceIndex++];
105
+ if (!operation || path.source !== "provider-native-helper" || path.method !== operation.method
106
+ || path.target !== operation.target || path.requestSha256 !== sha256(operation.arguments)
107
+ || path.responseSha256 !== sha256(operation.result)) return false;
108
+ }
109
+ }
110
+ return toolIndex === turn.toolCallCount && resourceIndex === operations.length;
111
+
66
112
  }
67
113
 
68
114
  function validToolAssertions(turn, isHash) {
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
 
4
+ import { collectResourceCatalogue } from "./resources.mjs";
5
+
4
6
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
5
7
 
6
8
  const sha256 = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
@@ -115,6 +117,7 @@ export async function listMcpTools(url, testCase, signal) {
115
117
  signal?.addEventListener("abort", abort, { once: true });
116
118
  });
117
119
  try {
120
+ if (testCase.nativeResources && !client.getServerCapabilities()?.tools) return [];
118
121
  const response = await Promise.race([client.listTools(), cancelled]);
119
122
  const tools = response.tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
120
123
  tools.sort((left, right) => left.name.localeCompare(right.name));
@@ -128,6 +131,15 @@ export async function listMcpTools(url, testCase, signal) {
128
131
  }
129
132
  }
130
133
 
134
+ export async function listMcpResourceCatalogue(url, testCase, signal) {
135
+ signal?.throwIfAborted();
136
+ const client = await openClient(url, testCase);
137
+ const abort = () => { void client.close().catch(() => {}); };
138
+ signal?.addEventListener("abort", abort, { once: true });
139
+ try { return await collectResourceCatalogue(client, signal); }
140
+ finally { signal?.removeEventListener("abort", abort); await client.close(); }
141
+ }
142
+
131
143
  function requestFor(operation) {
132
144
  if (operation.method === "tools/call") {
133
145
  return { method: operation.method, name: operation.name, arguments: operation.arguments ?? {} };
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { assertResourceHelperCollisions, resourceHelperOperation } from "./resources.mjs";
5
6
  import { scriptedElicitations } from "./elicitation.mjs";
6
7
 
7
8
  export function mcpClientVersion(client) {
@@ -248,9 +249,9 @@ export function modelInvocation(provider, prompt, directory) {
248
249
  };
249
250
  }
250
251
 
251
- export function conversationInvocation(provider, directory, url, tools, authToken, context) {
252
+ export function conversationInvocation(provider, directory, url, tools, authToken, context, resourceCatalogue) {
252
253
  if (provider.startsWith("codex-")) {
253
- return codexInvocation(provider, undefined, directory, { url, tools, authToken, context });
254
+ return codexInvocation(provider, undefined, directory, { url, tools, authToken, context, resourceCatalogue });
254
255
  }
255
256
  const nativeTools = tools.map(({ name }) => nativeToolName(name));
256
257
  const config = nativeTools.length ? {
@@ -288,9 +289,9 @@ export function conversationInvocation(provider, directory, url, tools, authToke
288
289
  };
289
290
  }
290
291
 
291
- export function startModelConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets = []) {
292
+ export function startModelConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets = [], resourceCatalogue) {
292
293
  if (provider.startsWith("codex-")) {
293
- return startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets);
294
+ return startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets, resourceCatalogue);
294
295
  }
295
296
  signal?.throwIfAborted();
296
297
  const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
@@ -425,7 +426,7 @@ export async function runModel(provider, prompt, directory, signal) {
425
426
  }
426
427
 
427
428
  export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, processExitCode = 0,
428
- selectedModel = "test-model", secrets = [], providerFailureCategory) {
429
+ selectedModel = "test-model", secrets = [], providerFailureCategory, resourceCatalogue) {
429
430
  let events;
430
431
  try {
431
432
  events = typeof stdout === "string"
@@ -464,17 +465,23 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
464
465
  || events.filter(({ type }) => type === "turn.completed").length !== 1) {
465
466
  throw new Error("Model command returned an incomplete turn");
466
467
  }
468
+ if (resourceCatalogue) assertResourceHelperCollisions(advertisedTools);
467
469
  const advertised = new Set(advertisedTools.map(({ name }) => name));
468
470
  const completed = events.filter(({ type }) => type === "item.completed").map(({ item }) => item);
469
471
  const forbidden = completed.filter((item) => !["agent_message", "reasoning", "mcp_tool_call"].includes(item?.type));
470
472
  if (forbidden.length) throw forbiddenItemError(forbidden);
471
473
  const toolItems = completed.filter(({ type }) => type === "mcp_tool_call");
472
- const discoveryItems = toolItems.filter(({ server, tool }) =>
474
+ const resourceItems = resourceCatalogue ? toolItems.flatMap((item) => {
475
+ const operation = resourceHelperOperation(item, resourceCatalogue);
476
+ return operation ? [{ item, operation }] : [];
477
+ }) : [];
478
+ const resourceItemSet = new Set(resourceItems.map(({ item }) => item));
479
+ const discoveryItems = toolItems.filter((item) => !resourceItemSet.has(item)).filter(({ server, tool }) =>
473
480
  server === "codex" && codexEmptyDiscoveryTools.has(tool));
474
481
  if (discoveryItems.some((item) => !emptyCodexDiscovery(item))) {
475
482
  throw forbiddenItemError(discoveryItems.filter((item) => !emptyCodexDiscovery(item)));
476
483
  }
477
- const applicationToolItems = toolItems.filter((item) => !discoveryItems.includes(item));
484
+ const applicationToolItems = toolItems.filter((item) => !discoveryItems.includes(item) && !resourceItemSet.has(item));
478
485
  const calls = applicationToolItems.map((item) => {
479
486
  if (item.server !== mcpServerName || !advertised.has(item.tool)
480
487
  || !item.arguments || typeof item.arguments !== "object" || Array.isArray(item.arguments)) {
@@ -494,17 +501,24 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
494
501
  answer,
495
502
  calls,
496
503
  toolResults,
497
- pathEvidence: calls.map((call, index) => ({
498
- method: "tools/call",
499
- target: call.name,
500
- requestSha256: hash({ method: "tools/call", name: call.name, arguments: call.arguments }),
501
- responseSha256: hash(toolResults[index].content),
502
- })),
504
+ resourceOperations: resourceItems.map(({ operation }) => ({ ...operation, source: "provider-native-helper" })),
505
+ pathEvidence: toolItems.flatMap((item) => {
506
+ const native = resourceItems.find((entry) => entry.item === item)?.operation;
507
+ if (native) return [{ method: native.method, target: native.target, source: "provider-native-helper",
508
+ requestSha256: hash(native.arguments), responseSha256: hash(native.result) }];
509
+ const index = applicationToolItems.indexOf(item);
510
+ if (index < 0) return [];
511
+ const call = calls[index];
512
+ return [{ method: "tools/call", target: call.name,
513
+ requestSha256: hash({ method: "tools/call", name: call.name, arguments: call.arguments }),
514
+ responseSha256: hash(toolResults[index].content) }];
515
+ }),
503
516
  models: [selectedModel],
504
517
  modelEvidence: "configured",
505
518
  turnCount: 1,
506
- providerTurnCount: calls.length + 1,
519
+ providerTurnCount: calls.length + resourceItems.length + 1,
507
520
  providerToolCount: calls.length,
521
+ providerResourceOperationCount: resourceItems.length,
508
522
  threadId,
509
523
  };
510
524
  }
@@ -528,14 +542,14 @@ function emptyCodexDiscovery(item) {
528
542
  }
529
543
  }
530
544
 
531
- function startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets) {
545
+ function startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets, resourceCatalogue) {
532
546
  let threadId;
533
547
  let closed = false;
534
548
  return Object.freeze({
535
549
  async send(prompt, options) {
536
550
  if (closed) throw new Error("Model conversation is closed");
537
551
  if (options?.elicitations?.length) throw new Error("Codex provider does not support scripted elicitation");
538
- const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
552
+ const invocation = conversationInvocation(provider, directory, url, tools, authToken, context, resourceCatalogue);
539
553
  const args = threadId
540
554
  ? ["exec", "resume", threadId,
541
555
  ...withoutOption(withoutOption(invocation.args.slice(1), "--cd"), "--sandbox")]
@@ -550,7 +564,7 @@ function startCodexConversation(provider, directory, url, tools, authToken, cont
550
564
  if (execution.errorCode) throw new Error("Model conversation could not start");
551
565
  const turn = parseCodexEvents(execution.stdout, tools, threadId, execution.code,
552
566
  providerModel(provider), [...invocation.providerSecrets, authToken, ...serverSecrets],
553
- execution.providerFailureCategory);
567
+ execution.providerFailureCategory, resourceCatalogue);
554
568
  threadId = turn.threadId;
555
569
  return { ...turn, elicitations: [] };
556
570
  },
@@ -597,9 +611,9 @@ function codexInvocation(provider, prompt, directory, conversation) {
597
611
  "-c", "features.unified_exec=false",
598
612
  ];
599
613
  if (conversation?.context) args.push("-c", `developer_instructions=${JSON.stringify(conversation.context)}`);
600
- if (conversation?.tools.length) {
614
+ if (conversation && (conversation.tools.length || conversation.resourceCatalogue)) {
601
615
  args.push("-c", `mcp_servers.${mcpServerName}.url=${JSON.stringify(conversation.url)}`);
602
- args.push("-c", `mcp_servers.${mcpServerName}.enabled_tools=${JSON.stringify(conversation.tools.map(({ name }) => name))}`);
616
+ if (conversation.tools.length) args.push("-c", `mcp_servers.${mcpServerName}.enabled_tools=${JSON.stringify(conversation.tools.map(({ name }) => name))}`);
603
617
  args.push("-c", `mcp_servers.${mcpServerName}.default_tools_approval_mode="approve"`);
604
618
  for (const { name } of conversation.tools) {
605
619
  args.push("-c", `mcp_servers.${mcpServerName}.tools.${JSON.stringify(name)}.approval_mode="approve"`);
@@ -0,0 +1,98 @@
1
+ import { ReadResourceResultSchema } from "@modelcontextprotocol/core";
2
+ import { ResourceTemplate } from "@modelcontextprotocol/server";
3
+
4
+ export const nativeResourceHelpers = new Set([
5
+ "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource",
6
+ ]);
7
+ const server = "emseepea_eval";
8
+ const limit = 1_048_576;
9
+
10
+ // The inventory is obtained with the same authentication as the conversation.
11
+ // It bounds provider-reported helpers; it is not independent HTTP-hop evidence.
12
+ export async function collectResourceCatalogue(client, signal) {
13
+ signal?.throwIfAborted();
14
+ if (!client.getServerCapabilities()?.resources) return { resources: [], resourceTemplates: [] };
15
+ const collect = async (method, key) => {
16
+ const entries = [];
17
+ const cursors = new Set();
18
+ let cursor;
19
+ let bytes = 0;
20
+ for (let page = 0; page < 32; page += 1) {
21
+ signal?.throwIfAborted();
22
+ const response = await client[method](cursor === undefined ? {} : { cursor }, { signal });
23
+ bytes += Buffer.byteLength(JSON.stringify(response));
24
+ if (bytes > limit || !Array.isArray(response[key])) throw new Error("Resource catalogue exceeds its bounds");
25
+ entries.push(...response[key]);
26
+ if (entries.length > 4096) throw new Error("Resource catalogue exceeds its bounds");
27
+ if (response.nextCursor === undefined) return entries;
28
+ if (typeof response.nextCursor !== "string" || !response.nextCursor || cursors.has(response.nextCursor)) {
29
+ throw new Error("Resource catalogue returned an invalid cursor");
30
+ }
31
+ cursors.add(response.nextCursor);
32
+ cursor = response.nextCursor;
33
+ }
34
+ throw new Error("Resource catalogue exceeds its page bound");
35
+ };
36
+ const resources = await collect("listResources", "resources");
37
+ const resourceTemplates = await collect("listResourceTemplates", "resourceTemplates");
38
+ if (resources.some((entry) => typeof entry.uri !== "string")
39
+ || resourceTemplates.some((entry) => typeof entry.uriTemplate !== "string")) {
40
+ throw new Error("Resource catalogue contains invalid entries");
41
+ }
42
+ for (const entry of resourceTemplates) new ResourceTemplate(entry.uriTemplate, { list: undefined });
43
+ return { resources, resourceTemplates };
44
+ }
45
+
46
+ export function assertResourceHelperCollisions(tools) {
47
+ if (tools.some(({ name }) => nativeResourceHelpers.has(name))) {
48
+ throw new Error("Native resource helpers collide with an advertised tool");
49
+ }
50
+ }
51
+
52
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value);
53
+ const keys = (value, allowed) => Object.keys(value).every((key) => allowed.includes(key));
54
+
55
+ export function resourceHelperOperation(item, catalogue) {
56
+ if (!catalogue || !nativeResourceHelpers.has(item.tool)) return undefined;
57
+ if (![server, "codex"].includes(item.server) || item.status !== "completed"
58
+ || !object(item.arguments) || !keys(item.arguments, ["server", "uri"])) return undefined;
59
+ const reading = item.tool === "read_mcp_resource";
60
+ if (reading && (item.arguments.server !== server || typeof item.arguments.uri !== "string")) return undefined;
61
+ if (!reading && (item.arguments.uri !== undefined
62
+ || (item.arguments.server !== undefined && item.arguments.server !== server))) return undefined;
63
+ if (!object(item.result) || !keys(item.result, ["content", "structured_content"])
64
+ || item.result.structured_content !== null || !Array.isArray(item.result.content)
65
+ || item.result.content.length !== 1) return undefined;
66
+ const [content] = item.result.content;
67
+ if (!object(content) || content.type !== "text" || typeof content.text !== "string"
68
+ || !keys(content, ["type", "text"]) || Buffer.byteLength(content.text) > limit) return undefined;
69
+ let result;
70
+ try { result = JSON.parse(content.text); } catch { return undefined; }
71
+ if (!object(result)) return undefined;
72
+ if (reading) {
73
+ const uri = item.arguments.uri;
74
+ if (!keys(result, ["server", "uri", "contents"]) || result.server !== server || result.uri !== uri
75
+ || !Array.isArray(result.contents) || !knownUri(uri, catalogue)
76
+ || !ReadResourceResultSchema.safeParse({ contents: result.contents }).success
77
+ || result.contents.some((entry) => !object(entry)
78
+ || (typeof entry.text === "string") === (typeof entry.blob === "string"))) return undefined;
79
+ // Aggregate reads may return distinct content URIs. They are content from
80
+ // this registered request, not new catalogue entries or follow-up authority.
81
+ return { method: "resources/read", target: uri, arguments: item.arguments, result };
82
+ }
83
+ const key = item.tool === "list_mcp_resources" ? "resources" : "resourceTemplates";
84
+ const field = key === "resources" ? "uri" : "uriTemplate";
85
+ if (!keys(result, ["server", key]) || (result.server !== undefined && result.server !== server)
86
+ || !Array.isArray(result[key])) return undefined;
87
+ if (item.server === "codex" && item.arguments.server === undefined
88
+ && result.server === undefined && result[key].length === 0) return undefined;
89
+ if (result[key].some((entry) => !object(entry) || entry.server !== server
90
+ || !catalogue[key].some((known) => known[field] === entry[field]))) return undefined;
91
+ return { method: key === "resources" ? "resources/list" : "resources/templates/list",
92
+ target: server, arguments: item.arguments, result };
93
+ }
94
+
95
+ function knownUri(uri, catalogue) {
96
+ return catalogue.resources.some((entry) => entry.uri === uri)
97
+ || catalogue.resourceTemplates.some((entry) => new ResourceTemplate(entry.uriTemplate, { list: undefined }).uriTemplate.match(uri) !== null);
98
+ }
@@ -35,11 +35,22 @@ export interface ConversationOptions {
35
35
  context?: string;
36
36
  /** Server environment shared by all trials, or isolated values selected by trial number. */
37
37
  environment?: Record<string, string> | ((trial: number) => Record<string, string>);
38
+ /** Opt in to registered native resource-helper evidence. Currently Codex only. */
39
+ nativeResources?: boolean;
38
40
  authToken?: string;
39
41
  authTokenEnvironment?: string;
40
42
  }
41
43
 
44
+ export interface ResourceOperation {
45
+ readonly method: "resources/list" | "resources/templates/list" | "resources/read";
46
+ readonly target: string;
47
+ readonly source: "provider-native-helper";
48
+ readonly arguments: Readonly<Record<string, unknown>>;
49
+ readonly result: unknown;
50
+ }
51
+
42
52
  export interface ConversationTurn {
53
+ readonly resourceOperations: readonly (readonly ResourceOperation[])[];
43
54
  readonly responses: readonly string[];
44
55
  readonly toolCalls: readonly (readonly ToolCall[])[];
45
56
  readonly toolOutcomes: readonly (readonly ToolOutcome[])[];
package/semantic/test.mjs CHANGED
@@ -6,11 +6,13 @@ import { dirname, join, resolve } from "node:path";
6
6
  import { environmentForTrial, validateConversationOptions } from "./case.mjs";
7
7
  import {
8
8
  listMcpTools,
9
+ listMcpResourceCatalogue,
9
10
  semanticAuthToken,
10
11
  startSemanticServer,
11
12
  stopSemanticServer,
12
13
  } from "./material.mjs";
13
14
  import { parseJudgeVerdict, providerModel, providerSettings, runModel, startModelConversation } from "./provider.mjs";
15
+ import { assertResourceHelperCollisions } from "./resources.mjs";
14
16
  import {
15
17
  canonicalToolNames,
16
18
  normalizeToolSelectionContract,
@@ -46,6 +48,9 @@ export async function createConversation(testContext, options) {
46
48
 
47
49
  const provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
48
50
  if (!["claude-local", "claude-ci", "codex-local", "codex-ci"].includes(provider)) throw new Error("Unsupported model provider");
51
+ if (specification.nativeResources && !provider.startsWith("codex-")) {
52
+ throw new Error("nativeResources requires the Codex provider");
53
+ }
49
54
  const smoke = process.env.EMSEEPEA_EVAL_SMOKE === "1";
50
55
  if (smoke && provider === "claude-ci") throw new Error("Smoke tests cannot qualify a release");
51
56
  const file = process.env.EMSEEPEA_TEST_FILE;
@@ -57,6 +62,7 @@ export async function createConversation(testContext, options) {
57
62
  name,
58
63
  file,
59
64
  mode: "conversation",
65
+ ...(specification.nativeResources ? { nativeResources: true } : {}),
60
66
  authoritative: provider === "claude-ci",
61
67
  smoke,
62
68
  provider,
@@ -86,12 +92,15 @@ export async function createConversation(testContext, options) {
86
92
  }, testContext.signal);
87
93
  try {
88
94
  const tools = await listMcpTools(running.url, specification, testContext.signal);
89
- const record = { trial, turns: [] };
95
+ if (specification.nativeResources) assertResourceHelperCollisions(tools);
96
+ const resourceCatalogue = specification.nativeResources
97
+ ? await listMcpResourceCatalogue(running.url, specification, testContext.signal) : undefined;
98
+ const record = { trial, turns: [], ...(resourceCatalogue ? { resourceCatalogue } : {}) };
90
99
  const directory = await mkdtemp(join(tmpdir(), "emseepea-conversation-"));
91
100
  const serverSecrets = Object.entries(environment ?? {})
92
101
  .filter(([key]) => /token|password|secret|credential|api.?key/i.test(key))
93
102
  .map(([, value]) => value);
94
- state.trials.push({ running, tools, record, directory, history: [], model: undefined, serverSecrets, conversation: 1 });
103
+ state.trials.push({ running, tools, resourceCatalogue, record, directory, history: [], model: undefined, serverSecrets, conversation: 1 });
95
104
  evidence.answerTrials.push(record);
96
105
  } catch (error) {
97
106
  await stopSemanticServer(running.child);
@@ -118,6 +127,7 @@ export async function createConversation(testContext, options) {
118
127
  specification.context,
119
128
  testContext.signal,
120
129
  trial.serverSecrets,
130
+ trial.resourceCatalogue,
121
131
  );
122
132
  const answer = await trial.model.send(prompt, options);
123
133
  const calls = answer.calls;
@@ -129,6 +139,7 @@ export async function createConversation(testContext, options) {
129
139
  trial.history.push({
130
140
  user: prompt,
131
141
  toolCalls: recordedToolCalls,
142
+ resourceOperations: answer.resourceOperations ?? [],
132
143
  assistant: answer.answer,
133
144
  });
134
145
  const record = {
@@ -139,6 +150,9 @@ export async function createConversation(testContext, options) {
139
150
  response: answer.answer,
140
151
  elicitations: answer.elicitations,
141
152
  toolCalls: recordedToolCalls,
153
+ resourceOperations: answer.resourceOperations ?? [],
154
+ resourceCatalogueSha256: trial.resourceCatalogue ? hash(JSON.stringify(trial.resourceCatalogue)) : undefined,
155
+ ...(trial.resourceCatalogue ? { answerProviderResourceOperationCount: answer.providerResourceOperationCount } : {}),
142
156
  toolOutcomesSha256: hash(JSON.stringify(recordedToolCalls)),
143
157
  promptSha256: hash(prompt),
144
158
  answerSha256: hash(answer.answer),
@@ -195,6 +209,7 @@ export async function createConversation(testContext, options) {
195
209
  toolOutcomes: Object.freeze(trials.map(({ record }) => immutableSnapshot(record.toolCalls.map(
196
210
  ({ name, arguments: args, result, isError }) => ({ name, arguments: args, result, isError }),
197
211
  )))),
212
+ resourceOperations: Object.freeze(trials.map(({ record }) => immutableSnapshot(record.resourceOperations))),
198
213
  elicitations: Object.freeze(trials.map(({ record }) => Object.freeze(record.elicitations))),
199
214
  [privateTurn]: trials,
200
215
  });