@emseepea/testing 0.23.1 → 0.23.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/README.md CHANGED
@@ -109,12 +109,39 @@ EMSEEPEA_CODEX_MODEL=gpt-5.5 emseepea-test --provider codex-local eval
109
109
 
110
110
  `codex-local` uses the existing Codex CLI sign-in without copying it.
111
111
  `codex-ci` requires `OPENAI_API_KEY` from the CI secret store. Both providers
112
- run `codex exec --json`, resume the actual Codex session for follow-ups, ignore
113
- user configuration, and pre-approve only the discovered tools from the test
114
- server.
112
+ support Codex CLI 0.162.0 or later, run `codex exec --json`, resume the actual
113
+ Codex session for follow-ups, ignore user configuration, and pre-approve only
114
+ the discovered tools from the test server.
115
115
 
116
116
  An isolated command policy denies shell launches and spawned shell environments
117
- inherit no provider credentials.
117
+ inherit no provider credentials. The runner also disables Codex account apps,
118
+ plugins, remote plugins, multi-agent execution, web search, shell tools, and
119
+ unified execution for each one-shot and resumed native invocation.
120
+
121
+ The invocation configuration sets the default account-app state to disabled,
122
+ omits account-app instructions, and disables Codex app, plugin, and tool
123
+ suggestion features.
124
+
125
+ Native-client events are authoritative for the tools that were actually used:
126
+ a turn is accepted only when every application MCP call belongs to the
127
+ configured test server and its expected tools.
128
+
129
+ Codex may make its two built-in resource-discovery calls through the CLI's
130
+ `codex` server. The runner accepts them only when their native results prove the
131
+ resource and template inventories are empty, and excludes them from application
132
+ assertions and raw evidence.
133
+
134
+ Any non-empty, unrecognised, account-app, plugin, or other foreign call is
135
+ rejected without storing raw account metadata.
136
+
137
+ Failed Codex runs retain only a fixed failure category: authentication,
138
+ configuration, host storage, permission or sandbox, provider, transport, or
139
+ unknown. The runner inspects at most 32 KiB of standard error to derive that
140
+ category and does not store the standard error.
141
+
142
+ Rejected native items retain only capped counts from a fixed item-type list.
143
+ Commands, arguments, results, paths, server and tool names, and unknown
144
+ item-type strings are excluded.
118
145
 
119
146
  Codex CLI evidence proves that CLI journey. It does not prove native ChatGPT
120
147
  connection, attachment, retrieval, or MCP App rendering behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.23.1",
3
+ "version": "0.23.3",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,6 +6,11 @@ import { scriptedElicitations } from "./elicitation.mjs";
6
6
 
7
7
  const claudeModel = "claude-sonnet-4-6";
8
8
  const mcpServerName = "emseepea_eval";
9
+ const minimumCodexCliVersion = "0.162.0";
10
+ const codexEmptyDiscoveryTools = new Map([
11
+ ["list_mcp_resources", "resources"],
12
+ ["list_mcp_resource_templates", "resourceTemplates"],
13
+ ]);
9
14
  const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
10
15
  const providerErrorMessages = new Map([
11
16
  ["error_during_execution", "Model command failed during execution"],
@@ -13,6 +18,17 @@ const providerErrorMessages = new Map([
13
18
  ["error_max_turns", "Model command exceeded its turn limit"],
14
19
  ]);
15
20
  const authenticationFailure = /not logged in|failed to authenticate|oauth session expired/i;
21
+ const codexFailureCategories = new Set([
22
+ "authentication",
23
+ "configuration",
24
+ "host-storage",
25
+ "permission-or-sandbox",
26
+ "provider",
27
+ "transport",
28
+ "unknown",
29
+ ]);
30
+ const stderrLimit = 32 * 1024;
31
+ const forbiddenItemLimit = 64;
16
32
  const hasAuthenticationFailure = (events) => events.some((event) => (
17
33
  (Array.isArray(event.message?.content)
18
34
  && event.message.content.some(({ type, text }) => type === "text" && /not logged in/i.test(text ?? "")))
@@ -28,6 +44,9 @@ export async function modelVersion(provider = "claude-local") {
28
44
  const result = await runProcess(command, ["--version"], { env: modelEnvironment({}) });
29
45
  const version = result.stdout.trim().match(/(?:codex-cli\s+)?(\d+\.\d+\.\d+)\b/)?.[1];
30
46
  if (result.code !== 0 || !version) throw new Error(`Could not verify ${codex ? "Codex" : "Claude"} CLI version`);
47
+ if (codex && compareVersions(version, minimumCodexCliVersion) < 0) {
48
+ throw new Error(`Codex CLI ${version} is unsupported; use ${minimumCodexCliVersion} or later`);
49
+ }
31
50
  return version;
32
51
  }
33
52
 
@@ -42,11 +61,16 @@ export function providerSettings(provider) {
42
61
  return provider.startsWith("codex-")
43
62
  ? {
44
63
  approvalPolicy: "never",
64
+ appInstructions: false,
65
+ apps: false,
45
66
  loginShell: false,
46
67
  multiAgent: false,
68
+ plugins: false,
69
+ remotePlugin: false,
47
70
  sandbox: "read-only",
48
71
  shell: "forbidden",
49
72
  shellEnvironment: "empty",
73
+ toolSuggest: false,
50
74
  userConfig: "ignored",
51
75
  webSearch: false,
52
76
  }
@@ -388,14 +412,17 @@ export async function runModel(provider, prompt, directory, signal) {
388
412
  if (execution.outputLimitExceeded) throw new Error("Model command output exceeded its limit");
389
413
  if (execution.errorCode === "ABORT_ERR") throw new Error("Model command was cancelled");
390
414
  if (execution.errorCode) throw new Error("Model command could not start");
391
- if (execution.code !== 0 && !execution.stdout) throw new Error(`Model command exited ${execution.code}`);
415
+ if (execution.code !== 0 && !execution.stdout) {
416
+ throw codexProcessError(execution.code, execution.providerFailureCategory);
417
+ }
392
418
  return provider.startsWith("codex-")
393
419
  ? parseCodexEvents(execution.stdout, [], undefined, execution.code,
394
- providerModel(provider), invocation.providerSecrets)
420
+ providerModel(provider), invocation.providerSecrets, execution.providerFailureCategory)
395
421
  : parseClaudeEvents(execution.stdout, execution.code);
396
422
  }
397
423
 
398
- export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, processExitCode = 0, selectedModel = "test-model", secrets = []) {
424
+ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, processExitCode = 0,
425
+ selectedModel = "test-model", secrets = [], providerFailureCategory) {
399
426
  let events;
400
427
  try {
401
428
  events = typeof stdout === "string"
@@ -412,11 +439,13 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
412
439
  const authentication = /(?:not logged in|authentication|unauthorized|invalid api key|oauth)/i;
413
440
  const errorText = events.flatMap((event) => [event.message, event.error?.message, event.item?.message])
414
441
  .filter((value) => typeof value === "string").join("\n");
415
- if (authentication.test(errorText)) throw new Error("Model command is not signed in");
442
+ if (authentication.test(errorText)) {
443
+ throw codexEvidenceError("Model command is not signed in", "authentication");
444
+ }
416
445
  const failedTool = events.some(({ item }) => item?.type === "mcp_tool_call"
417
446
  && (item.status === "failed" || item.result?.isError === true || item.result?.is_error === true));
418
447
  if (failedTool) throw new Error("Model command reported a failed MCP tool call");
419
- if (processExitCode !== 0) throw new Error(`Model command exited ${processExitCode}`);
448
+ if (processExitCode !== 0) throw codexProcessError(processExitCode, providerFailureCategory);
420
449
  if (events.some(({ type }) => type === "turn.failed" || type === "error")) {
421
450
  throw new Error("Model command reported an error");
422
451
  }
@@ -435,12 +464,18 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
435
464
  const advertised = new Set(advertisedTools.map(({ name }) => name));
436
465
  const completed = events.filter(({ type }) => type === "item.completed").map(({ item }) => item);
437
466
  const forbidden = completed.filter((item) => !["agent_message", "reasoning", "mcp_tool_call"].includes(item?.type));
438
- if (forbidden.length) throw new Error("Model command used a forbidden tool");
467
+ if (forbidden.length) throw forbiddenItemError(forbidden);
439
468
  const toolItems = completed.filter(({ type }) => type === "mcp_tool_call");
440
- const calls = toolItems.map((item) => {
469
+ const discoveryItems = toolItems.filter(({ server, tool }) =>
470
+ server === "codex" && codexEmptyDiscoveryTools.has(tool));
471
+ if (discoveryItems.some((item) => !emptyCodexDiscovery(item))) {
472
+ throw forbiddenItemError(discoveryItems.filter((item) => !emptyCodexDiscovery(item)));
473
+ }
474
+ const applicationToolItems = toolItems.filter((item) => !discoveryItems.includes(item));
475
+ const calls = applicationToolItems.map((item) => {
441
476
  if (item.server !== mcpServerName || !advertised.has(item.tool)
442
477
  || !item.arguments || typeof item.arguments !== "object" || Array.isArray(item.arguments)) {
443
- throw new Error("Model command used a forbidden tool");
478
+ throw forbiddenItemError([item]);
444
479
  }
445
480
  if (!item.result || typeof item.result !== "object" || item.status === "failed"
446
481
  || item.result.isError === true || item.result.is_error === true) {
@@ -451,7 +486,7 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
451
486
  const messages = completed.filter(({ type }) => type === "agent_message");
452
487
  const answer = messages.at(-1)?.text;
453
488
  if (typeof answer !== "string") throw new Error("Model command returned no answer");
454
- const toolResults = toolItems.map(({ result }) => ({ content: result.content ?? result, isError: false }));
489
+ const toolResults = applicationToolItems.map(({ result }) => ({ content: result.content ?? result, isError: false }));
455
490
  return {
456
491
  answer,
457
492
  calls,
@@ -471,6 +506,25 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
471
506
  };
472
507
  }
473
508
 
509
+ function emptyCodexDiscovery(item) {
510
+ if (item.status !== "completed" || !item.arguments || typeof item.arguments !== "object"
511
+ || Array.isArray(item.arguments) || Object.keys(item.arguments).length !== 0
512
+ || !item.result || typeof item.result !== "object" || Array.isArray(item.result)
513
+ || item.result.structured_content !== null || Object.keys(item.result).length !== 2
514
+ || !Array.isArray(item.result.content) || item.result.content.length !== 1) return false;
515
+ const [content] = item.result.content;
516
+ if (!content || content.type !== "text" || typeof content.text !== "string"
517
+ || Object.keys(content).length !== 2) return false;
518
+ try {
519
+ const parsed = JSON.parse(content.text);
520
+ const key = codexEmptyDiscoveryTools.get(item.tool);
521
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
522
+ && Object.keys(parsed).length === 1 && Array.isArray(parsed[key]) && parsed[key].length === 0;
523
+ } catch {
524
+ return false;
525
+ }
526
+ }
527
+
474
528
  function startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets) {
475
529
  let threadId;
476
530
  let closed = false;
@@ -492,7 +546,8 @@ function startCodexConversation(provider, directory, url, tools, authToken, cont
492
546
  if (execution.errorCode === "ABORT_ERR") throw new Error("Model command was cancelled");
493
547
  if (execution.errorCode) throw new Error("Model conversation could not start");
494
548
  const turn = parseCodexEvents(execution.stdout, tools, threadId, execution.code,
495
- providerModel(provider), [...invocation.providerSecrets, authToken, ...serverSecrets]);
549
+ providerModel(provider), [...invocation.providerSecrets, authToken, ...serverSecrets],
550
+ execution.providerFailureCategory);
496
551
  threadId = turn.threadId;
497
552
  return { ...turn, elicitations: [] };
498
553
  },
@@ -528,8 +583,14 @@ function codexInvocation(provider, prompt, directory, conversation) {
528
583
  "-c", 'web_search="disabled"',
529
584
  "-c", 'shell_environment_policy.inherit="none"',
530
585
  "-c", "allow_login_shell=false",
586
+ "-c", "apps._default.enabled=false",
587
+ "-c", "include_apps_instructions=false",
588
+ "-c", "features.apps=false",
531
589
  "-c", "features.multi_agent=false",
590
+ "-c", "features.plugins=false",
591
+ "-c", "features.remote_plugin=false",
532
592
  "-c", "features.shell_tool=false",
593
+ "-c", "features.tool_suggest=false",
533
594
  "-c", "features.unified_exec=false",
534
595
  ];
535
596
  if (conversation?.context) args.push("-c", `developer_instructions=${JSON.stringify(conversation.context)}`);
@@ -559,8 +620,10 @@ function runProcess(command, args, options) {
559
620
  return new Promise((resolve) => {
560
621
  const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], ...options });
561
622
  let stdout = "";
623
+ let stderr = Buffer.alloc(0);
562
624
  let timedOut = false;
563
625
  let outputLimitExceeded = false;
626
+ let settled = false;
564
627
  const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 180_000);
565
628
  timer.unref();
566
629
  child.stdout.on("data", (chunk) => {
@@ -570,18 +633,98 @@ function runProcess(command, args, options) {
570
633
  child.kill("SIGKILL");
571
634
  }
572
635
  });
573
- child.stderr.resume();
574
- child.once("error", (error) => {
636
+ child.stderr.on("data", (chunk) => {
637
+ if (stderr.length >= stderrLimit) return;
638
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
639
+ stderr = Buffer.concat([stderr, bytes.subarray(0, stderrLimit - stderr.length)]);
640
+ });
641
+ const finish = (result) => {
642
+ if (settled) return;
643
+ settled = true;
575
644
  clearTimeout(timer);
576
- resolve({ code: 1, errorCode: error.code, outputLimitExceeded, stdout, timedOut });
645
+ const providerFailureCategory = classifyCodexFailure(stderr.toString("utf8"));
646
+ stderr = Buffer.alloc(0);
647
+ resolve({ ...result, providerFailureCategory });
648
+ };
649
+ child.once("error", (error) => {
650
+ finish({ code: 1, errorCode: error.code, outputLimitExceeded, stdout, timedOut });
577
651
  });
578
652
  child.once("close", (code) => {
579
- clearTimeout(timer);
580
- resolve({ code: code ?? 1, outputLimitExceeded, stdout, timedOut });
653
+ finish({ code: code ?? 1, outputLimitExceeded, stdout, timedOut });
581
654
  });
582
655
  });
583
656
  }
584
657
 
658
+ function classifyCodexFailure(stderr) {
659
+ if (/no space left|disk (?:is )?full|storage (?:capacity|quota)|\benospc\b/i.test(stderr)) {
660
+ return "host-storage";
661
+ }
662
+ if (/not logged in|authenticat|unauthorized|invalid api key|oauth/i.test(stderr)) {
663
+ return "authentication";
664
+ }
665
+ if (/permission denied|operation not permitted|sandbox|\beacces\b|\beperm\b/i.test(stderr)) {
666
+ return "permission-or-sandbox";
667
+ }
668
+ if (/configuration|config(?:uration)? error|unknown (?:feature|field|key)|invalid (?:config|value)/i.test(stderr)) {
669
+ return "configuration";
670
+ }
671
+ if (/connection (?:refused|reset)|network|transport|timed? out|\btls\b|certificate|\bdns\b|\beconn/i.test(stderr)) {
672
+ return "transport";
673
+ }
674
+ if (/provider (?:error|failure|unavailable)|model (?:is )?(?:unavailable|overloaded)|rate limit|service unavailable/i.test(stderr)) {
675
+ return "provider";
676
+ }
677
+ return "unknown";
678
+ }
679
+
680
+ function compareVersions(left, right) {
681
+ const a = left.split(".").map(Number);
682
+ const b = right.split(".").map(Number);
683
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
684
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
685
+ if (difference !== 0) return difference;
686
+ }
687
+ return 0;
688
+ }
689
+
690
+ function codexEvidenceError(message, category) {
691
+ return Object.assign(new Error(message), {
692
+ providerFailureCategory: codexFailureCategories.has(category) ? category : "unknown",
693
+ });
694
+ }
695
+
696
+ function codexProcessError(code, category) {
697
+ return codexEvidenceError(`Model command exited ${code}`, category);
698
+ }
699
+
700
+ function forbiddenItemError(items) {
701
+ const counts = {
702
+ commandExecution: 0,
703
+ computerUse: 0,
704
+ fileChange: 0,
705
+ mcpToolCall: 0,
706
+ other: 0,
707
+ webSearch: 0,
708
+ };
709
+ for (const item of items.slice(0, forbiddenItemLimit)) {
710
+ const key = new Map([
711
+ ["command_execution", "commandExecution"],
712
+ ["computer_tool", "computerUse"],
713
+ ["file_change", "fileChange"],
714
+ ["mcp_tool_call", "mcpToolCall"],
715
+ ["web_search", "webSearch"],
716
+ ]).get(item?.type) ?? "other";
717
+ counts[key] += 1;
718
+ }
719
+ return Object.assign(new Error("Model command used a forbidden tool"), {
720
+ forbiddenItemEvidence: Object.freeze({
721
+ counts: Object.freeze(counts),
722
+ inspectedCount: Math.min(items.length, forbiddenItemLimit),
723
+ truncated: items.length > forbiddenItemLimit,
724
+ }),
725
+ });
726
+ }
727
+
585
728
  function modelEnvironment(extra) {
586
729
  return Object.fromEntries(Object.entries({
587
730
  CI: "true",
package/semantic/test.mjs CHANGED
@@ -178,7 +178,7 @@ export async function createConversation(testContext, options) {
178
178
  return;
179
179
  }
180
180
  const trial = state.trials[index];
181
- trial.record.error = safeModelFailure(result.reason);
181
+ recordModelFailure(trial.record, result.reason);
182
182
  if (Array.isArray(result.reason?.elicitations)) trial.record.elicitations = result.reason.elicitations;
183
183
  if (Array.isArray(result.reason?.attemptedToolCalls)) {
184
184
  trial.record.attemptedToolCalls = result.reason.attemptedToolCalls;
@@ -508,9 +508,11 @@ async function judgeTrialMeaning(trial, trialIndex, expected) {
508
508
  record.verdict = verdict;
509
509
  outcomes.push({ failed: !verdict.pass, record });
510
510
  } catch (error) {
511
- record.error = error instanceof SyntaxError || error.message === "Judge returned an invalid verdict"
512
- ? "invalid judge verdict"
513
- : safeModelFailure(error);
511
+ if (error instanceof SyntaxError || error.message === "Judge returned an invalid verdict") {
512
+ record.error = "invalid judge verdict";
513
+ } else {
514
+ recordModelFailure(record, error);
515
+ }
514
516
  outcomes.push({ failed: true, record });
515
517
  }
516
518
  }
@@ -570,6 +572,24 @@ function safeModelFailure(error) {
570
572
  return "model invocation failed";
571
573
  }
572
574
 
575
+ function recordModelFailure(record, error) {
576
+ record.error = safeModelFailure(error);
577
+ const category = error?.providerFailureCategory;
578
+ if ([
579
+ "authentication",
580
+ "configuration",
581
+ "host-storage",
582
+ "permission-or-sandbox",
583
+ "provider",
584
+ "transport",
585
+ "unknown",
586
+ ].includes(category)) record.providerFailureCategory = category;
587
+ const evidence = error?.forbiddenItemEvidence;
588
+ if (evidence && typeof evidence === "object") {
589
+ record.forbiddenItemEvidence = structuredClone(evidence);
590
+ }
591
+ }
592
+
573
593
  async function isolatedModel(provider, prompt, prefix, signal) {
574
594
  const directory = await mkdtemp(join(tmpdir(), prefix));
575
595
  try {