@emseepea/testing 0.25.0 → 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
@@ -670,6 +670,63 @@ The command verifies a
670
670
  signed-in control, not the account's identity; the human confirms that identity.
671
671
  If that control changes, pass an account-specific `--authenticated-selector`.
672
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
+
673
730
  Connect your authorized HTTPS MCP endpoint through ChatGPT's current plugin
674
731
  installation flow. Complete account consent yourself. A local server requires
675
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.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {