From 48aec79457c39d863f76ea961d336ea1a42007f6 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 28 Jul 2026 18:00:00 +0530 Subject: [PATCH 01/60] feat(browser-run): add sandboxed Playwright execution engine --- NOTICE | 4 + bun.lock | 115 ++- package-lock.json | 98 ++- package.json | 2 + src/browser/run/artifacts.ts | 68 ++ src/browser/run/bridge.test.ts | 171 ++++ src/browser/run/bridge.ts | 901 +++++++++++++++++++++ src/browser/run/index.ts | 7 + src/browser/run/input.test.ts | 82 ++ src/browser/run/input.ts | 79 ++ src/browser/run/network.test.ts | 205 +++++ src/browser/run/network.ts | 97 +++ src/browser/run/observation.test.ts | 54 ++ src/browser/run/observation.ts | 96 +++ src/browser/run/playwright-surface.test.ts | 750 +++++++++++++++++ src/browser/run/quickjs-host.test.ts | 102 +++ src/browser/run/quickjs-host.ts | 629 ++++++++++++++ src/browser/run/runner.test.ts | 423 ++++++++++ src/browser/run/runner.ts | 354 ++++++++ src/browser/run/sandbox-client.ts | 782 ++++++++++++++++++ src/browser/run/types.ts | 82 ++ src/observation/redaction.test.ts | 5 + src/observation/redaction.ts | 4 +- 23 files changed, 5078 insertions(+), 32 deletions(-) create mode 100644 src/browser/run/artifacts.ts create mode 100644 src/browser/run/bridge.test.ts create mode 100644 src/browser/run/bridge.ts create mode 100644 src/browser/run/index.ts create mode 100644 src/browser/run/input.test.ts create mode 100644 src/browser/run/input.ts create mode 100644 src/browser/run/network.test.ts create mode 100644 src/browser/run/network.ts create mode 100644 src/browser/run/observation.test.ts create mode 100644 src/browser/run/observation.ts create mode 100644 src/browser/run/playwright-surface.test.ts create mode 100644 src/browser/run/quickjs-host.test.ts create mode 100644 src/browser/run/quickjs-host.ts create mode 100644 src/browser/run/runner.test.ts create mode 100644 src/browser/run/runner.ts create mode 100644 src/browser/run/sandbox-client.ts create mode 100644 src/browser/run/types.ts diff --git a/NOTICE b/NOTICE index 12a00168..9bdc302d 100644 --- a/NOTICE +++ b/NOTICE @@ -1 +1,5 @@ webcmd is based on opencli (https://github.com/jackwener/opencli), Copyright 2025 jackwener, licensed under Apache-2.0. + +The browser-run QuickJS lifecycle is derived from dev-browser +(https://github.com/SawyerHood/dev-browser), Copyright Sawyer Hood, +licensed under the MIT License. diff --git a/bun.lock b/bun.lock index 9c78574b..713ba223 100644 --- a/bun.lock +++ b/bun.lock @@ -7,14 +7,19 @@ "dependencies": { "@mozilla/readability": "^0.6.0", "cli-table3": "^0.6.5", + "cloakbrowser": "0.4.5", "commander": "^14.0.3", "js-yaml": "^4.3.0", + "playwright-core": "1.61.1", + "quickjs-emscripten": "0.32.0", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", "undici": "^6.27.0", "ws": "^8.18.0", }, "devDependencies": { + "@emnapi/runtime": "^1.11.2", + "@google/genai": "^2.10.0", "@types/js-yaml": "^4.0.9", "@types/jsdom": "^27.0.0", "@types/node": "^25.5.2", @@ -57,7 +62,7 @@ "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], @@ -115,6 +120,20 @@ "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@google/genai": ["@google/genai@2.13.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-GM7C8Kaomvjz05x5JEO6+l3d/pciL9LxAG9dUjJLD7nTPZ9X0Cfsf2Z7eET6UjgWyUmxXCHtYnQoQ77F9+ZIOQ=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.32.0", "", {}, "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg=="], + + "@jitl/quickjs-wasmfile-debug-asyncify": ["@jitl/quickjs-wasmfile-debug-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw=="], + + "@jitl/quickjs-wasmfile-debug-sync": ["@jitl/quickjs-wasmfile-debug-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q=="], + + "@jitl/quickjs-wasmfile-release-asyncify": ["@jitl/quickjs-wasmfile-release-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q=="], + + "@jitl/quickjs-wasmfile-release-sync": ["@jitl/quickjs-wasmfile-release-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -125,6 +144,24 @@ "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="], @@ -173,6 +210,8 @@ "@types/node": ["@types/node@25.9.4", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="], @@ -193,30 +232,48 @@ "@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "cloakbrowser": ["cloakbrowser@0.4.5", "", { "dependencies": { "tar": "^7.0.0" }, "peerDependencies": { "mmdb-lib": ">=2.0.0", "playwright-core": ">=1.53.0", "puppeteer-core": ">=21.0.0", "socks-proxy-agent": ">=10.0.0" }, "optionalPeers": ["mmdb-lib", "playwright-core", "puppeteer-core", "socks-proxy-agent"], "bin": { "cloakbrowser": "dist/cli.js" } }, "sha512-FLEOoznA/d4SbUT1zi8BiMqH+xt/eCoCWeLHnEC7Wn1WBGR31QHSh93PSfS/WcovGaxQxxOQPKtF8+1IkdEp1g=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -229,14 +286,30 @@ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "gaxios": ["gaxios@7.3.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], @@ -245,6 +318,12 @@ "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -269,16 +348,30 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -287,16 +380,28 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "quickjs-emscripten": ["quickjs-emscripten@0.32.0", "", { "dependencies": { "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-release-sync": "0.32.0", "quickjs-emscripten-core": "0.32.0" } }, "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA=="], + + "quickjs-emscripten-core": ["quickjs-emscripten-core@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": "bin/cli.mjs" }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -313,6 +418,8 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], @@ -349,6 +456,8 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], @@ -363,6 +472,10 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "jsdom/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], diff --git a/package-lock.json b/package-lock.json index c57b769c..13d2eb3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "commander": "^14.0.3", "js-yaml": "^4.3.0", "playwright-core": "1.61.1", + "quickjs-emscripten": "0.32.0", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", "undici": "^6.27.0", @@ -203,7 +204,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -252,41 +252,16 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -799,6 +774,48 @@ "node": ">=18.0.0" } }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2508,7 +2525,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2521,7 +2537,6 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -2592,6 +2607,31 @@ "node": ">=6" } }, + "node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2863,7 +2903,6 @@ "integrity": "sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -2928,7 +2967,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/package.json b/package.json index 5abd002d..ab1157f7 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "./browser/ax-snapshot": "./dist/src/browser/ax-snapshot.js", "./browser/cdp": "./dist/src/browser/cdp.js", "./browser/page": "./dist/src/browser/page.js", + "./browser/run": "./dist/src/browser/run/index.js", "./browser/utils": "./dist/src/browser/utils.js", "./download": "./dist/src/download/index.js", "./download/article-download": "./dist/src/download/article-download.js", @@ -93,6 +94,7 @@ "commander": "^14.0.3", "js-yaml": "^4.3.0", "playwright-core": "1.61.1", + "quickjs-emscripten": "0.32.0", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", "undici": "^6.27.0", diff --git a/src/browser/run/artifacts.ts b/src/browser/run/artifacts.ts new file mode 100644 index 00000000..023d9db2 --- /dev/null +++ b/src/browser/run/artifacts.ts @@ -0,0 +1,68 @@ +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { + Locator as PlaywrightLocator, + Page as PlaywrightPage, +} from 'playwright-core'; +import type { BrowserRunScreenshotReceipt } from './types.js'; + +export interface BrowserRunArtifactWriterOptions { + baseDir?: string; +} + +function defaultArtifactBaseDir(): string { + const cacheDir = process.env.WEBCMD_CACHE_DIR + || path.join(os.homedir(), '.webcmd', 'cache'); + return path.join(cacheDir, 'browser-run'); +} + +function screenshotType(options: Record): 'png' | 'jpeg' { + return options.type === 'jpeg' ? 'jpeg' : 'png'; +} + +export class BrowserRunArtifactWriter { + readonly #baseDir: string; + + constructor(options: BrowserRunArtifactWriterOptions = {}) { + this.#baseDir = path.resolve(options.baseDir ?? defaultArtifactBaseDir()); + } + + async writeScreenshot( + target: Pick | Pick, + input: unknown, + ): Promise { + const requested = ( + typeof input === 'object' + && input !== null + && !Array.isArray(input) + ) + ? input as Record + : {}; + const { path: _ignoredPath, ...safeOptions } = requested; + const type = screenshotType(safeOptions); + const artifactId = `shot_${randomBytes(12).toString('hex')}`; + const filename = `${artifactId}.${type === 'jpeg' ? 'jpg' : 'png'}`; + const runDir = path.join(this.#baseDir, artifactId); + const outputPath = path.join(runDir, filename); + + await fs.mkdir(runDir, { recursive: true, mode: 0o700 }); + const bytes = await target.screenshot({ + ...safeOptions, + type, + path: undefined, + } as never); + const buffer = Buffer.from(bytes); + await fs.writeFile(outputPath, buffer, { mode: 0o600 }); + + return { + kind: 'screenshot', + artifactId, + filename, + contentType: type === 'jpeg' ? 'image/jpeg' : 'image/png', + byteSize: buffer.byteLength, + path: outputPath, + }; + } +} diff --git a/src/browser/run/bridge.test.ts b/src/browser/run/bridge.test.ts new file mode 100644 index 00000000..fdd2aeba --- /dev/null +++ b/src/browser/run/bridge.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + BrowserRunBridge, + initializeBrowserRunSandboxClient, +} from './bridge.js'; +import { QuickJSHost } from './quickjs-host.js'; +import { + BROWSER_RUN_PLAYWRIGHT_VERSION, + BROWSER_RUN_PROTOCOL_VERSION, +} from './types.js'; + +type FakePage = { + url(): string; + title(): Promise; +}; + +function fakePage(url = 'https://example.test/'): FakePage { + return { + url: () => url, + title: async () => 'Example', + }; +} + +const hosts = new Set(); + +async function sandboxFor(page: FakePage = fakePage()) { + const bridge = new BrowserRunBridge({ + page: page as never, + pageId: 'page-1', + }); + const host = await QuickJSHost.create({ + onHostCall: (operation, args) => bridge.dispatch(operation, args), + }); + hosts.add(host); + await initializeBrowserRunSandboxClient(host); + return { bridge, host }; +} + +afterEach(() => { + for (const host of hosts) host.dispose(); + hosts.clear(); +}); + +describe('BrowserRunBridge', () => { + it('fails closed when protocol or Playwright versions differ', async () => { + const bridge = new BrowserRunBridge({ + page: fakePage() as never, + pageId: 'page-1', + }); + + await expect(bridge.dispatch('handshake', [{ + protocolVersion: BROWSER_RUN_PROTOCOL_VERSION + 1, + playwrightVersion: BROWSER_RUN_PLAYWRIGHT_VERSION, + }])).rejects.toMatchObject({ + code: 'BROWSER_RUN_PROTOCOL_MISMATCH', + }); + await expect(bridge.dispatch('handshake', [{ + protocolVersion: BROWSER_RUN_PROTOCOL_VERSION, + playwrightVersion: '0.0.0', + }])).rejects.toMatchObject({ + code: 'BROWSER_RUN_PROTOCOL_MISMATCH', + }); + }); + + it('returns only the selected page through currentPage, getPage, and pages', async () => { + const { host } = await sandboxFor(); + + await expect(host.executeScript(` + (async () => { + const current = await browser.currentPage(); + const main = await browser.getPage("main"); + const pages = await browser.pages(); + return { + currentUrl: current.url(), + mainTitle: await main.title(), + pageCount: pages.length, + samePage: current === pages[0] + }; + })() + `)).resolves.toEqual({ + currentUrl: 'https://example.test/', + mainTitle: 'Example', + pageCount: 1, + samePage: true, + }); + }); + + it('does not expose the temporary host call or a mutable browser API', async () => { + const { host } = await sandboxFor(); + + await expect(host.executeScript(`({ + hostCall: typeof __webcmdHostCall, + browserFrozen: Object.isFrozen(browser), + browserPrototype: Object.getPrototypeOf(browser) + })`)).resolves.toEqual({ + hostCall: 'undefined', + browserFrozen: true, + browserPrototype: null, + }); + }); + + it('rejects unknown page names and forged object handles', async () => { + const { bridge, host } = await sandboxFor(); + + await expect(host.executeScript(` + (async () => browser.getPage("another-session"))() + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_API_UNSUPPORTED', + }); + + await expect(bridge.dispatch('page.call', [{ + handle: 'forged:page:1', + method: 'title', + args: [], + }])).rejects.toMatchObject({ + code: 'BROWSER_RUN_API_UNSUPPORTED', + }); + }); + + it('rejects malformed nested locator filter capabilities as invalid input', async () => { + const locator = { + filter: () => locator, + count: async () => 1, + }; + const page = { + ...fakePage(), + locator: () => locator, + }; + const bridge = new BrowserRunBridge({ + page: page as never, + pageId: 'page-1', + }); + const descriptor = await bridge.dispatch('browser.currentPage', []); + const handle = (descriptor as { $remote: { handle: string } }).$remote.handle; + + await expect(bridge.dispatch('locator.call', [{ + originType: 'page', + originHandle: handle, + recipe: [ + { method: 'locator', args: ['main'] }, + { + method: 'filter', + args: [{ has: { $locatorRecipe: 'not-a-recipe' } }], + }, + ], + method: 'count', + args: [], + }])).rejects.toMatchObject({ + code: 'BROWSER_RUN_INVALID_INPUT', + message: expect.stringContaining('same-origin locator capability'), + }); + }); + + it('invalidates every handle on disposal', async () => { + const bridge = new BrowserRunBridge({ + page: fakePage() as never, + pageId: 'page-1', + }); + const descriptor = await bridge.dispatch('browser.currentPage', []); + const handle = (descriptor as { $remote: { handle: string } }).$remote.handle; + bridge.dispose(); + + await expect(bridge.dispatch('page.call', [{ + handle, + method: 'title', + args: [], + }])).rejects.toMatchObject({ + code: 'BROWSER_RUN_API_UNSUPPORTED', + }); + }); +}); diff --git a/src/browser/run/bridge.ts b/src/browser/run/bridge.ts new file mode 100644 index 00000000..ee61ad6d --- /dev/null +++ b/src/browser/run/bridge.ts @@ -0,0 +1,901 @@ +import { randomBytes } from 'node:crypto'; +import { Buffer } from 'node:buffer'; +import type { + Frame as PlaywrightFrame, + Locator as PlaywrightLocator, + Page as PlaywrightPage, + Request as PlaywrightRequest, + Response as PlaywrightResponse, +} from 'playwright-core'; +import { + redactHeaders, + redactUrl, + redactValue, +} from '../../observation/redaction.js'; +import { QuickJSHost } from './quickjs-host.js'; +import { + BrowserRunNetworkSubscriptions, + type BrowserRunNetworkEvent, +} from './network.js'; +import { BROWSER_RUN_SANDBOX_CLIENT_SOURCE } from './sandbox-client.js'; +import { + BROWSER_RUN_MAX_UPLOAD_FILE_BYTES, + BROWSER_RUN_MAX_UPLOAD_FILES, + BROWSER_RUN_MAX_UPLOAD_TOTAL_BYTES, + BROWSER_RUN_MAX_RESPONSE_BODY_BYTES, + BROWSER_RUN_PLAYWRIGHT_VERSION, + BROWSER_RUN_PROTOCOL_VERSION, + BrowserRunError, + type BrowserRunScreenshotReceipt, +} from './types.js'; + +type RemoteType = 'page' | 'frame' | 'request' | 'response'; + +interface RemoteEntry { + type: RemoteType; + value: object; +} + +interface LocatorStep { + method: string; + args: unknown[]; +} + +export interface BrowserRunRemoteDescriptor { + $remote: { + type: RemoteType; + handle: string; + state?: Record; + }; +} + +export interface BrowserRunBridgeOptions { + page: PlaywrightPage; + pageId: string; + writeScreenshot?: ( + target: Pick | Pick, + options: unknown, + ) => Promise; + registerPage?: (page: PlaywrightPage) => string; +} + +const LOCATOR_ROOT_METHODS = new Set([ + 'locator', + 'getByRole', + 'getByText', + 'getByLabel', + 'getByPlaceholder', + 'getByAltText', + 'getByTitle', + 'getByTestId', +]); + +const LOCATOR_CHAIN_METHODS = new Set([ + ...LOCATOR_ROOT_METHODS, + 'filter', + 'first', + 'last', + 'nth', +]); + +const LOCATOR_FILTER_KEYS = new Set([ + 'has', + 'hasNot', + 'hasText', + 'hasNotText', + 'visible', +]); + +const LOCATOR_TERMINAL_METHODS = new Set([ + 'click', + 'dblclick', + 'hover', + 'focus', + 'fill', + 'press', + 'type', + 'clear', + 'dispatchEvent', + 'selectOption', + 'setChecked', + 'setInputFiles', + 'dragTo', + 'screenshot', + 'scrollIntoViewIfNeeded', + 'textContent', + 'innerText', + 'innerHTML', + 'inputValue', + 'getAttribute', + 'isVisible', + 'isHidden', + 'isEnabled', + 'isDisabled', + 'isEditable', + 'isChecked', + 'count', + 'allInnerTexts', + 'allTextContents', + 'evaluate', + 'evaluateAll', + 'waitFor', +]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function unsupported(message: string, hint?: string): BrowserRunError { + return new BrowserRunError( + 'BROWSER_RUN_API_UNSUPPORTED', + message, + hint ?? 'Use a supported Playwright browser automation method or a primitive webcmd browser command.', + ); +} + +function invalidInput(message: string, hint?: string): BrowserRunError { + return new BrowserRunError('BROWSER_RUN_INVALID_INPUT', message, hint); +} + +interface BrowserRunFilePayload { + name: string; + mimeType: string; + buffer: Buffer; +} + +function decodeUploadBytes(value: unknown): Buffer { + if ( + !isRecord(value) + || value.$type !== 'Bytes' + || value.encoding !== 'base64' + || typeof value.data !== 'string' + ) { + throw invalidInput( + 'Browser-run file buffer must be created with Buffer.from(), Uint8Array, or ArrayBuffer.', + ); + } + if ( + value.data.length % 4 !== 0 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.data) + ) { + throw invalidInput('Browser-run file buffer contains invalid base64 data.'); + } + const maxEncodedLength = Math.ceil(BROWSER_RUN_MAX_UPLOAD_FILE_BYTES / 3) * 4; + if (value.data.length > maxEncodedLength) { + throw invalidInput( + `Browser-run files cannot exceed ${BROWSER_RUN_MAX_UPLOAD_FILE_BYTES} bytes each.`, + ); + } + const decoded = Buffer.from(value.data, 'base64'); + if (decoded.toString('base64') !== value.data) { + throw invalidInput('Browser-run file buffer contains non-canonical base64 data.'); + } + if (decoded.byteLength > BROWSER_RUN_MAX_UPLOAD_FILE_BYTES) { + throw invalidInput( + `Browser-run files cannot exceed ${BROWSER_RUN_MAX_UPLOAD_FILE_BYTES} bytes each.`, + ); + } + return decoded; +} + +function decodeUploadFile(value: unknown): BrowserRunFilePayload { + if (!isRecord(value)) { + throw invalidInput( + 'Browser-run uploads require in-memory {name, mimeType, buffer} payloads.', + 'Host file paths are unavailable inside browser run.', + ); + } + const keys = Object.keys(value); + if ( + keys.some(key => !['name', 'mimeType', 'buffer'].includes(key)) + || typeof value.name !== 'string' + || typeof value.mimeType !== 'string' + ) { + throw invalidInput( + 'Browser-run uploads require exactly {name, mimeType, buffer}.', + ); + } + if ( + value.name.length === 0 + || value.name.length > 255 + || /[/\\\u0000-\u001f\u007f]/.test(value.name) + || value.name === '.' + || value.name === '..' + ) { + throw invalidInput('Browser-run upload filename is invalid.'); + } + if ( + value.mimeType.length === 0 + || value.mimeType.length > 255 + || /[\u0000-\u001f\u007f]/.test(value.mimeType) + ) { + throw invalidInput('Browser-run upload MIME type is invalid.'); + } + return { + name: value.name, + mimeType: value.mimeType, + buffer: decodeUploadBytes(value.buffer), + }; +} + +function decodeUploadFiles(value: unknown): BrowserRunFilePayload | BrowserRunFilePayload[] { + if (typeof value === 'string') { + throw invalidInput( + 'Host file paths are unavailable inside browser run.', + 'Use an in-memory {name, mimeType, buffer} payload.', + ); + } + const inputs = Array.isArray(value) ? value : [value]; + if (inputs.length > BROWSER_RUN_MAX_UPLOAD_FILES) { + throw invalidInput( + `Browser run accepts at most ${BROWSER_RUN_MAX_UPLOAD_FILES} in-memory files per upload.`, + ); + } + const files: BrowserRunFilePayload[] = []; + let totalBytes = 0; + for (const input of inputs) { + const file = decodeUploadFile(input); + totalBytes += file.buffer.byteLength; + if (totalBytes > BROWSER_RUN_MAX_UPLOAD_TOTAL_BYTES) { + throw invalidInput( + `Browser-run upload payloads cannot exceed ${BROWSER_RUN_MAX_UPLOAD_TOTAL_BYTES} bytes total.`, + ); + } + files.push(file); + } + return Array.isArray(value) ? files : files[0]!; +} + +function decodeValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(decodeValue); + if (!isRecord(value)) return value; + if (value.$type === 'Undefined') return undefined; + if ( + value.$type === 'RegExp' + && typeof value.source === 'string' + && typeof value.flags === 'string' + ) { + return new RegExp(value.source, value.flags); + } + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, decodeValue(nested)]), + ); +} + +function normalizeArgs(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw unsupported('Browser-run operation arguments must be an array.'); + } + return value.map(decodeValue); +} + +interface EvaluationPayload { + source: string; + args: unknown[]; +} + +function decodeEvaluationPayload(value: unknown, apiName: string): EvaluationPayload { + if ( + !isRecord(value) + || typeof value.source !== 'string' + || !Array.isArray(value.args) + ) { + throw unsupported( + `${apiName} requires a function and serializable arguments.`, + ); + } + return { + source: value.source, + args: value.args.map(decodeValue), + }; +} + +function normalizeLocatorRecipe(value: unknown): LocatorStep[] { + if (!Array.isArray(value) || value.length === 0) { + throw unsupported('Browser-run locator recipe is missing.'); + } + return value.map((step) => { + if ( + !isRecord(step) + || typeof step.method !== 'string' + || !Array.isArray(step.args) + ) { + throw unsupported('Browser-run locator recipe is invalid.'); + } + return { + method: step.method, + args: normalizeArgs(step.args), + }; + }); +} + +export class BrowserRunBridge { + readonly #runPrefix = randomBytes(12).toString('hex'); + readonly #entries = new Map(); + readonly #handles = new WeakMap(); + readonly #pageIds = new WeakMap(); + readonly #pages: PlaywrightPage[] = []; + readonly #network = new BrowserRunNetworkSubscriptions(); + readonly #networkKinds = new Map(); + readonly #pending = new Map, string>(); + readonly #pageId: string; + readonly #writeScreenshot?: BrowserRunBridgeOptions['writeScreenshot']; + readonly #registerPage?: BrowserRunBridgeOptions['registerPage']; + #counter = 0; + #disposed = false; + #cancelled?: Error; + + constructor(options: BrowserRunBridgeOptions) { + this.#pageId = options.pageId; + this.#writeScreenshot = options.writeScreenshot; + this.#registerPage = options.registerPage; + this.#pages.push(options.page); + this.#pageIds.set(options.page, options.pageId); + } + + dispatch(operation: string, args: unknown[]): Promise { + if (this.#disposed) { + return Promise.reject( + unsupported('The browser-run bridge has already been disposed.'), + ); + } + if (this.#cancelled) return Promise.reject(this.#cancelled); + + const pending = this.#dispatch(operation, args); + this.#pending.set(pending, operation); + return pending.finally(() => { + this.#pending.delete(pending); + }); + } + + hasPendingBrowserOperations(): boolean { + return [...this.#pending.values()].some(operation => ( + operation === 'page.call' + || operation === 'frame.call' + || operation === 'locator.call' + || operation === 'request.call' + || operation === 'response.call' + )); + } + + cancel(error: Error): void { + if (this.#disposed || this.#cancelled) return; + this.#cancelled = error; + this.#network.dispose(); + this.#networkKinds.clear(); + } + + async #dispatch(operation: string, args: unknown[]): Promise { + switch (operation) { + case 'handshake': + return this.#handshake(args[0]); + case 'browser.currentPage': + return this.#pageDescriptor(this.#pages[0]!); + case 'browser.getPage': + return this.#getPage(args[0]); + case 'browser.pages': + return this.#pages.map((page) => this.#pageDescriptor(page)); + case 'page.call': + return this.#pageCall(args[0]); + case 'frame.call': + return this.#frameCall(args[0]); + case 'locator.call': + return this.#locatorCall(args[0]); + case 'network.start': + return this.#networkStart(args[0]); + case 'network.next': + return this.#networkNext(args[0]); + case 'network.stop': + return this.#networkStop(args[0]); + case 'request.call': + return this.#requestCall(args[0]); + case 'response.call': + return this.#responseCall(args[0]); + case 'unsupported': + throw unsupported( + `Unsupported Playwright API: ${String(args[0])}`, + 'Use a supported Page, Frame, or Locator method. Browser/context ownership stays with Webcmd.', + ); + default: + throw unsupported(`Unsupported browser-run operation: ${operation}`); + } + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#network.dispose(); + this.#networkKinds.clear(); + this.#pending.clear(); + this.#entries.clear(); + this.#pages.length = 0; + } + + #handshake(value: unknown): { protocolVersion: number; playwrightVersion: string } { + if ( + !isRecord(value) + || value.protocolVersion !== BROWSER_RUN_PROTOCOL_VERSION + || value.playwrightVersion !== BROWSER_RUN_PLAYWRIGHT_VERSION + ) { + throw new BrowserRunError( + 'BROWSER_RUN_PROTOCOL_MISMATCH', + 'Browser-run client and host versions do not match.', + 'Update Webcmd so the CLI, daemon, sandbox client, and Playwright host use the same release.', + ); + } + return { + protocolVersion: BROWSER_RUN_PROTOCOL_VERSION, + playwrightVersion: BROWSER_RUN_PLAYWRIGHT_VERSION, + }; + } + + #getPage(nameOrId: unknown): BrowserRunRemoteDescriptor { + const requested = nameOrId === undefined || nameOrId === null || nameOrId === '' + ? 'main' + : String(nameOrId); + const page = requested === 'main' + ? this.#pages[0] + : this.#pages.find((candidate) => this.#pageIds.get(candidate) === requested); + if (!page) { + throw unsupported( + `Page "${requested}" is not part of this browser-run lease.`, + 'Use browser.currentPage(), browser.getPage("main"), or browser.pages().', + ); + } + return this.#pageDescriptor(page); + } + + #pageDescriptor(page: PlaywrightPage): BrowserRunRemoteDescriptor { + const handle = this.#register('page', page); + const frames = typeof page.frames === 'function' + ? page.frames().map((frame) => this.#frameDescriptor(frame)) + : []; + return { + $remote: { + type: 'page', + handle, + state: { + id: this.#pageIds.get(page) ?? this.#pageId, + url: redactUrl(page.url()), + frames, + }, + }, + }; + } + + #frameDescriptor(frame: PlaywrightFrame): BrowserRunRemoteDescriptor { + return { + $remote: { + type: 'frame', + handle: this.#register('frame', frame), + state: { + name: frame.name(), + url: redactUrl(frame.url()), + }, + }, + }; + } + + #requestDescriptor(request: PlaywrightRequest): BrowserRunRemoteDescriptor { + return { + $remote: { + type: 'request', + handle: this.#register('request', request), + state: { + url: redactUrl(request.url()), + method: request.method(), + resourceType: request.resourceType(), + headers: redactHeaders(request.headers()), + postData: redactValue(request.postData()), + failure: redactValue(request.failure()), + }, + }, + }; + } + + #responseDescriptor(response: PlaywrightResponse): BrowserRunRemoteDescriptor { + return { + $remote: { + type: 'response', + handle: this.#register('response', response), + state: { + url: redactUrl(response.url()), + status: response.status(), + ok: response.ok(), + headers: redactHeaders(response.headers()), + request: this.#requestDescriptor(response.request()), + }, + }, + }; + } + + async #pageCall(input: unknown): Promise { + if (!isRecord(input)) throw unsupported('Invalid page operation payload.'); + const page = this.#requireHandle(input.handle, 'page') as PlaywrightPage; + const method = input.method; + const args = normalizeArgs(input.args ?? []); + + let value: unknown; + switch (method) { + case 'title': + case 'content': + value = await page[method](); + break; + case 'goto': + await page.goto(args[0] as string, args[1] as never); + value = null; + break; + case 'reload': + case 'goBack': + case 'goForward': + await page[method](args[0] as never); + value = null; + break; + case 'waitForLoadState': + await page.waitForLoadState(args[0] as never, args[1] as never); + break; + case 'waitForURL': + await page.waitForURL(args[0] as never, args[1] as never); + break; + case 'evaluate': + value = await this.#evaluate(page, args[0]); + break; + case 'waitForEvent': { + if (args[0] !== 'popup') { + throw unsupported( + `Unsupported Playwright Page event: ${String(args[0])}`, + 'The initial browser-run surface supports page.waitForEvent("popup").', + ); + } + const popup = await page.waitForEvent('popup', args[1] as never); + this.#addPage(popup); + value = this.#pageDescriptor(popup); + break; + } + case 'screenshot': + if (!this.#writeScreenshot) { + throw unsupported('page.screenshot() is unavailable for this browser-run host.'); + } + value = await this.#writeScreenshot(page, args[0]); + break; + default: + throw unsupported(`Unsupported Playwright Page method: ${String(method)}`); + } + + return { + value, + state: this.#pageDescriptor(page).$remote.state, + }; + } + + async #frameCall(input: unknown): Promise { + if (!isRecord(input)) throw unsupported('Invalid frame operation payload.'); + const frame = this.#requireHandle(input.handle, 'frame') as PlaywrightFrame; + const method = input.method; + const args = normalizeArgs(input.args ?? []); + + let value: unknown; + switch (method) { + case 'content': + value = await frame.content(); + break; + case 'evaluate': + value = await this.#evaluate(frame, args[0]); + break; + case 'waitForLoadState': + await frame.waitForLoadState(args[0] as never, args[1] as never); + break; + case 'waitForURL': + await frame.waitForURL(args[0] as never, args[1] as never); + break; + default: + throw unsupported(`Unsupported Playwright Frame method: ${String(method)}`); + } + + return { + value, + state: { + name: frame.name(), + url: redactUrl(frame.url()), + }, + }; + } + + async #locatorCall(input: unknown): Promise { + if (!isRecord(input)) throw unsupported('Invalid locator operation payload.'); + const originType = input.originType; + if (originType !== 'page' && originType !== 'frame') { + throw unsupported('Browser-run locator origin is invalid.'); + } + const origin = this.#requireHandle( + input.originHandle, + originType, + ) as PlaywrightPage | PlaywrightFrame; + const recipe = normalizeLocatorRecipe(input.recipe); + const method = input.method; + if (typeof method !== 'string' || !LOCATOR_TERMINAL_METHODS.has(method)) { + throw unsupported(`Unsupported Playwright Locator method: ${String(method)}`); + } + const locator = this.#replayLocator(origin, recipe); + const args = normalizeArgs(input.args ?? []); + + let value: unknown; + if (method === 'dragTo') { + const targetRecipe = normalizeLocatorRecipe(args[0]); + const target = this.#replayLocator(origin, targetRecipe); + value = await locator.dragTo(target, args[1] as never); + } else if (method === 'setInputFiles') { + value = await locator.setInputFiles( + decodeUploadFiles(args[0]) as never, + args[1] as never, + ); + } else if (method === 'evaluate' || method === 'evaluateAll') { + value = await this.#evaluateLocator(locator, method, args[0]); + } else if (method === 'screenshot') { + if (!this.#writeScreenshot) { + throw unsupported('locator.screenshot() is unavailable for this browser-run host.'); + } + value = await this.#writeScreenshot(locator, args[0]); + } else { + const callable = (locator as unknown as Record)[method]; + if (typeof callable !== 'function') { + throw unsupported(`Playwright Locator method is unavailable: ${method}`); + } + value = await callable.apply(locator, args); + } + + const page = originType === 'page' + ? origin as PlaywrightPage + : (origin as PlaywrightFrame).page(); + return { + value, + pageState: this.#pageDescriptor(page).$remote.state, + frameState: originType === 'frame' + ? { + name: (origin as PlaywrightFrame).name(), + url: redactUrl((origin as PlaywrightFrame).url()), + } + : undefined, + }; + } + + #networkStart(input: unknown): number { + if (!isRecord(input)) throw unsupported('Invalid network subscription payload.'); + const page = this.#requireHandle(input.pageHandle, 'page') as PlaywrightPage; + const event = input.event; + if (event !== 'request' && event !== 'response') { + throw unsupported( + `Unsupported Playwright network event: ${String(event)}`, + 'The initial browser-run network surface supports request and response events.', + ); + } + const id = this.#network.start(page, event); + this.#networkKinds.set(id, event); + return id; + } + + async #networkNext(input: unknown): Promise { + if (!isRecord(input) || typeof input.id !== 'number') { + throw unsupported('Invalid network subscription id.'); + } + const kind = this.#networkKinds.get(input.id); + if (!kind) throw unsupported('Network subscription is unknown or expired.'); + const value = await this.#network.next(input.id); + return kind === 'request' + ? this.#requestDescriptor(value as PlaywrightRequest) + : this.#responseDescriptor(value as PlaywrightResponse); + } + + #networkStop(input: unknown): null { + if (!isRecord(input) || typeof input.id !== 'number') return null; + this.#network.stop(input.id); + this.#networkKinds.delete(input.id); + return null; + } + + async #requestCall(input: unknown): Promise { + if (!isRecord(input)) throw unsupported('Invalid request operation payload.'); + const request = this.#requireHandle( + input.handle, + 'request', + ) as PlaywrightRequest; + if (input.method !== 'allHeaders') { + throw unsupported(`Unsupported Playwright Request method: ${String(input.method)}`); + } + return redactHeaders(await request.allHeaders()); + } + + async #responseCall(input: unknown): Promise { + if (!isRecord(input)) throw unsupported('Invalid response operation payload.'); + const response = this.#requireHandle( + input.handle, + 'response', + ) as PlaywrightResponse; + switch (input.method) { + case 'allHeaders': + return redactHeaders(await response.allHeaders()); + case 'body': { + const body = await this.#boundedResponseBody(response); + return [...body]; + } + case 'text': { + const body = await this.#boundedResponseBody(response); + return body.toString('utf8'); + } + case 'json': { + const body = await this.#boundedResponseBody(response); + try { + return JSON.parse(body.toString('utf8')) as unknown; + } catch { + throw new BrowserRunError( + 'BROWSER_RUN_SERIALIZATION_ERROR', + 'The response body is not valid JSON.', + ); + } + } + default: + throw unsupported(`Unsupported Playwright Response method: ${String(input.method)}`); + } + } + + async #boundedResponseBody(response: PlaywrightResponse): Promise { + const body = await response.body(); + if (body.byteLength > BROWSER_RUN_MAX_RESPONSE_BODY_BYTES) { + throw new BrowserRunError( + 'BROWSER_RUN_OUTPUT_LIMIT', + `Response body exceeds the ${BROWSER_RUN_MAX_RESPONSE_BODY_BYTES}-byte browser-run limit.`, + 'Inspect headers or a smaller endpoint response instead of returning the full body.', + ); + } + return body; + } + + #replayLocator( + origin: PlaywrightPage | PlaywrightFrame, + recipe: LocatorStep[], + ): PlaywrightLocator { + let current: PlaywrightPage | PlaywrightFrame | PlaywrightLocator = origin; + for (let index = 0; index < recipe.length; index += 1) { + const step = recipe[index]!; + const allowed = index === 0 ? LOCATOR_ROOT_METHODS : LOCATOR_CHAIN_METHODS; + if (!allowed.has(step.method)) { + throw unsupported(`Unsupported Playwright locator chain method: ${step.method}`); + } + const callable = (current as unknown as Record)[step.method]; + if (typeof callable !== 'function') { + throw unsupported(`Playwright locator chain method is unavailable: ${step.method}`); + } + const args = step.method === 'filter' + ? [this.#decodeLocatorFilter(origin, step.args[0])] + : step.args; + current = callable.apply(current, args) as PlaywrightLocator; + } + return current as PlaywrightLocator; + } + + #decodeLocatorFilter( + origin: PlaywrightPage | PlaywrightFrame, + value: unknown, + ): Record { + if (!isRecord(value)) { + throw invalidInput('locator.filter() requires an options object.'); + } + const options: Record = {}; + for (const [key, nested] of Object.entries(value)) { + if (!LOCATOR_FILTER_KEYS.has(key)) { + throw invalidInput(`Unsupported locator.filter() option: ${key}`); + } + if (key === 'has' || key === 'hasNot') { + if ( + !isRecord(nested) + || Object.keys(nested).length !== 1 + || !('$locatorRecipe' in nested) + ) { + throw invalidInput( + `locator.filter(${key}) requires a same-origin locator capability.`, + ); + } + try { + options[key] = this.#replayLocator( + origin, + normalizeLocatorRecipe(nested.$locatorRecipe), + ); + } catch (error) { + if (error instanceof BrowserRunError) { + throw invalidInput( + `locator.filter(${key}) requires a valid same-origin locator capability.`, + ); + } + throw error; + } + continue; + } + if ( + (key === 'hasText' || key === 'hasNotText') + && typeof nested !== 'string' + && !(nested instanceof RegExp) + ) { + throw invalidInput(`locator.filter(${key}) requires a string or RegExp.`); + } + if (key === 'visible' && typeof nested !== 'boolean') { + throw invalidInput('locator.filter(visible) requires a boolean.'); + } + options[key] = nested; + } + return options; + } + + async #evaluate( + target: Pick | Pick, + value: unknown, + ): Promise { + const payload = decodeEvaluationPayload(value, 'page.evaluate()'); + return target.evaluate( + ({ source, args }) => { + const pageFunction = globalThis.eval(`(${source})`) as (...items: unknown[]) => unknown; + return pageFunction(...args); + }, + payload, + ); + } + + async #evaluateLocator( + locator: PlaywrightLocator, + method: 'evaluate' | 'evaluateAll', + value: unknown, + ): Promise { + const payload = decodeEvaluationPayload(value, `locator.${method}()`); + const pageFunction = ( + elementOrElements: unknown, + input: EvaluationPayload, + ): unknown => { + const callable = globalThis.eval(`(${input.source})`) as ( + value: unknown, + ...items: unknown[] + ) => unknown; + return callable(elementOrElements, ...input.args); + }; + if (method === 'evaluate') { + return locator.evaluate(pageFunction as never, payload as never); + } + return locator.evaluateAll(pageFunction as never, payload as never); + } + + #addPage(page: PlaywrightPage): void { + if (this.#pages.includes(page)) return; + this.#pages.push(page); + this.#pageIds.set( + page, + this.#registerPage?.(page) ?? `popup-${this.#pages.length - 1}`, + ); + } + + #register(type: RemoteType, value: object): string { + const existing = this.#handles.get(value); + if (existing) return existing; + const handle = `${this.#runPrefix}:${type}:${++this.#counter}`; + this.#handles.set(value, handle); + this.#entries.set(handle, { type, value }); + return handle; + } + + #requireHandle(handle: unknown, type: RemoteType): object { + if (typeof handle !== 'string') { + throw unsupported('Browser-run object handle is missing.'); + } + const entry = this.#entries.get(handle); + if (!entry || entry.type !== type) { + throw unsupported('Browser-run object handle is unknown, expired, or has the wrong type.'); + } + return entry.value; + } +} + +export async function initializeBrowserRunSandboxClient( + host: QuickJSHost, +): Promise { + host.installHostCall(); + await host.executeScript(BROWSER_RUN_SANDBOX_CLIENT_SOURCE, { + filename: 'webcmd-browser-run-client.js', + }); +} diff --git a/src/browser/run/index.ts b/src/browser/run/index.ts new file mode 100644 index 00000000..5eb60ce4 --- /dev/null +++ b/src/browser/run/index.ts @@ -0,0 +1,7 @@ +export * from './artifacts.js'; +export * from './bridge.js'; +export * from './input.js'; +export * from './network.js'; +export * from './observation.js'; +export * from './runner.js'; +export * from './types.js'; diff --git a/src/browser/run/input.test.ts b/src/browser/run/input.test.ts new file mode 100644 index 00000000..7d0ddb44 --- /dev/null +++ b/src/browser/run/input.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { loadBrowserRunSource, type BrowserRunSourceIo } from './input.js'; +import { BROWSER_RUN_MAX_SOURCE_BYTES } from './types.js'; + +function fakeIo(opts: { + stdin?: string; + files?: Record; +} = {}): BrowserRunSourceIo { + return { + readStdin: async () => opts.stdin ?? '', + readFile: async (filePath) => { + const value = opts.files?.[filePath]; + if (value === undefined) throw new Error(`ENOENT: ${filePath}`); + return value; + }, + }; +} + +describe('loadBrowserRunSource', () => { + it('requires exactly one source input', async () => { + await expect(loadBrowserRunSource({ stdin: false }, fakeIo())).rejects.toMatchObject({ + code: 'BROWSER_RUN_INVALID_INPUT', + }); + await expect(loadBrowserRunSource( + { stdin: true, file: 'task.js' }, + fakeIo({ stdin: 'return 1;', files: { 'task.js': 'return 2;' } }), + )).rejects.toMatchObject({ + code: 'BROWSER_RUN_INVALID_INPUT', + }); + }); + + it('reads a file in the CLI process', async () => { + await expect(loadBrowserRunSource( + { file: 'task.js' }, + fakeIo({ files: { 'task.js': 'return 42;' } }), + )).resolves.toBe('return 42;'); + }); + + it('reads standard input when selected', async () => { + await expect(loadBrowserRunSource( + { stdin: true }, + fakeIo({ stdin: 'const page = await browser.currentPage();' }), + )).resolves.toBe('const page = await browser.currentPage();'); + }); + + it('rejects empty source', async () => { + await expect(loadBrowserRunSource( + { stdin: true }, + fakeIo({ stdin: ' \n\t' }), + )).rejects.toMatchObject({ + code: 'BROWSER_RUN_INVALID_INPUT', + }); + }); + + it('enforces the UTF-8 byte limit for stdin and files', async () => { + const oversized = '😀'.repeat(Math.floor(BROWSER_RUN_MAX_SOURCE_BYTES / 4) + 1); + + await expect(loadBrowserRunSource( + { stdin: true }, + fakeIo({ stdin: oversized }), + )).rejects.toMatchObject({ + code: 'BROWSER_RUN_SOURCE_LIMIT', + }); + + await expect(loadBrowserRunSource( + { file: 'task.js' }, + fakeIo({ files: { 'task.js': oversized } }), + )).rejects.toMatchObject({ + code: 'BROWSER_RUN_SOURCE_LIMIT', + }); + }); + + it('does not include a client file path in file-read failures', async () => { + await expect(loadBrowserRunSource( + { file: '/Users/example/private/task.js' }, + fakeIo(), + )).rejects.toMatchObject({ + code: 'BROWSER_RUN_INVALID_INPUT', + message: 'Could not read the browser run source file.', + }); + }); +}); diff --git a/src/browser/run/input.ts b/src/browser/run/input.ts new file mode 100644 index 00000000..38c131eb --- /dev/null +++ b/src/browser/run/input.ts @@ -0,0 +1,79 @@ +import { readFile } from 'node:fs/promises'; +import { stdin as processStdin } from 'node:process'; +import { + BROWSER_RUN_MAX_SOURCE_BYTES, + BrowserRunError, +} from './types.js'; + +export interface BrowserRunSourceInput { + stdin?: boolean; + file?: string; +} + +export interface BrowserRunSourceIo { + readStdin(): Promise; + readFile(filePath: string): Promise; +} + +async function readProcessStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of processStdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +const defaultBrowserRunSourceIo: BrowserRunSourceIo = { + readStdin: readProcessStdin, + readFile: (filePath) => readFile(filePath, 'utf8'), +}; + +function validateSource(source: string): string { + if (!source.trim()) { + throw new BrowserRunError( + 'BROWSER_RUN_INVALID_INPUT', + 'Browser run source must not be empty.', + 'Pass JavaScript through --stdin or --file .', + ); + } + const bytes = Buffer.byteLength(source, 'utf8'); + if (bytes > BROWSER_RUN_MAX_SOURCE_BYTES) { + throw new BrowserRunError( + 'BROWSER_RUN_SOURCE_LIMIT', + `Browser run source exceeds the ${BROWSER_RUN_MAX_SOURCE_BYTES}-byte limit.`, + 'Split the task into smaller decision-sized programs.', + ); + } + return source; +} + +export async function loadBrowserRunSource( + input: BrowserRunSourceInput, + io: BrowserRunSourceIo = defaultBrowserRunSourceIo, +): Promise { + const useStdin = input.stdin === true; + const file = typeof input.file === 'string' && input.file.trim() + ? input.file + : undefined; + if (Number(useStdin) + Number(file !== undefined) !== 1) { + throw new BrowserRunError( + 'BROWSER_RUN_INVALID_INPUT', + 'Choose exactly one browser run source: --stdin or --file .', + ); + } + + if (useStdin) { + return validateSource(await io.readStdin()); + } + + try { + return validateSource(await io.readFile(file!)); + } catch (error) { + if (error instanceof BrowserRunError) throw error; + throw new BrowserRunError( + 'BROWSER_RUN_INVALID_INPUT', + 'Could not read the browser run source file.', + 'Check that the file exists and is readable by the local Webcmd CLI.', + ); + } +} diff --git a/src/browser/run/network.test.ts b/src/browser/run/network.test.ts new file mode 100644 index 00000000..1587d81b --- /dev/null +++ b/src/browser/run/network.test.ts @@ -0,0 +1,205 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + BrowserRunBridge, + initializeBrowserRunSandboxClient, +} from './bridge.js'; +import { QuickJSHost } from './quickjs-host.js'; +import { + BrowserRunNetworkSubscriptions, + type BrowserRunNetworkPage, +} from './network.js'; + +class FakeRequest { + constructor( + readonly requestUrl: string, + readonly requestMethod = 'GET', + ) {} + + url() { return this.requestUrl; } + method() { return this.requestMethod; } + resourceType() { return 'fetch'; } + headers() { + return { + accept: 'application/json', + authorization: 'Bearer secret', + cookie: 'session=secret', + }; + } + async allHeaders() { return this.headers(); } + postData() { return null; } + failure() { return null; } +} + +class FakeResponse { + constructor( + readonly responseUrl: string, + readonly requestValue: FakeRequest, + readonly bodyValue: Buffer = Buffer.from('{"items":[1]}'), + ) {} + + url() { return this.responseUrl; } + status() { return 200; } + ok() { return true; } + headers() { + return { + 'content-type': 'application/json', + 'set-cookie': 'session=secret', + }; + } + async allHeaders() { return this.headers(); } + request() { return this.requestValue; } + async body() { return this.bodyValue; } +} + +class FakeNetworkPage extends EventEmitter { + nextResponse?: FakeResponse; + + url() { return 'https://example.test/'; } + async title() { return 'Example'; } + frames() { return []; } + locator() { + return { + click: async () => { + const request = new FakeRequest('https://example.test/api/search'); + this.emit('request', request); + this.emit( + 'response', + this.nextResponse ?? new FakeResponse(request.url(), request), + ); + }, + }; + } +} + +const hosts = new Set(); + +async function sandboxFor(page: FakeNetworkPage) { + const bridge = new BrowserRunBridge({ + page: page as never, + pageId: 'page-1', + }); + const host = await QuickJSHost.create({ + onHostCall: (operation, args) => bridge.dispatch(operation, args), + }); + hosts.add(host); + await initializeBrowserRunSandboxClient(host); + return { bridge, host }; +} + +afterEach(() => { + for (const host of hosts) host.dispose(); + hosts.clear(); +}); + +describe('BrowserRunNetworkSubscriptions', () => { + it('queues events in order and removes its listener on stop', async () => { + const page = new FakeNetworkPage(); + const subscriptions = new BrowserRunNetworkSubscriptions(); + const id = subscriptions.start(page as BrowserRunNetworkPage, 'response'); + const first = new FakeResponse( + 'https://example.test/first', + new FakeRequest('https://example.test/first'), + ); + const second = new FakeResponse( + 'https://example.test/second', + new FakeRequest('https://example.test/second'), + ); + + page.emit('response', first); + page.emit('response', second); + + await expect(subscriptions.next(id)).resolves.toBe(first); + await expect(subscriptions.next(id)).resolves.toBe(second); + expect(page.listenerCount('response')).toBe(1); + subscriptions.stop(id); + expect(page.listenerCount('response')).toBe(0); + }); + + it('fails closed instead of allowing an unbounded event queue', async () => { + const page = new FakeNetworkPage(); + const subscriptions = new BrowserRunNetworkSubscriptions(2); + const id = subscriptions.start(page as BrowserRunNetworkPage, 'request'); + + page.emit('request', new FakeRequest('https://example.test/one')); + page.emit('request', new FakeRequest('https://example.test/two')); + page.emit('request', new FakeRequest('https://example.test/three')); + + await expect(subscriptions.next(id)).rejects.toMatchObject({ + code: 'BROWSER_RUN_OUTPUT_LIMIT', + }); + expect(page.listenerCount('request')).toBe(0); + subscriptions.stop(id); + }); +}); + +describe('browser-run network surface', () => { + it('arms waitForResponse before the triggering click and evaluates the predicate in QuickJS', async () => { + const page = new FakeNetworkPage(); + const { host } = await sandboxFor(page); + + const result = await host.executeScript(` + (async () => { + const page = await browser.currentPage(); + const responsePromise = page.waitForResponse( + response => response.url().includes("/api/search") + && response.request().method() === "GET" + ); + await page.locator("button").click(); + const response = await responsePromise; + return { + status: response.status(), + body: await response.json() + }; + })() + `); + + expect(result).toEqual({ status: 200, body: { items: [1] } }); + expect(page.listenerCount('response')).toBe(0); + }); + + it('redacts sensitive request and response headers', async () => { + const page = new FakeNetworkPage(); + const { host } = await sandboxFor(page); + + const result = await host.executeScript(` + (async () => { + const page = await browser.currentPage(); + const pending = page.waitForRequest("/api/search"); + await page.locator("button").click(); + const request = await pending; + return await request.allHeaders(); + })() + `); + + expect(result).toEqual({ + accept: 'application/json', + authorization: '[REDACTED]', + cookie: '[REDACTED]', + }); + }); + + it('rejects oversized bodies without returning their contents', async () => { + const page = new FakeNetworkPage(); + const { host } = await sandboxFor(page); + const response = new FakeResponse( + 'https://example.test/large', + new FakeRequest('https://example.test/large'), + Buffer.alloc(1024 * 1024 + 1, 65), + ); + page.nextResponse = response; + + const result = host.executeScript(` + (async () => { + const page = await browser.currentPage(); + const pending = page.waitForResponse("/large"); + await page.locator("button").click(); + return (await pending).text(); + })() + `); + + await expect(result).rejects.toMatchObject({ + code: 'BROWSER_RUN_OUTPUT_LIMIT', + }); + }); +}); diff --git a/src/browser/run/network.ts b/src/browser/run/network.ts new file mode 100644 index 00000000..3d6e3963 --- /dev/null +++ b/src/browser/run/network.ts @@ -0,0 +1,97 @@ +import { BrowserRunError } from './types.js'; + +export type BrowserRunNetworkEvent = 'request' | 'response'; + +export interface BrowserRunNetworkPage { + on(event: BrowserRunNetworkEvent, listener: (value: unknown) => void): unknown; + off(event: BrowserRunNetworkEvent, listener: (value: unknown) => void): unknown; +} + +interface PendingWaiter { + resolve(value: unknown): void; + reject(error: Error): void; +} + +interface Subscription { + page: BrowserRunNetworkPage; + event: BrowserRunNetworkEvent; + listener: (value: unknown) => void; + queue: unknown[]; + waiters: PendingWaiter[]; + overflow?: BrowserRunError; +} + +export class BrowserRunNetworkSubscriptions { + readonly #subscriptions = new Map(); + #nextId = 1; + #disposed = false; + + constructor(private readonly maxQueuedEvents = 256) { + if (!Number.isSafeInteger(maxQueuedEvents) || maxQueuedEvents <= 0) { + throw new TypeError('maxQueuedEvents must be a positive integer'); + } + } + + start(page: BrowserRunNetworkPage, event: BrowserRunNetworkEvent): number { + if (this.#disposed) throw this.#closedError(); + const id = this.#nextId++; + const subscription: Subscription = { + page, + event, + queue: [], + waiters: [], + listener: (value) => { + const waiter = subscription.waiters.shift(); + if (waiter) waiter.resolve(value); + else if (subscription.queue.length < this.maxQueuedEvents) { + subscription.queue.push(value); + } else { + subscription.overflow = new BrowserRunError( + 'BROWSER_RUN_OUTPUT_LIMIT', + `Browser-run network subscription exceeded ${this.maxQueuedEvents} queued events.`, + 'Use a narrower request/response predicate or remove long-lived event listeners.', + ); + subscription.page.off(subscription.event, subscription.listener); + } + }, + }; + this.#subscriptions.set(id, subscription); + page.on(event, subscription.listener); + return id; + } + + next(id: number): Promise { + const subscription = this.#subscriptions.get(id); + if (!subscription) return Promise.reject(this.#closedError()); + if (subscription.overflow) return Promise.reject(subscription.overflow); + if (subscription.queue.length > 0) { + return Promise.resolve(subscription.queue.shift()); + } + return new Promise((resolve, reject) => { + subscription.waiters.push({ resolve, reject }); + }); + } + + stop(id: number): void { + const subscription = this.#subscriptions.get(id); + if (!subscription) return; + this.#subscriptions.delete(id); + subscription.page.off(subscription.event, subscription.listener); + const error = this.#closedError(); + for (const waiter of subscription.waiters.splice(0)) waiter.reject(error); + subscription.queue.length = 0; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + for (const id of [...this.#subscriptions.keys()]) this.stop(id); + } + + #closedError(): BrowserRunError { + return new BrowserRunError( + 'BROWSER_RUN_CANCELLED', + 'The browser-run network subscription is closed.', + ); + } +} diff --git a/src/browser/run/observation.test.ts b/src/browser/run/observation.test.ts new file mode 100644 index 00000000..c8b430d4 --- /dev/null +++ b/src/browser/run/observation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { BrowserRunObservationStore } from './observation.js'; + +describe('BrowserRunObservationStore', () => { + it('returns a full first observation and a compact later diff', () => { + const store = new BrowserRunObservationStore(); + + expect(store.record({ + pageId: 'page-1', + url: 'https://example.test/', + content: 'button "Save"', + requestedMode: 'diff', + maxChars: 10_000, + })).toEqual({ + observation: { mode: 'full', content: 'button "Save"' }, + truncated: false, + }); + + expect(store.record({ + pageId: 'page-1', + url: 'https://example.test/', + content: 'button "Save"\nstatus "Saved"', + requestedMode: 'diff', + maxChars: 10_000, + })).toEqual({ + observation: { mode: 'diff', changed: '+ status "Saved"' }, + truncated: false, + }); + }); + + it('resets to a full observation after navigation and bounds content', () => { + const store = new BrowserRunObservationStore(); + store.record({ + pageId: 'page-1', + url: 'https://example.test/one', + content: 'first', + requestedMode: 'diff', + maxChars: 10_000, + }); + + const result = store.record({ + pageId: 'page-1', + url: 'https://example.test/two', + content: 'x'.repeat(20), + requestedMode: 'diff', + maxChars: 10, + }); + + expect(result).toEqual({ + observation: { mode: 'full', content: 'xxxxxxxxxx' }, + truncated: true, + }); + }); +}); diff --git a/src/browser/run/observation.ts b/src/browser/run/observation.ts new file mode 100644 index 00000000..c5232217 --- /dev/null +++ b/src/browser/run/observation.ts @@ -0,0 +1,96 @@ +import { redactText } from '../../observation/redaction.js'; +import type { + BrowserRunObservation, + BrowserRunObserveMode, +} from './types.js'; + +interface ObservationBaseline { + url: string; + content: string; +} + +export interface BrowserRunObservationInput { + pageId: string; + url: string; + content: string; + requestedMode: BrowserRunObserveMode; + maxChars: number; +} + +export interface BrowserRunObservationOutput { + observation: BrowserRunObservation; + truncated: boolean; +} + +function lineDiff(previous: string, current: string): string { + const before = new Set(previous.split('\n')); + const after = new Set(current.split('\n')); + const removed = [...before] + .filter((line) => !after.has(line)) + .map((line) => `- ${line}`); + const added = [...after] + .filter((line) => !before.has(line)) + .map((line) => `+ ${line}`); + return [...removed, ...added].join('\n'); +} + +function bound(value: string, maxChars: number): { + value: string; + truncated: boolean; +} { + if (value.length <= maxChars) return { value, truncated: false }; + return { + value: value.slice(0, Math.max(0, maxChars)), + truncated: true, + }; +} + +/** + * Stores semantic observation baselines outside the sandbox. + * + * Baselines are keyed by Webcmd page id and reset after navigation so a diff + * is never presented against an unrelated document. + */ +export class BrowserRunObservationStore { + readonly #baselines = new Map(); + + record(input: BrowserRunObservationInput): BrowserRunObservationOutput { + if (input.requestedMode === 'none') { + return { + observation: { mode: 'none' }, + truncated: false, + }; + } + + const content = redactText(input.content, { + maxStringLength: Math.max(input.maxChars * 2, input.maxChars), + }); + const previous = this.#baselines.get(input.pageId); + this.#baselines.set(input.pageId, { + url: input.url, + content, + }); + + const useFull = input.requestedMode === 'full' + || !previous + || previous.url !== input.url; + if (useFull) { + const result = bound(content, input.maxChars); + return { + observation: { mode: 'full', content: result.value }, + truncated: result.truncated, + }; + } + + const result = bound(lineDiff(previous.content, content), input.maxChars); + return { + observation: { mode: 'diff', changed: result.value }, + truncated: result.truncated, + }; + } + + clear(pageId?: string): void { + if (pageId) this.#baselines.delete(pageId); + else this.#baselines.clear(); + } +} diff --git a/src/browser/run/playwright-surface.test.ts b/src/browser/run/playwright-surface.test.ts new file mode 100644 index 00000000..592370a3 --- /dev/null +++ b/src/browser/run/playwright-surface.test.ts @@ -0,0 +1,750 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + BrowserRunBridge, + initializeBrowserRunSandboxClient, +} from './bridge.js'; +import { QuickJSHost } from './quickjs-host.js'; + +type Operation = [string, ...unknown[]]; + +class FakeLocator { + constructor( + private readonly page: FakePage, + private readonly recipe: Operation[], + ) {} + + private next(operation: Operation): FakeLocator { + return new FakeLocator(this.page, [...this.recipe, operation]); + } + + locator(selector: string, options?: unknown) { + return this.next(['locator', selector, options]); + } + + getByRole(role: string, options?: unknown) { + return this.next(['getByRole', role, options]); + } + + getByText(text: string, options?: unknown) { + return this.next(['getByText', text, options]); + } + + getByLabel(text: string, options?: unknown) { + return this.next(['getByLabel', text, options]); + } + + getByPlaceholder(text: string, options?: unknown) { + return this.next(['getByPlaceholder', text, options]); + } + + getByAltText(text: string, options?: unknown) { + return this.next(['getByAltText', text, options]); + } + + getByTitle(text: string, options?: unknown) { + return this.next(['getByTitle', text, options]); + } + + getByTestId(id: string | RegExp) { + return this.next(['getByTestId', id]); + } + + first() { + return this.next(['first']); + } + + last() { + return this.next(['last']); + } + + nth(index: number) { + return this.next(['nth', index]); + } + + filter(options: { + has?: FakeLocator; + hasNot?: FakeLocator; + [key: string]: unknown; + }) { + return this.next(['filter', { + ...options, + has: options.has?.recipe, + hasNot: options.hasNot?.recipe, + }]); + } + + async terminal(method: string, ...args: unknown[]) { + this.page.operations.push(...this.recipe, [method, ...args]); + if (method === 'click') this.page.urlValue = 'https://example.test/saved'; + switch (method) { + case 'innerText': return 'Save'; + case 'innerHTML': return 'Save'; + case 'textContent': return 'Save'; + case 'inputValue': return 'draft'; + case 'getAttribute': return 'button'; + case 'isVisible': return true; + case 'isHidden': return false; + case 'isEnabled': return true; + case 'isDisabled': return false; + case 'isEditable': return true; + case 'isChecked': return false; + case 'count': return 2; + case 'allInnerTexts': return ['Save', 'Cancel']; + case 'allTextContents': return ['Save', 'Cancel']; + case 'selectOption': return ['published']; + default: return undefined; + } + } + + click(options?: unknown) { return this.terminal('click', options ?? {}); } + dblclick(options?: unknown) { return this.terminal('dblclick', options ?? {}); } + hover(options?: unknown) { return this.terminal('hover', options ?? {}); } + focus(options?: unknown) { return this.terminal('focus', options ?? {}); } + fill(value: string, options?: unknown) { return this.terminal('fill', value, options ?? {}); } + press(key: string, options?: unknown) { return this.terminal('press', key, options ?? {}); } + type(value: string, options?: unknown) { return this.terminal('type', value, options ?? {}); } + clear(options?: unknown) { return this.terminal('clear', options ?? {}); } + dispatchEvent(type: string, eventInit?: unknown) { + return this.terminal('dispatchEvent', type, eventInit ?? {}); + } + selectOption(value: unknown, options?: unknown) { return this.terminal('selectOption', value, options ?? {}); } + setChecked(value: boolean, options?: unknown) { return this.terminal('setChecked', value, options ?? {}); } + setInputFiles(files: unknown, options?: unknown) { + return this.terminal('setInputFiles', files, options ?? {}); + } + dragTo(target: FakeLocator, options?: unknown) { + return this.terminal('dragTo', target.recipe, options ?? {}); + } + scrollIntoViewIfNeeded(options?: unknown) { + return this.terminal('scrollIntoViewIfNeeded', options ?? {}); + } + textContent(options?: unknown) { return this.terminal('textContent', options ?? {}); } + innerText(options?: unknown) { return this.terminal('innerText', options ?? {}); } + innerHTML(options?: unknown) { return this.terminal('innerHTML', options ?? {}); } + inputValue(options?: unknown) { return this.terminal('inputValue', options ?? {}); } + getAttribute(name: string, options?: unknown) { + return this.terminal('getAttribute', name, options ?? {}); + } + isVisible(options?: unknown) { return this.terminal('isVisible', options ?? {}); } + isHidden(options?: unknown) { return this.terminal('isHidden', options ?? {}); } + isEnabled(options?: unknown) { return this.terminal('isEnabled', options ?? {}); } + isDisabled(options?: unknown) { return this.terminal('isDisabled', options ?? {}); } + isEditable(options?: unknown) { return this.terminal('isEditable', options ?? {}); } + isChecked(options?: unknown) { return this.terminal('isChecked', options ?? {}); } + count() { return this.terminal('count'); } + allInnerTexts() { return this.terminal('allInnerTexts'); } + allTextContents() { return this.terminal('allTextContents'); } + waitFor(options?: unknown) { return this.terminal('waitFor', options ?? {}); } + + async evaluate( + pageFunction: (element: { tagName: string }, payload: unknown) => unknown, + payload: unknown, + ) { + this.page.operations.push(...this.recipe, ['evaluate', payload]); + return pageFunction({ tagName: 'IMG' }, payload); + } + + async evaluateAll( + pageFunction: (elements: { tagName: string }[], payload: unknown) => unknown, + payload: unknown, + ) { + this.page.operations.push(...this.recipe, ['evaluateAll', payload]); + return pageFunction([{ tagName: 'IMG' }, { tagName: 'SVG' }], payload); + } +} + +class FakeFrame { + constructor( + readonly frameName: string, + readonly frameUrl: string, + private readonly parentPage: FakePage, + ) {} + + name() { return this.frameName; } + url() { return this.frameUrl; } + page() { return this.parentPage; } + locator(selector: string, options?: unknown) { + return new FakeLocator(this.parentPage, [['frame', this.frameName], ['locator', selector, options]]); + } + getByRole(role: string, options?: unknown) { + return new FakeLocator(this.parentPage, [['frame', this.frameName], ['getByRole', role, options]]); + } + getByText(text: string, options?: unknown) { + return new FakeLocator(this.parentPage, [['frame', this.frameName], ['getByText', text, options]]); + } + getByLabel(text: string, options?: unknown) { + return new FakeLocator(this.parentPage, [['frame', this.frameName], ['getByLabel', text, options]]); + } + getByPlaceholder(text: string, options?: unknown) { + return new FakeLocator(this.parentPage, [['frame', this.frameName], ['getByPlaceholder', text, options]]); + } + getByAltText(text: string, options?: unknown) { + return new FakeLocator(this.parentPage, [['frame', this.frameName], ['getByAltText', text, options]]); + } + getByTitle(text: string, options?: unknown) { + return new FakeLocator(this.parentPage, [['frame', this.frameName], ['getByTitle', text, options]]); + } + getByTestId(id: string | RegExp) { + return new FakeLocator(this.parentPage, [['frame', this.frameName], ['getByTestId', id]]); + } + async evaluate(_source: unknown, payload?: { source?: string; args?: unknown[] }) { + this.parentPage.evaluatePayload = payload; + return 'Frame!'; + } + async content() { return '

frame

'; } + async waitForLoadState(state?: string, options?: unknown) { + this.parentPage.operations.push(['frame.waitForLoadState', state, options]); + } + async waitForURL(url: unknown, options?: unknown) { + this.parentPage.operations.push(['frame.waitForURL', url, options]); + } +} + +class FakePage { + urlValue = 'https://example.test/'; + operations: Operation[] = []; + evaluatePayload: unknown; + readonly listeners = new Map void>>(); + readonly childFrame = new FakeFrame('child', 'https://frame.test/', this); + popup?: FakePage; + + url() { return this.urlValue; } + async title() { return 'Example'; } + async content() { return '
Example
'; } + async goto(url: string, options?: unknown) { + this.operations.push(['goto', url, options]); + this.urlValue = url; + return null; + } + async reload(options?: unknown) { + this.operations.push(['reload', options]); + return null; + } + async goBack(options?: unknown) { + this.operations.push(['goBack', options]); + return null; + } + async goForward(options?: unknown) { + this.operations.push(['goForward', options]); + return null; + } + async waitForLoadState(state?: string, options?: unknown) { + this.operations.push(['waitForLoadState', state, options]); + } + async waitForURL(url: unknown, options?: unknown) { + this.operations.push(['waitForURL', url, options]); + } + async evaluate(_source: unknown, payload?: { source?: string; args?: unknown[] }) { + this.evaluatePayload = payload; + return payload?.args?.[0] === '!' ? 'Example!' : 'evaluated'; + } + frames() { return [new FakeFrame('main', this.urlValue, this), this.childFrame]; } + async waitForEvent(name: string, _options?: unknown) { + if (name !== 'popup' || !this.popup) throw new Error(`No ${name}`); + return this.popup; + } + on(event: string, listener: (value: unknown) => void) { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + } + off(event: string, listener: (value: unknown) => void) { + this.listeners.get(event)?.delete(listener); + } + + locator(selector: string, options?: unknown) { + return new FakeLocator(this, [['locator', selector, options]]); + } + getByRole(role: string, options?: unknown) { + return new FakeLocator(this, [['getByRole', role, options]]); + } + getByText(text: string, options?: unknown) { + return new FakeLocator(this, [['getByText', text, options]]); + } + getByLabel(text: string, options?: unknown) { + return new FakeLocator(this, [['getByLabel', text, options]]); + } + getByPlaceholder(text: string, options?: unknown) { + return new FakeLocator(this, [['getByPlaceholder', text, options]]); + } + getByAltText(text: string, options?: unknown) { + return new FakeLocator(this, [['getByAltText', text, options]]); + } + getByTitle(text: string, options?: unknown) { + return new FakeLocator(this, [['getByTitle', text, options]]); + } + getByTestId(id: string | RegExp) { + return new FakeLocator(this, [['getByTestId', id]]); + } +} + +const hosts = new Set(); + +async function runInSandbox(page: FakePage, source: string): Promise { + const bridge = new BrowserRunBridge({ + page: page as never, + pageId: 'page-1', + }); + const host = await QuickJSHost.create({ + onHostCall: (operation, args) => bridge.dispatch(operation, args), + }); + hosts.add(host); + await initializeBrowserRunSandboxClient(host); + return host.executeScript(`(async () => { ${source} })()`, { + filename: 'user-script.js', + }); +} + +afterEach(() => { + for (const host of hosts) host.dispose(); + hosts.clear(); +}); + +describe('browser-run Playwright surface', () => { + it('replays semantic locator chains and actions on the selected page', async () => { + const page = new FakePage(); + const result = await runInSandbox(page, ` + const page = await browser.getPage("main"); + const save = page.getByRole("button", { name: "Save" }).first(); + await save.click(); + return { text: await save.innerText(), url: page.url() }; + `); + + expect(result).toEqual({ + text: 'Save', + url: 'https://example.test/saved', + }); + expect(page.operations).toEqual([ + ['getByRole', 'button', { name: 'Save' }], + ['first'], + ['click', {}], + ['getByRole', 'button', { name: 'Save' }], + ['first'], + ['innerText', {}], + ]); + }); + + it('supports navigation, page reads, waits, and page-context evaluation', async () => { + const page = new FakePage(); + const result = await runInSandbox(page, ` + const page = await browser.currentPage(); + await page.goto("https://example.test/next", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("load"); + await page.waitForURL(/\\/next$/); + const evaluated = await page.evaluate((value) => document.title + value, "!"); + return { + url: page.url(), + title: await page.title(), + content: await page.content(), + evaluated + }; + `); + + expect(result).toEqual({ + url: 'https://example.test/next', + title: 'Example', + content: '
Example
', + evaluated: 'Example!', + }); + expect(page.evaluatePayload).toMatchObject({ + args: ['!'], + }); + expect((page.evaluatePayload as { source: string }).source) + .toContain('document.title'); + expect(page.operations).toEqual([ + ['goto', 'https://example.test/next', { waitUntil: 'domcontentloaded' }], + ['waitForLoadState', 'load', {}], + ['waitForURL', /\/next$/, {}], + ]); + }); + + it('exposes the selected page as a global', async () => { + const page = new FakePage(); + + await expect(runInSandbox(page, ` + return await globalThis.page.title(); + `)).resolves.toBe('Example'); + }); + + it('uploads an in-memory file through the sandbox bridge', async () => { + const page = new FakePage(); + const result = await runInSandbox(page, ` + const buffer = Buffer.from("Wufoo π document"); + await page.locator("#file").setInputFiles({ + name: "document.txt", + mimeType: "text/plain", + buffer + }); + return { + isBuffer: Buffer.isBuffer(buffer), + text: buffer.toString(), + base64: buffer.toString("base64") + }; + `); + + expect(result).toEqual({ + isBuffer: true, + text: 'Wufoo π document', + base64: Buffer.from('Wufoo π document').toString('base64'), + }); + expect(page.operations).toEqual([ + ['locator', '#file', undefined], + ['setInputFiles', { + name: 'document.txt', + mimeType: 'text/plain', + buffer: Buffer.from('Wufoo π document'), + }, {}], + ]); + }); + + it('rejects host file paths in browser run', async () => { + const page = new FakePage(); + + await expect(runInSandbox(page, ` + await page.locator("#file").setInputFiles("/etc/passwd"); + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_INVALID_INPUT', + }); + }); + + it('rejects more than eight in-memory files', async () => { + const page = new FakePage(); + + await expect(runInSandbox(page, ` + const files = Array.from({ length: 9 }, (_, index) => ({ + name: "file-" + index + ".txt", + mimeType: "text/plain", + buffer: Buffer.from("x") + })); + await page.locator("#file").setInputFiles(files); + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_INVALID_INPUT', + }); + }); + + it('supports page.waitForTimeout inside the sandbox', async () => { + const page = new FakePage(); + const elapsed = await runInSandbox(page, ` + const page = await browser.currentPage(); + const started = Date.now(); + await page.waitForTimeout(10); + return Date.now() - started; + `); + + expect(elapsed).toBeGreaterThanOrEqual(5); + }); + + it('supports the initial locator action and read matrix', async () => { + const page = new FakePage(); + const result = await runInSandbox(page, ` + const page = await browser.currentPage(); + const field = page.locator("input").nth(1); + await field.focus(); + await field.fill("draft"); + await field.press("Enter"); + await field.type("!"); + await field.hover(); + await field.dblclick(); + await field.setChecked(true); + await field.scrollIntoViewIfNeeded(); + await field.waitFor({ state: "visible" }); + const target = page.getByText("Drop"); + await field.dragTo(target); + return { + text: await field.textContent(), + inner: await field.innerText(), + value: await field.inputValue(), + role: await field.getAttribute("role"), + visible: await field.isVisible(), + enabled: await field.isEnabled(), + checked: await field.isChecked(), + count: await field.count(), + all: await field.allTextContents(), + selected: await page.getByLabel("Status").selectOption("published") + }; + `); + + expect(result).toEqual({ + text: 'Save', + inner: 'Save', + value: 'draft', + role: 'button', + visible: true, + enabled: true, + checked: false, + count: 2, + all: ['Save', 'Cancel'], + selected: ['published'], + }); + expect(page.operations).toContainEqual(['fill', 'draft', {}]); + expect(page.operations).toContainEqual(['dragTo', [ + ['getByText', 'Drop', undefined], + ], {}]); + }); + + it('supports high-frequency locator evaluation, roots, reads, and interactions', async () => { + const page = new FakePage(); + const result = await runInSandbox(page, ` + const images = page.getByAltText("Example"); + const links = page.getByTitle("Details"); + await links.clear(); + await links.dispatchEvent("input", { bubbles: true }); + return { + one: await images.evaluate( + (element, suffix) => element.tagName + suffix, + "!" + ), + many: await images.evaluateAll( + (elements, prefix) => elements.map(element => prefix + element.tagName), + "#" + ), + html: await links.innerHTML(), + texts: await links.allInnerTexts(), + hidden: await links.isHidden(), + disabled: await links.isDisabled(), + editable: await links.isEditable() + }; + `); + + expect(result).toEqual({ + one: 'IMG!', + many: ['#IMG', '#SVG'], + html: 'Save', + texts: ['Save', 'Cancel'], + hidden: false, + disabled: false, + editable: true, + }); + expect(page.operations).toContainEqual([ + 'getByTitle', + 'Details', + undefined, + ]); + expect(page.operations).toContainEqual([ + 'dispatchEvent', + 'input', + { bubbles: true }, + ]); + const evaluate = page.operations.find(operation => operation[0] === 'evaluate'); + expect(evaluate?.[1]).toMatchObject({ + source: expect.stringContaining('element.tagName'), + args: ['!'], + }); + const evaluateAll = page.operations.find( + operation => operation[0] === 'evaluateAll', + ); + expect(evaluateAll?.[1]).toMatchObject({ + source: expect.stringContaining('elements.map'), + args: ['#'], + }); + }); + + it('supports scalar and same-origin locator filters plus all()', async () => { + const page = new FakePage(); + const result = await runInSandbox(page, ` + const row = page.getByRole("row").filter({ + hasText: "Ready", + has: page.getByRole("button", { name: "Open" }) + }); + const items = await row.all(); + await items[1].click(); + return { + count: items.length, + awaitedLocatorIsSame: (await items[0]) === items[0] + }; + `); + + expect(result).toEqual({ + count: 2, + awaitedLocatorIsSame: true, + }); + const filterStep = [ + 'filter', + { + hasText: 'Ready', + has: [['getByRole', 'button', { name: 'Open' }]], + hasNot: undefined, + }, + ]; + expect(page.operations).toEqual([ + ['getByRole', 'row', undefined], + filterStep, + ['count'], + ['getByRole', 'row', undefined], + filterStep, + ['nth', 1], + ['click', {}], + ]); + }); + + it('rejects invalid and cross-origin locator filters', async () => { + const page = new FakePage(); + + await expect(runInSandbox(page, ` + page.locator("main").filter(null); + `)).rejects.toThrow('requires an options object'); + + await expect(runInSandbox(page, ` + page.locator("main").filter({ + has: page.frames()[1].locator("button") + }); + `)).rejects.toThrow('same page or frame'); + }); + + it('waits for page and frame selectors and returns usable locators', async () => { + const page = new FakePage(); + const result = await runInSandbox(page, ` + const ready = await page.waitForSelector("#ready", { state: "visible" }); + const frameReady = await page.frames()[1].waitForSelector(".ready"); + return { + pageReady: await ready.isVisible(), + frameReady: await frameReady.isVisible() + }; + `); + + expect(result).toEqual({ + pageReady: true, + frameReady: true, + }); + expect(page.operations).toEqual([ + ['locator', '#ready', undefined], + ['waitFor', { state: 'visible' }], + ['frame', 'child'], + ['locator', '.ready', undefined], + ['waitFor', {}], + ['locator', '#ready', undefined], + ['isVisible', {}], + ['frame', 'child'], + ['locator', '.ready', undefined], + ['isVisible', {}], + ]); + }); + + it('supports Playwright check and uncheck locator aliases', async () => { + const page = new FakePage(); + + await runInSandbox(page, ` + const consent = page.getByLabel("Email"); + await consent.check({ force: true }); + await consent.uncheck({ timeout: 250 }); + `); + + expect(page.operations).toEqual([ + ['getByLabel', 'Email', undefined], + ['setChecked', true, { force: true }], + ['getByLabel', 'Email', undefined], + ['setChecked', false, { timeout: 250 }], + ]); + }); + + it('exposes frames synchronously and returns popups as run-owned pages', async () => { + const page = new FakePage(); + page.popup = new FakePage(); + page.popup.urlValue = 'https://popup.test/'; + + const result = await runInSandbox(page, ` + const page = await browser.currentPage(); + const frames = page.frames(); + const popup = await page.waitForEvent("popup"); + return { + frameName: frames[1].name(), + frameUrl: frames[1].url(), + frameValue: await frames[1].evaluate(() => document.title), + frameAlt: await frames[1].getByAltText("Diagram").innerText(), + frameTitle: await frames[1].getByTitle("Details").innerText(), + popupUrl: popup.url(), + pageCount: (await browser.pages()).length + }; + `); + + expect(result).toEqual({ + frameName: 'child', + frameUrl: 'https://frame.test/', + frameValue: 'Frame!', + frameAlt: 'Save', + frameTitle: 'Save', + popupUrl: 'https://popup.test/', + pageCount: 2, + }); + }); + + it('returns a typed correction for denied context and browser ownership APIs', async () => { + const page = new FakePage(); + + await expect(runInSandbox(page, ` + const page = await browser.currentPage(); + return page.context(); + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_API_UNSUPPORTED', + }); + }); + + it('names unknown page, frame, and locator methods', async () => { + const page = new FakePage(); + + await expect(runInSandbox(page, ` + await page.locator("img").methodThatDoesNotExist(); + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_API_UNSUPPORTED', + message: expect.stringContaining('locator.methodThatDoesNotExist'), + }); + + await expect(runInSandbox(page, ` + await page.methodThatDoesNotExist(); + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_API_UNSUPPORTED', + message: expect.stringContaining('page.methodThatDoesNotExist'), + }); + + await expect(runInSandbox(page, ` + const frame = page.frames()[1]; + await frame.methodThatDoesNotExist(); + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_API_UNSUPPORTED', + message: expect.stringContaining('frame.methodThatDoesNotExist'), + }); + }); + + it('does not make guarded browser surfaces thenable', async () => { + const page = new FakePage(); + + await expect(runInSandbox(page, ` + const locator = page.locator("img"); + return { + pageThen: page.then, + locatorThen: locator.then, + awaitedPageIsSame: (await page) === page, + awaitedLocatorIsSame: (await locator) === locator + }; + `)).resolves.toEqual({ + awaitedPageIsSame: true, + awaitedLocatorIsSame: true, + }); + }); + + it('preserves the guarded page through fluent network listener methods', async () => { + const page = new FakePage(); + const result = await runInSandbox(page, ` + const listener = () => {}; + const fromOn = page.on("request", listener); + const fromOff = fromOn.off("request", listener); + return { + onSame: fromOn === page, + offSame: fromOff === page + }; + `); + + expect(result).toEqual({ + onSame: true, + offSame: true, + }); + + await expect(runInSandbox(page, ` + const fluentPage = page.off("request", () => {}); + await fluentPage.methodThatDoesNotExist(); + `)).rejects.toMatchObject({ + code: 'BROWSER_RUN_API_UNSUPPORTED', + message: expect.stringContaining('page.methodThatDoesNotExist'), + }); + }); +}); diff --git a/src/browser/run/quickjs-host.test.ts b/src/browser/run/quickjs-host.test.ts new file mode 100644 index 00000000..5e39e522 --- /dev/null +++ b/src/browser/run/quickjs-host.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { QuickJSHost } from './quickjs-host.js'; + +const hosts = new Set(); + +async function createHost( + options: Parameters[0] = {}, +): Promise { + const host = await QuickJSHost.create(options); + hosts.add(host); + return host; +} + +afterEach(() => { + for (const host of hosts) host.dispose(); + hosts.clear(); +}); + +describe('QuickJSHost', () => { + it('executes async JavaScript and captures console values', async () => { + const logs: Array<{ level: string; args: unknown[] }> = []; + const host = await createHost({ + onConsole: (level, args) => logs.push({ level, args }), + }); + + await expect(host.executeScript(` + (async () => { + console.log("ok", 42, { nested: true }); + return 7; + })() + `)).resolves.toBe(7); + expect(logs).toEqual([{ + level: 'log', + args: ['ok', 42, { nested: true }], + }]); + }); + + it('has no Node, network, module-loading, or host-call globals by default', async () => { + const host = await createHost(); + + await expect(host.executeScript(`({ + process: typeof process, + require: typeof require, + module: typeof module, + fetch: typeof fetch, + XMLHttpRequest: typeof XMLHttpRequest, + WebSocket: typeof WebSocket, + hostCall: typeof __webcmdHostCall + })`)).resolves.toEqual({ + process: 'undefined', + require: 'undefined', + module: 'undefined', + fetch: 'undefined', + XMLHttpRequest: 'undefined', + WebSocket: 'undefined', + hostCall: 'undefined', + }); + }); + + it('bridges an explicitly configured asynchronous host call', async () => { + const host = await createHost({ + onHostCall: async (name, args) => ({ name, args }), + }); + host.installHostCall(); + + await expect(host.executeScript(` + __webcmdHostCall("demo", JSON.stringify([1, "two"])) + `)).resolves.toEqual({ + name: 'demo', + args: [1, 'two'], + }); + }); + + it('supports timers without exposing Node timer handles', async () => { + const host = await createHost(); + + await expect(host.executeScript(` + new Promise(resolve => setTimeout(() => resolve("done"), 5)) + `)).resolves.toBe('done'); + }); + + it('interrupts an infinite synchronous loop', async () => { + const host = await createHost({ cpuTimeoutMs: 20 }); + + await expect(host.executeScript('while (true) {}')).rejects.toThrow(); + }); + + it('enforces a configured memory ceiling', async () => { + const host = await createHost({ memoryLimitBytes: 2 * 1024 * 1024 }); + + await expect(host.executeScript( + 'new ArrayBuffer(8 * 1024 * 1024)', + )).rejects.toThrow(/memory|allocation|interrupted/i); + }); + + it('refuses use after disposal', async () => { + const host = await createHost(); + host.dispose(); + + await expect(host.executeScript('1 + 1')).rejects.toThrow(/disposed/i); + }); +}); diff --git a/src/browser/run/quickjs-host.ts b/src/browser/run/quickjs-host.ts new file mode 100644 index 00000000..75c0bd32 --- /dev/null +++ b/src/browser/run/quickjs-host.ts @@ -0,0 +1,629 @@ +/* + * QuickJS lifecycle and promise bridge. + * + * Derived from dev-browser's MIT-licensed QuickJSHost and adapted for + * Webcmd's capability-scoped browser-run protocol. See NOTICE. + */ +import { + getQuickJS, + type ContextEvalOptions, + type QuickJSContext, + type QuickJSDeferredPromise, + type QuickJSHandle, + type QuickJSRuntime, + type QuickJSWASMModule, +} from 'quickjs-emscripten'; + +export type QuickJSConsoleLevel = 'log' | 'warn' | 'error' | 'info'; + +export type QuickJSHostValue = + | undefined + | null + | boolean + | number + | string + | bigint + | QuickJSHostValue[] + | { [key: string]: QuickJSHostValue }; + +export interface QuickJSHostOptions { + memoryLimitBytes?: number; + maxStackSizeBytes?: number; + cpuTimeoutMs?: number; + globals?: Record; + onHostCall?: (name: string, args: unknown[]) => unknown | Promise; + onDrain?: () => void | Promise; + onConsole?: (level: QuickJSConsoleLevel, args: unknown[]) => void; +} + +export interface QuickJSExecutionOptions { + filename?: string; + type?: ContextEvalOptions['type']; +} + +interface TimerRecord { + callback: QuickJSHandle; + args: QuickJSHandle[]; + timeout: NodeJS.Timeout; +} + +export class QuickJSHost { + static async create(options: QuickJSHostOptions = {}): Promise { + const quickjs = await getQuickJS(); + return new QuickJSHost(quickjs, options); + } + + readonly #quickjs: QuickJSWASMModule; + readonly #runtime: QuickJSRuntime; + readonly #context: QuickJSContext; + readonly #options: QuickJSHostOptions; + readonly #timers = new Map(); + readonly #pendingDeferreds = new Set(); + + #disposed = false; + #hostCallInstalled = false; + #interruptDeadline: number | undefined; + #nextTimerId = 1; + + private constructor(quickjs: QuickJSWASMModule, options: QuickJSHostOptions) { + this.#quickjs = quickjs; + this.#options = options; + this.#runtime = quickjs.newRuntime(); + + if (options.memoryLimitBytes !== undefined) { + this.#runtime.setMemoryLimit(options.memoryLimitBytes); + } + if (options.maxStackSizeBytes !== undefined) { + this.#runtime.setMaxStackSize(options.maxStackSizeBytes); + } + if (options.cpuTimeoutMs !== undefined) { + this.#runtime.setInterruptHandler(() => ( + this.#interruptDeadline !== undefined + && Date.now() > this.#interruptDeadline + )); + } + + this.#context = this.#runtime.newContext(); + this.#installConsole(); + this.#installTimers(); + + for (const [name, value] of Object.entries(options.globals ?? {})) { + this.setGlobal(name, value); + } + } + + get disposed(): boolean { + return this.#disposed; + } + + installHostCall(): void { + this.#assertAlive(); + if (this.#hostCallInstalled) return; + if (!this.#options.onHostCall) { + throw new Error('QuickJS host-call installation requires onHostCall.'); + } + + const hostCall = this.#context.newFunction( + '__webcmdHostCall', + (nameHandle, argsJsonHandle) => { + const name = this.#context.getString(nameHandle); + const argsJson = this.#context.getString(argsJsonHandle); + const args = this.#parseArgs(name, argsJson); + return this.#bridgeHostResult(() => this.#options.onHostCall!(name, args)); + }, + ); + this.#context.setProp(this.#context.global, '__webcmdHostCall', hostCall); + hostCall.dispose(); + this.#hostCallInstalled = true; + } + + setGlobal(name: string, value: QuickJSHostValue): void { + this.#assertAlive(); + const valueHandle = this.#toHandle(value); + try { + this.#context.setProp(this.#context.global, name, valueHandle); + } finally { + valueHandle.dispose(); + } + } + + executeScriptSync(code: string, options: QuickJSExecutionOptions = {}): unknown { + const resultHandle = this.#evalCode(code, options); + try { + const promiseState = this.#context.getPromiseState(resultHandle); + if (!(promiseState.type === 'fulfilled' && promiseState.notAPromise)) { + throw new Error('QuickJS script returned a promise; use executeScript() instead'); + } + return this.#dumpHandle(resultHandle); + } finally { + resultHandle.dispose(); + } + } + + async executeScript(code: string, options: QuickJSExecutionOptions = {}): Promise { + const resultHandle = this.#evalCode(code, options); + return this.#consumeHandle(resultHandle); + } + + async callFunction(name: string, ...args: QuickJSHostValue[]): Promise { + this.#assertAlive(); + const functionHandle = this.#context.getProp(this.#context.global, name); + const argHandles = args.map((value) => this.#toHandle(value)); + + try { + if (this.#context.typeof(functionHandle) !== 'function') { + throw new Error(`QuickJS global "${name}" is not a function`); + } + + const result = this.#runWithCpuLimit(() => ( + this.#context.callFunction( + functionHandle, + this.#context.global, + ...argHandles, + ) + )); + const resultHandle = this.#unwrapResult( + result, + `QuickJS function "${name}" failed`, + ); + return await this.#consumeHandle(resultHandle); + } finally { + for (const argHandle of argHandles) argHandle.dispose(); + functionHandle.dispose(); + } + } + + cancelPending(error: Error = new Error('QuickJS host calls were cancelled')): void { + if (this.#disposed) return; + for (const deferred of [...this.#pendingDeferreds]) { + if (!deferred.alive) continue; + const errorHandle = this.#newGuestErrorHandle(error); + try { + deferred.reject(errorHandle); + } finally { + errorHandle.dispose(); + } + } + this.#tryDrainPendingJobs(); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + + for (const timerId of [...this.#timers.keys()]) { + this.#clearTimer(timerId); + } + for (const deferred of [...this.#pendingDeferreds]) { + deferred.dispose(); + this.#pendingDeferreds.delete(deferred); + } + + this.#context.dispose(); + this.#runtime.dispose(); + } + + #installConsole(): void { + const consoleObject = this.#context.newObject(); + try { + for (const level of ['log', 'warn', 'error', 'info'] as const) { + const fn = this.#context.newFunction(level, (...args) => { + const nativeArgs = args.map((arg) => this.#context.dump(arg)); + this.#options.onConsole?.(level, nativeArgs); + }); + this.#context.setProp(consoleObject, level, fn); + fn.dispose(); + } + this.#context.setProp(this.#context.global, 'console', consoleObject); + } finally { + consoleObject.dispose(); + } + } + + #installTimers(): void { + const setTimeoutHandle = this.#context.newFunction( + 'setTimeout', + (callbackHandle, delayHandle, ...argHandles) => { + if (this.#context.typeof(callbackHandle) !== 'function') { + throw new TypeError('setTimeout callback must be a function'); + } + + const timerId = this.#nextTimerId++; + const delay = Math.max(0, this.#context.getNumber(delayHandle)); + const callback = callbackHandle.dup(); + const args = argHandles.map((arg) => arg.dup()); + const timeout = globalThis.setTimeout(() => { + if (this.#disposed) { + this.#clearTimer(timerId); + return; + } + const record = this.#timers.get(timerId); + if (!record) return; + + try { + const result = this.#runWithCpuLimit(() => ( + this.#context.callFunction( + record.callback, + this.#context.undefined, + ...record.args, + ) + )); + if (result.error) { + this.#emitConsoleError(this.#context.dump(result.error)); + result.error.dispose(); + } else { + result.value.dispose(); + } + this.#tryDrainPendingJobs(); + } finally { + this.#clearTimer(timerId); + } + }, delay); + + this.#timers.set(timerId, { callback, args, timeout }); + return this.#context.newNumber(timerId); + }, + ); + const clearTimeoutHandle = this.#context.newFunction( + 'clearTimeout', + (timerIdHandle) => { + this.#clearTimer(this.#context.getNumber(timerIdHandle)); + }, + ); + this.#context.setProp( + this.#context.global, + 'setTimeout', + setTimeoutHandle, + ); + this.#context.setProp( + this.#context.global, + 'clearTimeout', + clearTimeoutHandle, + ); + setTimeoutHandle.dispose(); + clearTimeoutHandle.dispose(); + } + + #clearTimer(timerId: number): void { + const record = this.#timers.get(timerId); + if (!record) return; + globalThis.clearTimeout(record.timeout); + record.callback.dispose(); + for (const arg of record.args) arg.dispose(); + this.#timers.delete(timerId); + } + + #bridgeHostResult(invoker: () => unknown | Promise): QuickJSHandle | void { + try { + const result = invoker(); + if (this.#isPromiseLike(result)) { + return this.#createDeferredPromise(result); + } + if (result === undefined) return; + return this.#toHandle(result as QuickJSHostValue); + } catch (error) { + throw this.#normalizeHostError(error); + } + } + + #createDeferredPromise(promise: Promise): QuickJSHandle { + const deferred = this.#context.newPromise(); + this.#pendingDeferreds.add(deferred); + promise + .then((value) => { + if (this.#disposed || !deferred.alive) return; + const valueHandle = value === undefined + ? undefined + : this.#toHandle(value as QuickJSHostValue); + try { + deferred.resolve(valueHandle); + } finally { + valueHandle?.dispose(); + } + }) + .catch((error) => { + if (this.#disposed || !deferred.alive) return; + const errorHandle = this.#newGuestErrorHandle(error); + try { + deferred.reject(errorHandle); + } finally { + errorHandle.dispose(); + } + }) + .finally(() => { + this.#pendingDeferreds.delete(deferred); + this.#tryDrainPendingJobs(); + }); + return deferred.handle; + } + + #evalCode(code: string, options: QuickJSExecutionOptions): QuickJSHandle { + this.#assertAlive(); + const result = this.#runWithCpuLimit(() => ( + this.#context.evalCode(code, options.filename ?? 'sandbox.js', { + type: options.type, + }) + )); + return this.#unwrapResult(result, 'QuickJS evaluation failed'); + } + + async #consumeHandle(handle: QuickJSHandle): Promise { + try { + const promiseState = this.#context.getPromiseState(handle); + if (promiseState.type === 'fulfilled' && promiseState.notAPromise) { + return this.#dumpHandle(handle); + } + if (promiseState.type === 'fulfilled') { + try { + return this.#dumpHandle(promiseState.value); + } finally { + promiseState.value.dispose(); + } + } + if (promiseState.type === 'pending') { + const resolved = await this.#awaitQuickJSPromise(handle); + try { + return this.#dumpHandle(resolved); + } finally { + resolved.dispose(); + } + } + if (promiseState.type === 'rejected') { + try { + throw this.#toError( + 'QuickJS promise rejected', + promiseState.error, + ); + } finally { + promiseState.error.dispose(); + } + } + + const resolved = await this.#awaitQuickJSPromise(handle); + try { + return this.#dumpHandle(resolved); + } finally { + resolved.dispose(); + } + } finally { + handle.dispose(); + } + } + + async #awaitQuickJSPromise( + promiseHandle: QuickJSHandle, + ): Promise { + let settled = false; + const nativePromise = this.#context + .resolvePromise(promiseHandle) + .finally(() => { + settled = true; + }); + + while (!settled) { + this.#drainPendingJobs(); + if (settled) break; + await this.#options.onDrain?.(); + this.#drainPendingJobs(); + if (settled) break; + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 0); + }); + } + + this.#drainPendingJobs(); + return this.#unwrapResult( + await nativePromise, + 'QuickJS promise rejected', + ); + } + + #drainPendingJobs(): void { + this.#assertAlive(); + while (true) { + const jobResult = this.#runWithCpuLimit(() => ( + this.#runtime.executePendingJobs() + )); + if (jobResult.error) { + const error = this.#toError( + 'QuickJS pending job failed', + jobResult.error, + ); + jobResult.error.dispose(); + throw error; + } + if (jobResult.value === 0) return; + } + } + + #tryDrainPendingJobs(): void { + if (this.#disposed) return; + try { + this.#drainPendingJobs(); + } catch (error) { + this.#emitConsoleError(this.#normalizeHostError(error)); + } + } + + #unwrapResult( + result: { value?: T; error?: QuickJSHandle }, + prefix: string, + ): T { + if (result.error) { + const error = this.#toError(prefix, result.error); + result.error.dispose(); + throw error; + } + return result.value as T; + } + + #toHandle(value: QuickJSHostValue): QuickJSHandle { + if (value === undefined) return this.#context.undefined.dup(); + if (value === null) return this.#context.null.dup(); + if (value === true) return this.#context.true.dup(); + if (value === false) return this.#context.false.dup(); + + switch (typeof value) { + case 'number': + return this.#context.newNumber(value); + case 'string': + return this.#context.newString(value); + case 'bigint': + return this.#context.newBigInt(value); + case 'object': { + if (Array.isArray(value)) { + const arrayHandle = this.#context.newArray(); + try { + value.forEach((item, index) => { + const itemHandle = this.#toHandle(item); + try { + this.#context.setProp(arrayHandle, index, itemHandle); + } finally { + itemHandle.dispose(); + } + }); + return arrayHandle; + } catch (error) { + arrayHandle.dispose(); + throw error; + } + } + + const objectHandle = this.#context.newObject(); + try { + for (const [key, item] of Object.entries(value)) { + const itemHandle = this.#toHandle(item); + try { + this.#context.setProp(objectHandle, key, itemHandle); + } finally { + itemHandle.dispose(); + } + } + return objectHandle; + } catch (error) { + objectHandle.dispose(); + throw error; + } + } + default: + throw new TypeError(`Unsupported host value type: ${typeof value}`); + } + } + + #dumpHandle(handle: QuickJSHandle): unknown { + return this.#context.dump(handle); + } + + #parseArgs(name: string, argsJson: string): unknown[] { + try { + const parsed = JSON.parse(argsJson) as unknown; + if (!Array.isArray(parsed)) throw new Error('Expected a JSON array'); + return parsed; + } catch (error) { + throw new Error( + `Invalid JSON arguments for host call "${name}": ` + + this.#normalizeHostError(error).message, + ); + } + } + + #newGuestErrorHandle(error: unknown): QuickJSHandle { + const normalized = this.#normalizeHostError(error); + const errorHandle = this.#context.newError({ + name: normalized.name || 'Error', + message: normalized.message, + }); + if (normalized.stack) { + const stackHandle = this.#context.newString(normalized.stack); + try { + this.#context.setProp(errorHandle, 'stack', stackHandle); + } finally { + stackHandle.dispose(); + } + } + if ('code' in normalized && typeof normalized.code === 'string') { + const codeHandle = this.#context.newString(normalized.code); + try { + this.#context.setProp(errorHandle, 'code', codeHandle); + } finally { + codeHandle.dispose(); + } + } + if ('hint' in normalized && typeof normalized.hint === 'string') { + const hintHandle = this.#context.newString(normalized.hint); + try { + this.#context.setProp(errorHandle, 'hint', hintHandle); + } finally { + hintHandle.dispose(); + } + } + return errorHandle; + } + + #toError(prefix: string, errorHandle: QuickJSHandle): Error { + const dumped = this.#context.dump(errorHandle); + const normalized = this.#normalizeHostError(dumped); + normalized.message = `${prefix}: ${normalized.message}`; + return normalized; + } + + #emitConsoleError(error: unknown): void { + const normalized = this.#normalizeHostError(error); + this.#options.onConsole?.('error', [{ + name: normalized.name, + message: normalized.message, + stack: normalized.stack, + }]); + } + + #normalizeHostError(error: unknown): Error & { + code?: string; + hint?: string; + } { + if (error instanceof Error) { + return error as Error & { code?: string; hint?: string }; + } + if (typeof error === 'object' && error !== null) { + const record = error as Record; + const name = typeof record.name === 'string' ? record.name : 'Error'; + const message = typeof record.message === 'string' + ? record.message + : JSON.stringify(error); + const normalized = new Error(message) as Error & { + code?: string; + hint?: string; + }; + normalized.name = name; + if (typeof record.stack === 'string') normalized.stack = record.stack; + if (typeof record.code === 'string') normalized.code = record.code; + if (typeof record.hint === 'string') normalized.hint = record.hint; + return normalized; + } + return new Error(String(error)); + } + + #runWithCpuLimit(callback: () => T): T { + if (this.#options.cpuTimeoutMs === undefined) return callback(); + const previousDeadline = this.#interruptDeadline; + this.#interruptDeadline = Date.now() + this.#options.cpuTimeoutMs; + try { + return callback(); + } finally { + this.#interruptDeadline = previousDeadline; + } + } + + #assertAlive(): void { + if (this.#disposed) { + throw new Error('QuickJSHost has been disposed'); + } + } + + #isPromiseLike(value: unknown): value is Promise { + return ( + (typeof value === 'object' || typeof value === 'function') + && value !== null + && 'then' in value + ); + } +} diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts new file mode 100644 index 00000000..d1022ecb --- /dev/null +++ b/src/browser/run/runner.test.ts @@ -0,0 +1,423 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BrowserRunArtifactWriter } from './artifacts.js'; +import { BrowserRunObservationStore } from './observation.js'; +import { runBrowserProgram } from './runner.js'; + +class FakeRunPage { + urlValue = 'https://example.test/'; + snapshot = 'button "Save"'; + screenshotBytes = Buffer.from([1, 2, 3]); + locatorScreenshotOptions: unknown; + closed = false; + + url() { return this.urlValue; } + async title() { return 'Example'; } + frames() { return []; } + async goto(url: string) { this.urlValue = url; } + async close() { this.closed = true; } + isClosed() { return this.closed; } + async waitForEvent(): Promise { + throw new Error('No popup configured'); + } + async evaluate(source: unknown) { + if (typeof source === 'string' && source.includes('__webcmd_prev_hashes')) { + return this.snapshot; + } + return null; + } + locator() { + return { + screenshot: async (options: unknown) => { + this.locatorScreenshotOptions = options; + return Buffer.from([4, 5, 6]); + }, + evaluate: async ( + pageFunction: (element: unknown, payload: unknown) => unknown, + payload: unknown, + ) => pageFunction({ tagName: 'MAIN' }, payload), + evaluateAll: async ( + pageFunction: (elements: unknown[], payload: unknown) => unknown, + payload: unknown, + ) => pageFunction([{ tagName: 'MAIN' }], payload), + }; + } + async screenshot() { return this.screenshotBytes; } +} + +const tempDirs: string[] = []; + +async function outcomeWithin( + promise: Promise, + timeoutMs = 250, +): Promise< + | { status: 'resolved' } + | { status: 'rejected'; error: unknown } + | { status: 'hung' } +> { + return Promise.race([ + promise.then( + () => ({ status: 'resolved' as const }), + error => ({ status: 'rejected' as const, error }), + ), + new Promise<{ status: 'hung' }>(resolve => ( + setTimeout(() => resolve({ status: 'hung' }), timeoutMs) + )), + ]); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('runBrowserProgram', () => { + it('publishes the browser-run package subpath', () => { + const packageJson = JSON.parse( + fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), + ) as { exports?: Record }; + + expect(packageJson.exports?.['./browser/run']) + .toBe('./dist/src/browser/run/index.js'); + }); + + it('returns result, logs, final page metadata, and the first full observation', async () => { + const page = new FakeRunPage(); + const output = await runBrowserProgram({ + page: page as never, + pageId: 'page-1', + observationStore: new BrowserRunObservationStore(), + }, ` + const page = await browser.currentPage(); + console.log("ready"); + return { title: await page.title() }; + `); + + expect(output).toEqual({ + ok: true, + result: { title: 'Example' }, + logs: [{ level: 'log', args: ['ready'] }], + page: { + id: 'page-1', + url: 'https://example.test/', + title: 'Example', + }, + observation: { + mode: 'full', + content: 'button "Save"', + }, + limits: { + outputTruncated: false, + observationTruncated: false, + }, + }); + }); + + it('returns a compact diff on a later run for the same document', async () => { + const page = new FakeRunPage(); + const observationStore = new BrowserRunObservationStore(); + const input = { + page: page as never, + pageId: 'page-1', + observationStore, + }; + + await runBrowserProgram(input, 'return null;'); + page.snapshot = 'button "Save"\nstatus "Saved"'; + const output = await runBrowserProgram(input, 'return null;'); + + expect(output.observation).toEqual({ + mode: 'diff', + changed: '+ status "Saved"', + }); + }); + + it('terminates infinite guest code with a typed timeout', async () => { + const page = new FakeRunPage(); + await expect(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, 'while (true) {}', { + timeoutMs: 25, + observe: 'none', + })).rejects.toMatchObject({ + code: 'BROWSER_RUN_TIMEOUT', + }); + }); + + it('compiles source as a function body without allowing wrapper escape', async () => { + const page = new FakeRunPage(); + + await expect(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, '})(); }); new Promise(() => {}) //', { + timeoutMs: 25, + observe: 'none', + })).rejects.toMatchObject({ + code: 'BROWSER_RUN_SYNTAX_ERROR', + }); + }); + + it('returns a wall timeout without closing the selected page or awaiting its in-flight operation', async () => { + const page = new FakeRunPage(); + page.goto = async () => await new Promise(() => {}); + page.close = async () => { + page.closed = true; + }; + + const outcome = await outcomeWithin(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, ` + const page = await browser.currentPage(); + await page.goto("https://slow.example/"); + `, { + timeoutMs: 25, + observe: 'none', + })); + + expect(outcome).toMatchObject({ + status: 'rejected', + error: { + code: 'BROWSER_RUN_TIMEOUT', + }, + }); + expect(page.closed).toBe(false); + }); + + it('returns a wall timeout without closing or awaiting an in-flight popup', async () => { + const page = new FakeRunPage(); + const popup = new FakeRunPage(); + page.waitForEvent = async () => popup; + popup.goto = async () => await new Promise(() => {}); + popup.close = async () => { + popup.closed = true; + await new Promise(() => {}); + }; + + const outcome = await outcomeWithin(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + registerPage: () => 'page-popup', + }, ` + const page = await browser.currentPage(); + const popup = await page.waitForEvent("popup"); + await popup.goto("https://slow.example/"); + `, { + timeoutMs: 25, + observe: 'none', + })); + + expect(outcome).toMatchObject({ + status: 'rejected', + error: { + code: 'BROWSER_RUN_TIMEOUT', + }, + }); + expect(page.closed).toBe(false); + expect(popup.closed).toBe(false); + }); + + it('bounds console capture before logs accumulate in the host', async () => { + const page = new FakeRunPage(); + const output = await runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, ` + for (let index = 0; index < 100; index += 1) { + console.log("x".repeat(100)); + } + return null; + `, { + maxOutputChars: 200, + observe: 'none', + }); + + expect(JSON.stringify(output.logs).length).toBeLessThanOrEqual(200); + expect(output.limits.outputTruncated).toBe(true); + }); + + it('redacts credentials from page metadata and execution errors', async () => { + const page = new FakeRunPage(); + page.urlValue = 'https://alice:secret@example.test/path?token=secret'; + + const output = await runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, 'return null;', { + observe: 'none', + }); + expect(output.page.url).toBe( + 'https://[REDACTED]@example.test/path?token=[REDACTED]', + ); + + await expect(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, ` + throw new Error( + "failed https://alice:secret@example.test/path?token=secret" + ); + `, { + observe: 'none', + })).rejects.toMatchObject({ + message: expect.not.stringContaining('secret'), + }); + }); + + it('redacts credentials from the returned result', async () => { + const page = new FakeRunPage(); + const output = await runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, ` + return { + token: "secret123", + authorization: "Bearer abc123", + nested: { apiKey: "key456", value: "safe" } + }; + `, { + observe: 'none', + }); + + expect(output.result).toEqual({ + token: '[REDACTED]', + authorization: '[REDACTED]', + nested: { + apiKey: '[REDACTED]', + value: 'safe', + }, + }); + }); + + it('rejects non-serializable and oversized returned values', async () => { + const page = new FakeRunPage(); + await expect(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, 'return 1n;', { + observe: 'none', + })).rejects.toMatchObject({ + code: 'BROWSER_RUN_SERIALIZATION_ERROR', + }); + + await expect(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, 'return "x".repeat(100);', { + maxOutputChars: 20, + observe: 'none', + })).rejects.toMatchObject({ + code: 'BROWSER_RUN_OUTPUT_LIMIT', + }); + }); + + it('applies redaction and result limits to locator evaluation values', async () => { + const page = new FakeRunPage(); + const redacted = await runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, ` + return await page.locator("main").evaluate(() => ({ + token: "secret123", + authorization: "Bearer abc123", + safe: "visible" + })); + `, { + observe: 'none', + }); + + expect(redacted.result).toEqual({ + token: '[REDACTED]', + authorization: '[REDACTED]', + safe: 'visible', + }); + + await expect(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, ` + return await page.locator("main").evaluateAll( + () => "x".repeat(100) + ); + `, { + maxOutputChars: 20, + observe: 'none', + })).rejects.toMatchObject({ + code: 'BROWSER_RUN_OUTPUT_LIMIT', + }); + + await expect(runBrowserProgram({ + page: page as never, + pageId: 'page-1', + }, ` + return await page.locator("main").evaluate(() => 1n); + `, { + observe: 'none', + })).rejects.toMatchObject({ + code: 'BROWSER_RUN_SERIALIZATION_ERROR', + }); + }); + + it('writes screenshot bytes under the host-selected artifact directory', async () => { + const page = new FakeRunPage(); + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-run-')); + tempDirs.push(baseDir); + const output = await runBrowserProgram({ + page: page as never, + pageId: 'page-1', + artifactWriter: new BrowserRunArtifactWriter({ baseDir }), + }, ` + const page = await browser.currentPage(); + return await page.screenshot(); + `, { + observe: 'none', + }); + + expect(output.result).toMatchObject({ + kind: 'screenshot', + contentType: 'image/png', + byteSize: 3, + }); + const receipt = output.result as { path: string }; + expect(receipt.path.startsWith(baseDir + path.sep)).toBe(true); + expect(fs.readFileSync(receipt.path)).toEqual(Buffer.from([1, 2, 3])); + }); + + it('writes locator screenshots without honoring agent-selected host paths', async () => { + const page = new FakeRunPage(); + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-run-')); + const requestedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-requested-')); + tempDirs.push(baseDir, requestedDir); + const requestedPath = path.join(requestedDir, 'agent-selected.png'); + const output = await runBrowserProgram({ + page: page as never, + pageId: 'page-1', + artifactWriter: new BrowserRunArtifactWriter({ baseDir }), + }, ` + return await page.locator("main").screenshot({ + path: ${JSON.stringify(requestedPath)} + }); + `, { + observe: 'none', + }); + + expect(output.result).toMatchObject({ + kind: 'screenshot', + contentType: 'image/png', + byteSize: 3, + }); + const receipt = output.result as { path: string }; + expect(receipt.path.startsWith(baseDir + path.sep)).toBe(true); + expect(fs.readFileSync(receipt.path)).toEqual(Buffer.from([4, 5, 6])); + expect(fs.existsSync(requestedPath)).toBe(false); + expect(page.locatorScreenshotOptions).toEqual({ + type: 'png', + path: undefined, + }); + }); +}); diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts new file mode 100644 index 00000000..ff33cc7b --- /dev/null +++ b/src/browser/run/runner.ts @@ -0,0 +1,354 @@ +import type { Page as PlaywrightPage } from 'playwright-core'; +import { generateSnapshotJs } from '../dom-snapshot.js'; +import { + redactText, + redactUrl, + redactValue, +} from '../../observation/redaction.js'; +import { BrowserRunArtifactWriter } from './artifacts.js'; +import { + BrowserRunBridge, + initializeBrowserRunSandboxClient, +} from './bridge.js'; +import { BrowserRunObservationStore } from './observation.js'; +import { QuickJSHost } from './quickjs-host.js'; +import { + BROWSER_RUN_DEFAULT_MAX_OUTPUT_CHARS, + BROWSER_RUN_DEFAULT_MEMORY_LIMIT_BYTES, + BROWSER_RUN_DEFAULT_TIMEOUT_MS, + BrowserRunError, + type BrowserRunLogEntry, + type BrowserRunOptions, + type BrowserRunResult, +} from './types.js'; + +export interface BrowserRunProgramHost { + page: PlaywrightPage; + pageId: string; + observationStore?: BrowserRunObservationStore; + artifactWriter?: BrowserRunArtifactWriter; + registerPage?: (page: PlaywrightPage) => string; +} + +function requirePositiveInteger( + value: number | undefined, + fallback: number, + name: string, +): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved <= 0) { + throw new BrowserRunError( + 'BROWSER_RUN_INVALID_INPUT', + `${name} must be a positive integer.`, + ); + } + return resolved; +} + +function normalizeExecutionError(error: unknown): Error { + const sanitize = (value: string): string => redactUrl(redactText(value)); + if (error instanceof BrowserRunError) { + return new BrowserRunError( + error.code, + sanitize(error.message), + error.hint ? sanitize(error.hint) : undefined, + ); + } + if ( + error instanceof Error + && 'code' in error + && typeof error.code === 'string' + && error.code.startsWith('BROWSER_RUN_') + ) { + const normalized = new Error(sanitize(error.message)) as Error & { + code: string; + hint?: string; + }; + normalized.name = error.name; + normalized.code = error.code; + if ( + 'hint' in error + && typeof error.hint === 'string' + ) { + normalized.hint = sanitize(error.hint); + } + return normalized; + } + const message = error instanceof Error ? error.message : String(error); + const errorKind = error instanceof Error ? error.name : ''; + if (/interrupted|execution timeout|timed out/i.test(message)) { + return new BrowserRunError( + 'BROWSER_RUN_TIMEOUT', + 'Browser-run execution exceeded its time limit.', + 'Split the task into a smaller run or increase --timeout.', + ); + } + if (/out of memory|memory limit/i.test(message)) { + return new BrowserRunError( + 'BROWSER_RUN_MEMORY_LIMIT', + 'Browser-run execution exceeded its memory limit.', + ); + } + if (/syntaxerror/i.test(`${errorKind}: ${message}`)) { + return new BrowserRunError( + 'BROWSER_RUN_SYNTAX_ERROR', + sanitize(message), + 'Fix the browser-run JavaScript syntax and retry.', + ); + } + const normalized = new Error(sanitize(message)); + normalized.name = error instanceof Error ? error.name : 'Error'; + return normalized; +} + +function boundedLogs( + logs: BrowserRunLogEntry[], + remainingChars: number, +): { logs: BrowserRunLogEntry[]; truncated: boolean } { + const kept: BrowserRunLogEntry[] = []; + let used = 0; + for (const log of logs) { + const chars = JSON.stringify(log).length; + if (used + chars > remainingChars) { + return { logs: kept, truncated: true }; + } + kept.push(log); + used += chars; + } + return { logs: kept, truncated: false }; +} + +function javascriptStringLiteral(value: string): string { + return JSON.stringify(value) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +async function captureObservation( + page: PlaywrightPage, + pageId: string, + store: BrowserRunObservationStore, + options: Required>, +) { + if (options.observe === 'none') { + return store.record({ + pageId, + url: page.url(), + content: '', + requestedMode: 'none', + maxChars: options.maxOutputChars, + }); + } + let content: string; + try { + const value = await page.evaluate(generateSnapshotJs({ + viewportExpand: 0, + maxDepth: 40, + maxTextLength: 120, + includeScrollInfo: true, + bboxDedup: true, + }) as never); + content = typeof value === 'string' ? value : String(value ?? ''); + } catch { + content = '[semantic observation unavailable]'; + } + return store.record({ + pageId, + url: page.url(), + content, + requestedMode: options.observe, + maxChars: options.maxOutputChars, + }); +} + +export async function runBrowserProgram( + input: BrowserRunProgramHost, + source: string, + options: BrowserRunOptions = {}, +): Promise { + const timeoutMs = requirePositiveInteger( + options.timeoutMs, + BROWSER_RUN_DEFAULT_TIMEOUT_MS, + 'timeoutMs', + ); + const maxOutputChars = requirePositiveInteger( + options.maxOutputChars, + BROWSER_RUN_DEFAULT_MAX_OUTPUT_CHARS, + 'maxOutputChars', + ); + const memoryLimitBytes = requirePositiveInteger( + options.memoryLimitBytes, + BROWSER_RUN_DEFAULT_MEMORY_LIMIT_BYTES, + 'memoryLimitBytes', + ); + const observe = options.observe ?? 'diff'; + if (!['diff', 'full', 'none'].includes(observe)) { + throw new BrowserRunError( + 'BROWSER_RUN_INVALID_INPUT', + 'observe must be diff, full, or none.', + ); + } + + const logs: BrowserRunLogEntry[] = []; + const redactionOptions = { + maxDepth: 8, + maxArrayItems: 100, + maxObjectFields: 100, + maxStringLength: maxOutputChars, + }; + let capturedLogChars = 0; + let logOutputTruncated = false; + const artifactWriter = input.artifactWriter ?? new BrowserRunArtifactWriter(); + const observationStore = input.observationStore + ?? new BrowserRunObservationStore(); + const bridge = new BrowserRunBridge({ + page: input.page, + pageId: input.pageId, + writeScreenshot: (page, screenshotOptions) => ( + artifactWriter.writeScreenshot(page, screenshotOptions) + ), + registerPage: input.registerPage, + }); + const cancellation = new Promise(() => {}); + const host = await QuickJSHost.create({ + memoryLimitBytes, + maxStackSizeBytes: 2 * 1024 * 1024, + cpuTimeoutMs: timeoutMs, + globals: { + __webcmdMaxLogChars: maxOutputChars, + }, + onHostCall: (operation, args) => ( + operation === 'runtime.waitForCancellation' + ? cancellation + : bridge.dispatch(operation, args) + ), + onConsole: (level, args) => { + const entry: BrowserRunLogEntry = { + level, + args: redactValue(args, redactionOptions) as unknown[], + }; + const chars = JSON.stringify(entry).length; + if (capturedLogChars + chars > maxOutputChars) { + logOutputTruncated = true; + return; + } + logs.push(entry); + capturedLogChars += chars; + }, + }); + + let timeout: ReturnType | undefined; + let wallTimedOut = false; + let execution: Promise | undefined; + try { + await initializeBrowserRunSandboxClient(host); + execution = host.executeScript(` + (() => { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const program = new AsyncFunction(${javascriptStringLiteral(source)}); + return __webcmdRaceRun(program).then(__webcmdSerializeResult); + })() + `, { + filename: 'browser-run.js', + }); + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + wallTimedOut = true; + const timeoutError = new BrowserRunError( + 'BROWSER_RUN_TIMEOUT', + `Browser-run execution exceeded ${timeoutMs}ms.`, + 'Split the task into a smaller run or increase --timeout.', + ); + host.cancelPending(timeoutError); + reject(timeoutError); + }, timeoutMs); + }); + execution.catch(() => {}); + const serialized = await Promise.race([execution, deadline]); + if (typeof serialized !== 'string') { + throw new BrowserRunError( + 'BROWSER_RUN_SERIALIZATION_ERROR', + 'Browser-run returned an invalid serialized result.', + ); + } + if (serialized.length > maxOutputChars) { + throw new BrowserRunError( + 'BROWSER_RUN_OUTPUT_LIMIT', + `Browser-run result exceeds the ${maxOutputChars}-character output limit.`, + 'Return a smaller value or increase --max-output.', + ); + } + const result = redactValue( + JSON.parse(serialized) as unknown, + redactionOptions, + ); + const resultChars = JSON.stringify(result).length; + if (resultChars > maxOutputChars) { + throw new BrowserRunError( + 'BROWSER_RUN_OUTPUT_LIMIT', + `Browser-run result exceeds the ${maxOutputChars}-character output limit.`, + 'Return a smaller value or increase --max-output.', + ); + } + const unfinishedBrowserOperations = bridge.hasPendingBrowserOperations(); + const completedError = new BrowserRunError( + 'BROWSER_RUN_CANCELLED', + 'Browser-run execution has ended.', + ); + host.cancelPending(completedError); + bridge.cancel(completedError); + if (unfinishedBrowserOperations) { + throw new BrowserRunError( + 'BROWSER_RUN_CANCELLED', + 'Browser-run ended with an unfinished browser operation.', + 'Await every Playwright operation before returning from browser run.', + ); + } + const bounded = boundedLogs(logs, Math.max(0, maxOutputChars - resultChars)); + const title = await input.page.title().catch(() => ''); + const observation = await captureObservation( + input.page, + input.pageId, + observationStore, + { observe, maxOutputChars }, + ); + return { + ok: true, + result, + logs: bounded.logs, + page: { + id: input.pageId, + url: redactUrl(input.page.url()), + title, + }, + observation: observation.observation, + limits: { + outputTruncated: logOutputTruncated || bounded.truncated, + observationTruncated: observation.truncated, + }, + }; + } catch (error) { + if (wallTimedOut) { + bridge.cancel( + error instanceof Error + ? error + : new BrowserRunError( + 'BROWSER_RUN_TIMEOUT', + 'Browser-run execution exceeded its time limit.', + ), + ); + await execution?.catch(() => {}); + } + throw normalizeExecutionError(error); + } finally { + if (timeout) clearTimeout(timeout); + const completionError = new BrowserRunError( + 'BROWSER_RUN_CANCELLED', + 'Browser-run execution has ended.', + ); + host.cancelPending(completionError); + bridge.cancel(completionError); + bridge.dispose(); + host.dispose(); + } +} diff --git a/src/browser/run/sandbox-client.ts b/src/browser/run/sandbox-client.ts new file mode 100644 index 00000000..ec4040df --- /dev/null +++ b/src/browser/run/sandbox-client.ts @@ -0,0 +1,782 @@ +import { + BROWSER_RUN_PLAYWRIGHT_VERSION, + BROWSER_RUN_PROTOCOL_VERSION, +} from './types.js'; + +/** + * Playwright-compatible capability client that runs inside QuickJS. + * + * It intentionally contains no transport endpoint or Node integration. The + * temporary host call is captured in a closure and removed before user source + * runs; every reachable method maps to an allowlisted bridge operation. + */ +export const BROWSER_RUN_SANDBOX_CLIENT_SOURCE = ` +(async () => { + const hostCall = globalThis.__webcmdHostCall; + if (typeof hostCall !== "function") { + throw new Error("Browser-run host call is unavailable."); + } + + const nativeConsole = globalThis.console; + const configuredLogChars = Number(globalThis.__webcmdMaxLogChars); + const maxLogChars = Number.isFinite(configuredLogChars) && configuredLogChars > 0 + ? Math.min(Math.floor(configuredLogChars), 65536) + : 65536; + if (!delete globalThis.__webcmdMaxLogChars) { + globalThis.__webcmdMaxLogChars = undefined; + } + const boundedConsoleArgs = (args) => { + const seen = new WeakSet(); + try { + const serialized = JSON.stringify(args, (_key, value) => { + const type = typeof value; + if (type === "bigint") return String(value) + "n"; + if (type === "function" || type === "symbol") return "[" + type + "]"; + if (value && type === "object") { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + } + return value; + }) || "[]"; + if (serialized.length > maxLogChars) { + return [ + "[browser-run console truncated] " + + serialized.slice(0, maxLogChars) + ]; + } + return JSON.parse(serialized); + } catch { + return ["[browser-run console value unavailable]"]; + } + }; + const boundedConsole = Object.create(null); + for (const level of ["log", "warn", "error", "info"]) { + Object.defineProperty(boundedConsole, level, { + value: (...args) => nativeConsole[level](...boundedConsoleArgs(args)), + enumerable: true + }); + } + Object.freeze(boundedConsole); + Object.defineProperty(globalThis, "console", { + value: boundedConsole, + configurable: false, + enumerable: false, + writable: false + }); + + const base64Alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const encodeBase64 = (bytes) => { + let result = ""; + for (let index = 0; index < bytes.length; index += 3) { + const chunk = + (bytes[index] << 16) + | ((bytes[index + 1] || 0) << 8) + | (bytes[index + 2] || 0); + result += base64Alphabet[(chunk >> 18) & 63]; + result += base64Alphabet[(chunk >> 12) & 63]; + result += index + 1 < bytes.length + ? base64Alphabet[(chunk >> 6) & 63] + : "="; + result += index + 2 < bytes.length + ? base64Alphabet[chunk & 63] + : "="; + } + return result; + }; + const decodeBase64 = (input) => { + const value = String(input); + if ( + value.length % 4 !== 0 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + throw new TypeError("Invalid base64 input."); + } + const output = []; + for (let index = 0; index < value.length; index += 4) { + const a = base64Alphabet.indexOf(value[index]); + const b = base64Alphabet.indexOf(value[index + 1]); + const c = value[index + 2] === "=" + ? 64 + : base64Alphabet.indexOf(value[index + 2]); + const d = value[index + 3] === "=" + ? 64 + : base64Alphabet.indexOf(value[index + 3]); + const chunk = (a << 18) | (b << 12) | ((c & 63) << 6) | (d & 63); + output.push((chunk >> 16) & 255); + if (c !== 64) output.push((chunk >> 8) & 255); + if (d !== 64) output.push(chunk & 255); + } + return new Uint8Array(output); + }; + const encodeUtf8 = (input) => { + const encoded = encodeURIComponent(String(input)); + const output = []; + for (let index = 0; index < encoded.length; index += 1) { + if (encoded[index] === "%") { + output.push(parseInt(encoded.slice(index + 1, index + 3), 16)); + index += 2; + } else { + output.push(encoded.charCodeAt(index)); + } + } + return new Uint8Array(output); + }; + const decodeUtf8 = (bytes) => { + let encoded = ""; + for (const byte of bytes) { + encoded += "%" + byte.toString(16).padStart(2, "0"); + } + return decodeURIComponent(encoded); + }; + + class SandboxBuffer extends Uint8Array { + static isBuffer(value) { + return value instanceof SandboxBuffer; + } + + static from(value, encoding) { + if (typeof value === "string") { + if (encoding === undefined || encoding === "utf8" || encoding === "utf-8") { + return new SandboxBuffer(encodeUtf8(value)); + } + if (encoding === "base64") { + return new SandboxBuffer(decodeBase64(value)); + } + throw new TypeError("QuickJS Buffer supports only utf8 and base64 strings."); + } + if (value instanceof ArrayBuffer) { + return new SandboxBuffer(value.slice(0)); + } + if (ArrayBuffer.isView(value)) { + return new SandboxBuffer( + value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) + ); + } + if (Array.isArray(value)) { + return new SandboxBuffer(value); + } + throw new TypeError("Unsupported Buffer.from input."); + } + + toString(encoding) { + if (encoding === undefined || encoding === "utf8" || encoding === "utf-8") { + return decodeUtf8(this); + } + if (encoding === "base64") { + return encodeBase64(this); + } + throw new TypeError("QuickJS Buffer supports only utf8 and base64 output."); + } + } + Object.freeze(SandboxBuffer.prototype); + Object.freeze(SandboxBuffer); + Object.defineProperty(globalThis, "Buffer", { + value: SandboxBuffer, + configurable: false, + enumerable: true, + writable: false + }); + + const encode = (value) => { + if (value === undefined) return { $type: "Undefined" }; + if (value instanceof RegExp) { + return { $type: "RegExp", source: value.source, flags: value.flags }; + } + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + const bytes = value instanceof ArrayBuffer + ? new Uint8Array(value) + : new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + return { + $type: "Bytes", + encoding: "base64", + data: encodeBase64(bytes) + }; + } + if (Array.isArray(value)) return value.map(encode); + if (value && typeof value === "object") { + const encoded = {}; + for (const key of Object.keys(value)) encoded[key] = encode(value[key]); + return encoded; + } + return value; + }; + const rpc = (operation, ...args) => ( + hostCall(operation, JSON.stringify(encode(args))) + ); + await rpc("handshake", { + protocolVersion: ${BROWSER_RUN_PROTOCOL_VERSION}, + playwrightVersion: ${JSON.stringify(BROWSER_RUN_PLAYWRIGHT_VERSION)} + }); + const runCancellation = rpc("runtime.waitForCancellation"); + + const remoteCache = new Map(); + const serializationError = () => { + const error = new Error("Playwright remote objects cannot be returned from browser run."); + error.code = "BROWSER_RUN_SERIALIZATION_ERROR"; + return error; + }; + const unsupported = (name) => rpc("unsupported", name); + const guardSurface = (target, surface) => { + Object.freeze(target); + return new Proxy(target, { + get(object, property, receiver) { + if (property === "then") return undefined; + if (typeof property !== "string" || Reflect.has(object, property)) { + return Reflect.get(object, property, receiver); + } + return () => unsupported(surface + "." + property); + } + }); + }; + const valueAndState = (result, state) => { + if (result && result.state) Object.assign(state, result.state); + return result ? result.value : undefined; + }; + + const locatorFrom = (originType, originHandle, pageState, frameState, recipe) => { + const append = (method, args) => ( + locatorFrom( + originType, + originHandle, + pageState, + frameState, + recipe.concat([{ method, args }]) + ) + ); + const call = async (method, args) => { + const result = await rpc("locator.call", { + originType, + originHandle, + recipe, + method, + args + }); + if (result && result.pageState) Object.assign(pageState, result.pageState); + if (result && result.frameState && frameState) { + Object.assign(frameState, result.frameState); + } + return result ? result.value : undefined; + }; + + const locator = Object.create(null); + const properties = { + locator: (selector, options) => append("locator", [selector, options]), + getByRole: (role, options) => append("getByRole", [role, options]), + getByText: (text, options) => append("getByText", [text, options]), + getByLabel: (text, options) => append("getByLabel", [text, options]), + getByPlaceholder: (text, options) => append("getByPlaceholder", [text, options]), + getByAltText: (text, options) => append("getByAltText", [text, options]), + getByTitle: (text, options) => append("getByTitle", [text, options]), + getByTestId: (id) => append("getByTestId", [id]), + filter: (options) => { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("locator.filter() requires an options object."); + } + const encoded = { ...options }; + for (const key of ["has", "hasNot"]) { + if (encoded[key] === undefined) continue; + const nested = encoded[key]; + if ( + !nested + || !nested.__recipe + || nested.__originType !== originType + || nested.__originHandle !== originHandle + ) { + throw new TypeError( + "locator.filter(" + key + ") requires a locator from the same page or frame." + ); + } + encoded[key] = { $locatorRecipe: nested.__recipe }; + } + return append("filter", [encoded]); + }, + first: () => append("first", []), + last: () => append("last", []), + nth: (index) => append("nth", [index]), + all: async () => { + const length = await call("count", []); + return Array.from({ length }, (_, index) => append("nth", [index])); + }, + click: (options) => call("click", [options || {}]), + dblclick: (options) => call("dblclick", [options || {}]), + hover: (options) => call("hover", [options || {}]), + focus: (options) => call("focus", [options || {}]), + fill: (value, options) => call("fill", [value, options || {}]), + press: (key, options) => call("press", [key, options || {}]), + type: (value, options) => call("type", [value, options || {}]), + clear: (options) => call("clear", [options || {}]), + dispatchEvent: (type, eventInit) => ( + call("dispatchEvent", [type, eventInit || {}]) + ), + selectOption: (value, options) => call("selectOption", [value, options || {}]), + setChecked: (value, options) => call("setChecked", [value, options || {}]), + check: (options) => call("setChecked", [true, options || {}]), + uncheck: (options) => call("setChecked", [false, options || {}]), + setInputFiles: (files, options) => ( + call("setInputFiles", [files, options || {}]) + ), + dragTo: (target, options) => { + if (!target || !target.__recipe) return unsupported("locator.dragTo"); + return call("dragTo", [target.__recipe, options || {}]); + }, + screenshot: (options) => call("screenshot", [options || {}]), + scrollIntoViewIfNeeded: (options) => ( + call("scrollIntoViewIfNeeded", [options || {}]) + ), + textContent: (options) => call("textContent", [options || {}]), + innerText: (options) => call("innerText", [options || {}]), + innerHTML: (options) => call("innerHTML", [options || {}]), + inputValue: (options) => call("inputValue", [options || {}]), + getAttribute: (name, options) => ( + call("getAttribute", [name, options || {}]) + ), + isVisible: (options) => call("isVisible", [options || {}]), + isHidden: (options) => call("isHidden", [options || {}]), + isEnabled: (options) => call("isEnabled", [options || {}]), + isDisabled: (options) => call("isDisabled", [options || {}]), + isEditable: (options) => call("isEditable", [options || {}]), + isChecked: (options) => call("isChecked", [options || {}]), + count: () => call("count", []), + allInnerTexts: () => call("allInnerTexts", []), + allTextContents: () => call("allTextContents", []), + evaluate: (pageFunction, ...args) => call("evaluate", [{ + source: String(pageFunction), + args + }]), + evaluateAll: (pageFunction, ...args) => call("evaluateAll", [{ + source: String(pageFunction), + args + }]), + waitFor: (options) => call("waitFor", [options || {}]), + toJSON: () => { throw serializationError(); } + }; + for (const [name, value] of Object.entries(properties)) { + Object.defineProperty(locator, name, { + value, + enumerable: name !== "toJSON" + }); + } + Object.defineProperty(locator, "__recipe", { value: recipe }); + Object.defineProperty(locator, "__originType", { value: originType }); + Object.defineProperty(locator, "__originHandle", { value: originHandle }); + return guardSurface(locator, "locator"); + }; + + const locatorRoot = (type, handle, pageState, frameState, method, args) => ( + locatorFrom(type, handle, pageState, frameState, [{ method, args }]) + ); + + const frameFrom = (descriptor, pageState) => { + if (!descriptor || !descriptor.$remote || descriptor.$remote.type !== "frame") { + throw new Error("Browser-run bridge returned an invalid frame descriptor."); + } + const { handle, state = {} } = descriptor.$remote; + const cached = remoteCache.get(handle); + if (cached) { + Object.assign(cached.__state, state); + return cached.value; + } + const frameState = { ...state }; + const call = async (method, args) => ( + valueAndState(await rpc("frame.call", { handle, method, args }), frameState) + ); + const frame = Object.create(null); + const properties = { + name: () => String(frameState.name || ""), + url: () => String(frameState.url || ""), + locator: (selector, options) => ( + locatorRoot("frame", handle, pageState, frameState, "locator", [selector, options]) + ), + getByRole: (role, options) => ( + locatorRoot("frame", handle, pageState, frameState, "getByRole", [role, options]) + ), + getByText: (text, options) => ( + locatorRoot("frame", handle, pageState, frameState, "getByText", [text, options]) + ), + getByLabel: (text, options) => ( + locatorRoot("frame", handle, pageState, frameState, "getByLabel", [text, options]) + ), + getByPlaceholder: (text, options) => ( + locatorRoot("frame", handle, pageState, frameState, "getByPlaceholder", [text, options]) + ), + getByAltText: (text, options) => ( + locatorRoot("frame", handle, pageState, frameState, "getByAltText", [text, options]) + ), + getByTitle: (text, options) => ( + locatorRoot("frame", handle, pageState, frameState, "getByTitle", [text, options]) + ), + getByTestId: (id) => ( + locatorRoot("frame", handle, pageState, frameState, "getByTestId", [id]) + ), + evaluate: (pageFunction, ...args) => call("evaluate", [{ + source: String(pageFunction), + args + }]), + content: () => call("content", []), + waitForLoadState: (state, options) => ( + call("waitForLoadState", [state, options || {}]) + ), + waitForURL: (url, options) => call("waitForURL", [url, options || {}]), + waitForSelector: async (selector, options) => { + const target = locatorRoot( + "frame", + handle, + pageState, + frameState, + "locator", + [selector, undefined] + ); + await target.waitFor(options || {}); + return target; + }, + toJSON: () => { throw serializationError(); } + }; + for (const [name, value] of Object.entries(properties)) { + Object.defineProperty(frame, name, { + value, + enumerable: name !== "toJSON" + }); + } + const guardedFrame = guardSurface(frame, "frame"); + remoteCache.set(handle, { value: guardedFrame, __state: frameState }); + return guardedFrame; + }; + + const requestFrom = (descriptor) => { + if (!descriptor || !descriptor.$remote || descriptor.$remote.type !== "request") { + throw new Error("Browser-run bridge returned an invalid request descriptor."); + } + const { handle, state = {} } = descriptor.$remote; + const cached = remoteCache.get(handle); + if (cached) { + Object.assign(cached.__state, state); + return cached.value; + } + const requestState = { ...state }; + const request = Object.create(null); + const properties = { + url: () => String(requestState.url || ""), + method: () => String(requestState.method || ""), + resourceType: () => String(requestState.resourceType || ""), + headers: () => ({ ...(requestState.headers || {}) }), + allHeaders: () => rpc("request.call", { handle, method: "allHeaders", args: [] }), + postData: () => requestState.postData == null ? null : String(requestState.postData), + failure: () => requestState.failure == null ? null : requestState.failure, + toJSON: () => { throw serializationError(); } + }; + for (const [name, value] of Object.entries(properties)) { + Object.defineProperty(request, name, { + value, + enumerable: name !== "toJSON" + }); + } + Object.freeze(request); + remoteCache.set(handle, { value: request, __state: requestState }); + return request; + }; + + const responseFrom = (descriptor) => { + if (!descriptor || !descriptor.$remote || descriptor.$remote.type !== "response") { + throw new Error("Browser-run bridge returned an invalid response descriptor."); + } + const { handle, state = {} } = descriptor.$remote; + const cached = remoteCache.get(handle); + if (cached) { + Object.assign(cached.__state, state); + return cached.value; + } + const responseState = { ...state }; + const response = Object.create(null); + const properties = { + url: () => String(responseState.url || ""), + status: () => Number(responseState.status || 0), + ok: () => Boolean(responseState.ok), + headers: () => ({ ...(responseState.headers || {}) }), + allHeaders: () => rpc("response.call", { handle, method: "allHeaders", args: [] }), + request: () => requestFrom(responseState.request), + body: async () => new Uint8Array( + await rpc("response.call", { handle, method: "body", args: [] }) + ), + text: () => rpc("response.call", { handle, method: "text", args: [] }), + json: () => rpc("response.call", { handle, method: "json", args: [] }), + toJSON: () => { throw serializationError(); } + }; + for (const [name, value] of Object.entries(properties)) { + Object.defineProperty(response, name, { + value, + enumerable: name !== "toJSON" + }); + } + Object.freeze(response); + remoteCache.set(handle, { value: response, __state: responseState }); + return response; + }; + + const networkValueFrom = (event, descriptor) => ( + event === "request" ? requestFrom(descriptor) : responseFrom(descriptor) + ); + const networkMatches = async (value, predicate) => { + if (typeof predicate === "function") return Boolean(await predicate(value)); + if (predicate instanceof RegExp) return predicate.test(value.url()); + if (typeof predicate === "string") return value.url().includes(predicate); + throw new TypeError("Network predicate must be a function, RegExp, or string."); + }; + const waitForNetwork = async (pageHandle, event, predicate, options) => { + const id = await rpc("network.start", { pageHandle, event }); + let timeoutId; + try { + const loop = async () => { + while (true) { + const descriptor = await rpc("network.next", { id }); + const value = networkValueFrom(event, descriptor); + if (await networkMatches(value, predicate)) return value; + } + }; + const timeout = options && Number(options.timeout); + if (!(timeout > 0)) return await loop(); + return await Promise.race([ + loop(), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const error = new Error( + "Timed out waiting for browser-run " + event + " event." + ); + error.code = "BROWSER_RUN_TIMEOUT"; + reject(error); + }, timeout); + }) + ]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + await rpc("network.stop", { id }); + } + }; + + const pageFrom = (descriptor) => { + if (!descriptor || !descriptor.$remote || descriptor.$remote.type !== "page") { + throw new Error("Browser-run bridge returned an invalid page descriptor."); + } + const { handle, state = {} } = descriptor.$remote; + const cached = remoteCache.get(handle); + if (cached) { + Object.assign(cached.__state, state); + return cached.value; + } + + const pageState = { ...state }; + let guardedPage; + const call = async (method, args) => ( + valueAndState(await rpc("page.call", { handle, method, args }), pageState) + ); + const eventListeners = new Map(); + const on = (event, listener) => { + if ((event !== "request" && event !== "response") || typeof listener !== "function") { + return unsupported("page.on(" + String(event) + ")"); + } + let listeners = eventListeners.get(event); + if (!listeners) { + listeners = new Map(); + eventListeners.set(event, listeners); + } + if (listeners.has(listener)) return guardedPage; + const control = { stopped: false, id: undefined }; + listeners.set(listener, control); + (async () => { + try { + control.id = await rpc("network.start", { pageHandle: handle, event }); + while (!control.stopped) { + const descriptor = await rpc("network.next", { id: control.id }); + if (!control.stopped) { + await listener(networkValueFrom(event, descriptor)); + } + } + } catch (error) { + if ( + !control.stopped + && (!error || error.code !== "BROWSER_RUN_CANCELLED") + ) { + console.error(error); + } + } finally { + if (control.id !== undefined) { + await rpc("network.stop", { id: control.id }); + } + } + })(); + return guardedPage; + }; + const off = (event, listener) => { + const listeners = eventListeners.get(event); + const control = listeners && listeners.get(listener); + if (control) { + control.stopped = true; + listeners.delete(listener); + if (control.id !== undefined) { + rpc("network.stop", { id: control.id }); + } + } + return guardedPage; + }; + const page = Object.create(null); + const properties = { + url: () => String(pageState.url || ""), + title: () => call("title", []), + content: () => call("content", []), + goto: (url, options) => call("goto", [url, options || {}]), + reload: (options) => call("reload", [options || {}]), + goBack: (options) => call("goBack", [options || {}]), + goForward: (options) => call("goForward", [options || {}]), + waitForLoadState: (state, options) => ( + call("waitForLoadState", [state, options || {}]) + ), + waitForURL: (url, options) => call("waitForURL", [url, options || {}]), + waitForTimeout: (timeout) => new Promise((resolve) => { + setTimeout(resolve, Number(timeout)); + }), + evaluate: (pageFunction, ...args) => call("evaluate", [{ + source: String(pageFunction), + args + }]), + waitForSelector: async (selector, options) => { + const target = locatorRoot( + "page", + handle, + pageState, + null, + "locator", + [selector, undefined] + ); + await target.waitFor(options || {}); + return target; + }, + frames: () => (pageState.frames || []).map((item) => frameFrom(item, pageState)), + waitForEvent: async (name, options) => { + const result = await call("waitForEvent", [name, options || {}]); + return pageFrom(result); + }, + screenshot: (options) => call("screenshot", [options || {}]), + waitForRequest: (predicate, options) => ( + waitForNetwork(handle, "request", predicate, options || {}) + ), + waitForResponse: (predicate, options) => ( + waitForNetwork(handle, "response", predicate, options || {}) + ), + on, + off, + locator: (selector, options) => ( + locatorRoot("page", handle, pageState, null, "locator", [selector, options]) + ), + getByRole: (role, options) => ( + locatorRoot("page", handle, pageState, null, "getByRole", [role, options]) + ), + getByText: (text, options) => ( + locatorRoot("page", handle, pageState, null, "getByText", [text, options]) + ), + getByLabel: (text, options) => ( + locatorRoot("page", handle, pageState, null, "getByLabel", [text, options]) + ), + getByPlaceholder: (text, options) => ( + locatorRoot("page", handle, pageState, null, "getByPlaceholder", [text, options]) + ), + getByAltText: (text, options) => ( + locatorRoot("page", handle, pageState, null, "getByAltText", [text, options]) + ), + getByTitle: (text, options) => ( + locatorRoot("page", handle, pageState, null, "getByTitle", [text, options]) + ), + getByTestId: (id) => ( + locatorRoot("page", handle, pageState, null, "getByTestId", [id]) + ), + context: () => unsupported("page.context"), + toJSON: () => { throw serializationError(); } + }; + for (const [name, value] of Object.entries(properties)) { + Object.defineProperty(page, name, { + value, + enumerable: name !== "toJSON" + }); + } + guardedPage = guardSurface(page, "page"); + remoteCache.set(handle, { value: guardedPage, __state: pageState }); + return guardedPage; + }; + + const browserApi = Object.create(null); + Object.defineProperties(browserApi, { + currentPage: { + value: async () => pageFrom(await rpc("browser.currentPage")), + enumerable: true + }, + getPage: { + value: async (nameOrId) => pageFrom(await rpc("browser.getPage", nameOrId)), + enumerable: true + }, + pages: { + value: async () => { + const pages = await rpc("browser.pages"); + return pages.map(pageFrom); + }, + enumerable: true + } + }); + Object.freeze(browserApi); + + if (!delete globalThis.__webcmdHostCall) { + globalThis.__webcmdHostCall = undefined; + } + const serializeResult = (input) => { + const seen = new WeakSet(); + try { + const json = JSON.stringify(input === undefined ? null : input, (_key, value) => { + const type = typeof value; + if (type === "function" || type === "symbol" || type === "bigint") { + throw new TypeError("Browser-run results must contain only JSON-compatible values."); + } + if (value && type === "object") { + if (seen.has(value)) { + throw new TypeError("Browser-run results cannot contain circular references."); + } + seen.add(value); + } + return value === undefined ? null : value; + }); + return json === undefined ? "null" : json; + } catch (cause) { + if (cause && cause.code === "BROWSER_RUN_SERIALIZATION_ERROR") throw cause; + const error = new Error(cause && cause.message + ? cause.message + : "Browser-run result is not serializable."); + error.code = "BROWSER_RUN_SERIALIZATION_ERROR"; + throw error; + } + }; + Object.defineProperty(globalThis, "__webcmdSerializeResult", { + value: serializeResult, + configurable: false, + enumerable: false, + writable: false + }); + Object.defineProperty(globalThis, "__webcmdRaceRun", { + value: (program) => Promise.race([ + Promise.resolve().then(program), + runCancellation + ]), + configurable: false, + enumerable: false, + writable: false + }); + Object.defineProperty(globalThis, "browser", { + value: browserApi, + configurable: false, + enumerable: true, + writable: false + }); + Object.defineProperty(globalThis, "page", { + value: await browserApi.currentPage(), + configurable: false, + enumerable: true, + writable: false + }); +})() +`; diff --git a/src/browser/run/types.ts b/src/browser/run/types.ts new file mode 100644 index 00000000..16fb8b6f --- /dev/null +++ b/src/browser/run/types.ts @@ -0,0 +1,82 @@ +export const BROWSER_RUN_PROTOCOL_VERSION = 1 as const; +export const BROWSER_RUN_PLAYWRIGHT_VERSION = '1.61.1' as const; + +export const BROWSER_RUN_DEFAULT_TIMEOUT_MS = 30_000; +export const BROWSER_RUN_MAX_SOURCE_BYTES = 256 * 1024; +export const BROWSER_RUN_DEFAULT_MAX_OUTPUT_CHARS = 65_536; +export const BROWSER_RUN_DEFAULT_MEMORY_LIMIT_BYTES = 128 * 1024 * 1024; +export const BROWSER_RUN_MAX_RESPONSE_BODY_BYTES = 1024 * 1024; +export const BROWSER_RUN_MAX_UPLOAD_FILES = 8; +export const BROWSER_RUN_MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024; +export const BROWSER_RUN_MAX_UPLOAD_TOTAL_BYTES = 20 * 1024 * 1024; + +export type BrowserRunObserveMode = 'diff' | 'full' | 'none'; + +export type BrowserRunErrorCode = + | 'BROWSER_RUN_INVALID_INPUT' + | 'BROWSER_RUN_SOURCE_LIMIT' + | 'BROWSER_RUN_SYNTAX_ERROR' + | 'BROWSER_RUN_PROTOCOL_MISMATCH' + | 'BROWSER_RUN_API_UNSUPPORTED' + | 'BROWSER_RUN_TIMEOUT' + | 'BROWSER_RUN_MEMORY_LIMIT' + | 'BROWSER_RUN_CANCELLED' + | 'BROWSER_RUN_OUTPUT_LIMIT' + | 'BROWSER_RUN_SERIALIZATION_ERROR'; + +export class BrowserRunError extends Error { + constructor( + readonly code: BrowserRunErrorCode, + message: string, + readonly hint?: string, + ) { + super(message); + this.name = 'BrowserRunError'; + } +} + +export interface BrowserRunOptions { + timeoutMs?: number; + maxOutputChars?: number; + memoryLimitBytes?: number; + observe?: BrowserRunObserveMode; +} + +export interface BrowserRunLogEntry { + level: 'log' | 'info' | 'warn' | 'error'; + args: unknown[]; +} + +export interface BrowserRunPageMetadata { + id: string; + url: string; + title: string; +} + +export type BrowserRunObservation = + | { mode: 'none' } + | { mode: 'full'; content: string } + | { mode: 'diff'; changed: string }; + +export interface BrowserRunLimits { + outputTruncated: boolean; + observationTruncated: boolean; +} + +export interface BrowserRunResult { + ok: true; + result: unknown; + logs: BrowserRunLogEntry[]; + page: BrowserRunPageMetadata; + observation: BrowserRunObservation; + limits: BrowserRunLimits; +} + +export interface BrowserRunScreenshotReceipt { + kind: 'screenshot'; + artifactId: string; + filename: string; + contentType: 'image/png' | 'image/jpeg'; + byteSize: number; + path: string; +} diff --git a/src/observation/redaction.test.ts b/src/observation/redaction.test.ts index 3462bb8a..3f1e77ec 100644 --- a/src/observation/redaction.test.ts +++ b/src/observation/redaction.test.ts @@ -21,6 +21,11 @@ describe('observation redaction', () => { .toBe('https://x.test/api?token=[REDACTED]&ok=1&password=[REDACTED]'); }); + it('redacts credentials embedded in url authority', () => { + expect(redactUrl('https://alice:secret@x.test/private?ok=1')) + .toBe('https://[REDACTED]@x.test/private?ok=1'); + }); + it('redacts password and token fields recursively', () => { expect(redactValue({ user: 'alice', diff --git a/src/observation/redaction.ts b/src/observation/redaction.ts index 1f660380..43fabb89 100644 --- a/src/observation/redaction.ts +++ b/src/observation/redaction.ts @@ -23,7 +23,9 @@ export interface RedactionOptions { } export function redactUrl(url: string): string { - return url.replace(SENSITIVE_URL_PARAMS, '$1$2=[REDACTED]'); + return url + .replace(/(https?:\/\/)[^/\s@]+@/gi, '$1[REDACTED]@') + .replace(SENSITIVE_URL_PARAMS, '$1$2=[REDACTED]'); } export function redactHeaders(headers: Record | undefined, opts: RedactionOptions = {}): Record | undefined { From dc7a75b4e6bdf0b04f7a705460f906c06ed4e9f5 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Wed, 29 Jul 2026 15:00:00 +0530 Subject: [PATCH 02/60] feat(browser): expose browser run through local Cloak sessions --- src/browser/command-catalog.test.ts | 11 +- src/browser/command-catalog.ts | 32 +- src/browser/protocol.ts | 6 + src/browser/runtime/local-cloak/actions.ts | 57 ++++ .../runtime/local-cloak/provider.test.ts | 323 ++++++++++++++++++ src/browser/runtime/local-cloak/provider.ts | 42 ++- .../runtime/local-cloak/session-manager.ts | 14 + src/cli-argv-preprocess.test.ts | 13 +- src/cli-argv-preprocess.ts | 1 + src/cli.test.ts | 48 +++ src/cli.ts | 58 ++++ src/daemon/server.test.ts | 18 + src/hosted/availability.test.ts | 2 + src/hosted/availability.ts | 10 +- src/hosted/browser-args.ts | 2 +- src/hosted/runner.test.ts | 27 +- src/hosted/runner.ts | 8 +- 17 files changed, 655 insertions(+), 17 deletions(-) diff --git a/src/browser/command-catalog.test.ts b/src/browser/command-catalog.test.ts index ed8ac2ea..ef9be88e 100644 --- a/src/browser/command-catalog.test.ts +++ b/src/browser/command-catalog.test.ts @@ -87,15 +87,16 @@ describe('browserCommandCatalog', () => { expect(Object.fromEntries(browserCommandCatalog.map(command => [ command.command, - command.options.map(browserOptionFlags), + command.options.map(option => browserOptionFlags(option, command.command)), ]))).toEqual(Object.fromEntries(local)); }); - it('marks bind as the only local-only command and gives every hosted command an action', () => { + it('marks bind and run as local-only and gives every hosted command an action', () => { expect(browserCommandCatalog.filter(command => command.sessionPolicy === 'local-only').map(command => command.command)) - .toEqual(['bind']); + .toEqual(['bind', 'run']); expect(browserCommandCatalog.find(command => command.command === 'bind')).not.toHaveProperty('action'); - expect(browserCommandCatalog.filter(command => command.command !== 'bind').every(command => command.action)) + expect(browserCommandCatalog.find(command => command.command === 'run')).not.toHaveProperty('action'); + expect(browserCommandCatalog.filter(command => !['bind', 'run'].includes(command.command)).every(command => command.action)) .toBe(true); }); @@ -113,6 +114,6 @@ describe('browserCommandCatalog', () => { expect(contract.browserCommands).toEqual(browserCommandCatalog); expect(contract.browserCommands.filter(command => !command.action).map(command => command.command)) - .toEqual(['bind']); + .toEqual(['bind', 'run']); }); }); diff --git a/src/browser/command-catalog.ts b/src/browser/command-catalog.ts index 3a88a809..1bbe4e88 100644 --- a/src/browser/command-catalog.ts +++ b/src/browser/command-catalog.ts @@ -66,6 +66,7 @@ const BROWSER_OPTION_VALUE_NAMES: Readonly> = { depth: 'n', detail: 'key', filter: 'fields', + file: 'path', frame: 'index', fromLabel: 'text', fromName: 'text', @@ -80,8 +81,10 @@ const BROWSER_OPTION_VALUE_NAMES: Readonly> = { limit: 'n', max: 'n', maxBody: 'chars', + maxOutput: 'characters', name: 'text', nth: 'n', + observe: 'mode', page: 'id', role: 'role', seedArgs: 'value', @@ -107,10 +110,17 @@ const BROWSER_OPTION_VALUE_NAMES: Readonly> = { }; /** Exact local Commander flags for every catalogued browser option. */ -export function browserOptionFlags(option: HostedArgumentContract): string { +export function browserOptionFlags( + option: HostedArgumentContract, + commandPath?: string, +): string { const longName = option.name.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`); if (option.type === 'boolean') return option.name === 'fixture' ? '--no-fixture' : `--${longName}`; - const valueName = BROWSER_OPTION_VALUE_NAMES[option.name]; + const valueName = commandPath === 'run' && option.name === 'timeout' + ? 'seconds' + : commandPath === 'run' && option.name === 'tab' + ? 'page-id' + : BROWSER_OPTION_VALUE_NAMES[option.name]; if (!valueName) throw new Error(`Browser option --${longName} is missing its Commander value name`); return `--${longName} <${valueName}>`; } @@ -401,6 +411,24 @@ export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [ [tabOption], 'create-or-reuse', ), + command( + 'run', + 'Run a sandboxed Playwright-style program against the browser session', + undefined, + [], + [ + flag('stdin', 'Read the browser-run program from stdin', false), + option('file', 'Read the browser-run program from a local file'), + option('timeout', 'Maximum run time in seconds', { default: '30' }), + option('maxOutput', 'Maximum returned result and log characters', { default: '65536' }), + option('observe', 'Final semantic observation: diff, full, or none', { + default: 'diff', + choices: ['diff', 'full', 'none'], + }), + tabOption, + ], + 'local-only', + ), command( 'screenshot', 'Take screenshot', diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index 9372667e..99ce212a 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -15,6 +15,7 @@ export type BrowserRuntimeAction = | 'wait-download' | 'cdp' | 'frames' + | 'run' | 'lease-release'; export type BrowserSurface = 'browser' | 'adapter'; @@ -56,6 +57,11 @@ export interface BrowserRuntimeCommand { windowMode?: BrowserWindowMode; idleTimeout?: number; frameIndex?: number; + /** Agent-authored JavaScript executed by the browser-run QuickJS sandbox. */ + source?: string; + observe?: 'diff' | 'full' | 'none'; + maxOutputChars?: number; + memoryLimitBytes?: number; contextId?: string; preferredContextId?: string; profileId?: string; diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index db5b44a6..0d9c80b7 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -2,6 +2,8 @@ import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol import { waitForDownload } from './downloads.js'; import type { CloakSessionManager } from './session-manager.js'; import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core'; +import { runBrowserProgram } from '../../run/runner.js'; +import { BROWSER_RUN_MAX_SOURCE_BYTES } from '../../run/types.js'; class CloakActionError extends Error { constructor( @@ -123,6 +125,44 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: const data = await target.evaluate(command.code); return { id: command.id, ok: true, data, page: lease.pageId }; } + case 'run': { + if (typeof command.source !== 'string' || !command.source.trim()) { + return invalidRequest(command, 'Missing source'); + } + if (Buffer.byteLength(command.source, 'utf8') > BROWSER_RUN_MAX_SOURCE_BYTES) { + return { + id: command.id, + ok: false, + errorCode: 'BROWSER_RUN_SOURCE_LIMIT', + error: `Browser-run source exceeds the ${BROWSER_RUN_MAX_SOURCE_BYTES}-byte limit.`, + }; + } + const lease = await resolveLease(manager, command); + const data = await runBrowserProgram({ + page: lease.page, + pageId: lease.pageId, + observationStore: manager.browserRunObservations, + registerPage: (page) => manager.registerPage({ + profileId: lease.profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + idleTimeout: command.idleTimeout, + windowMode: command.windowMode, + }, page), + }, command.source, { + timeoutMs: command.timeoutMs, + maxOutputChars: command.maxOutputChars, + memoryLimitBytes: command.memoryLimitBytes, + observe: command.observe, + }); + return { + id: command.id, + ok: true, + data, + page: lease.pageId, + }; + } case 'cookies': { const lease = await resolveLease(manager, command); const cookies = await lease.context.cookies(command.url ? [command.url] : undefined); @@ -273,6 +313,23 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: if (err instanceof CloakActionError) { return { id: command.id, ok: false, errorCode: err.errorCode, error: err.message, ...(err.page && { page: err.page }), ...(err.errorHint && { errorHint: err.errorHint }) }; } + if ( + err instanceof Error + && 'code' in err + && typeof err.code === 'string' + && err.code.startsWith('BROWSER_RUN_') + ) { + const hint = 'hint' in err && typeof err.hint === 'string' + ? err.hint + : undefined; + return { + id: command.id, + ok: false, + errorCode: err.code, + error: err.message, + ...(hint && { errorHint: hint }), + }; + } return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: err instanceof Error ? err.message : String(err) }; } } diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index cb0b2178..aa264e5d 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -19,6 +19,7 @@ function fakePage(url: string, initialViewport: { width: number; height: number viewportSize = size; }), locator: vi.fn(), + waitForEvent: vi.fn(), bringToFront: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockImplementation(async () => { closed = true; @@ -95,6 +96,328 @@ describe('LocalCloakRuntimeProvider', () => { .resolves.toMatchObject({ id: 'exec', ok: true, data: { ok: true }, page: nav.page }); }); + it('runs Playwright-style source against the selected Cloak page', async () => { + const { provider } = makeProviderWithFakePage(); + + await expect(provider.dispatch({ + id: 'run', + action: 'run', + session: 'work', + surface: 'browser', + source: ` + const page = await browser.currentPage(); + return page.url(); + `, + observe: 'none', + profileId: 'default', + })).resolves.toMatchObject({ + id: 'run', + ok: true, + page: expect.any(String), + data: { + ok: true, + result: 'https://example.com/', + observation: { mode: 'none' }, + }, + }); + }); + + it('rejects oversized run source even when the daemon is called directly', async () => { + const { provider } = makeProviderWithFakePage(); + + await expect(provider.dispatch({ + id: 'run-large', + action: 'run', + session: 'work', + surface: 'browser', + source: 'x'.repeat(256 * 1024 + 1), + profileId: 'default', + })).resolves.toMatchObject({ + id: 'run-large', + ok: false, + errorCode: 'BROWSER_RUN_SOURCE_LIMIT', + }); + }); + + it('does not route existing exec commands through the QuickJS runner', async () => { + const { provider, page } = makeProviderWithFakePage(); + + await provider.dispatch({ + id: 'exec', + action: 'exec', + session: 'work', + surface: 'browser', + code: 'document.title', + profileId: 'default', + }); + + expect(page.evaluate).toHaveBeenCalledWith('document.title'); + }); + + it('serializes run and primitive commands for the same local session', async () => { + const { provider, page } = makeProviderWithFakePage(); + const first = provider.dispatch({ + id: 'run', + action: 'run', + session: 'work', + surface: 'browser', + source: 'await new Promise(resolve => setTimeout(resolve, 30)); return 1;', + observe: 'none', + profileId: 'default', + }); + const second = provider.dispatch({ + id: 'exec', + action: 'exec', + session: 'work', + surface: 'browser', + code: 'document.title', + profileId: 'default', + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(page.evaluate).not.toHaveBeenCalled(); + await Promise.all([first, second]); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('serializes commands by the resolved page lease when explicit page metadata differs', async () => { + const { provider, page } = makeProviderWithFakePage(); + const nav = await provider.dispatch({ + id: 'nav', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://example.com/', + profileId: 'default', + }); + page.evaluate.mockClear(); + + const first = provider.dispatch({ + id: 'run', + action: 'run', + page: nav.page, + session: 'misleading-session', + surface: 'adapter', + source: 'await new Promise(resolve => setTimeout(resolve, 30)); return 1;', + observe: 'none', + profileId: 'misleading-profile', + }); + const second = provider.dispatch({ + id: 'exec', + action: 'exec', + page: nav.page, + session: 'different-session', + surface: 'adapter', + code: 'document.title', + profileId: 'different-profile', + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(page.evaluate).not.toHaveBeenCalled(); + await Promise.all([first, second]); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('keeps popup pages under the originating session queue lock', async () => { + const { provider, page, pages } = makeProviderWithFakePage(); + const popup = fakePage('https://popup.example/'); + pages.push(popup); + page.waitForEvent.mockResolvedValue(popup); + const nav = await provider.dispatch({ + id: 'nav', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://example.com/', + profileId: 'default', + }); + const manager = ( + provider as unknown as { + manager: { pageIdFor(target: unknown): string | undefined }; + } + ).manager; + + const first = provider.dispatch({ + id: 'run', + action: 'run', + page: nav.page, + session: 'misleading-session', + surface: 'adapter', + source: ` + const page = await browser.currentPage(); + await page.waitForEvent("popup"); + await new Promise(resolve => setTimeout(resolve, 30)); + return null; + `, + observe: 'none', + profileId: 'misleading-profile', + }); + await vi.waitFor(() => { + expect(manager.pageIdFor(popup)).toEqual(expect.any(String)); + }, { interval: 1, timeout: 100 }); + const popupPageId = manager.pageIdFor(popup)!; + popup.evaluate.mockClear(); + + const second = provider.dispatch({ + id: 'exec', + action: 'exec', + page: popupPageId, + session: 'different-session', + surface: 'adapter', + code: 'document.title', + profileId: 'different-profile', + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(popup.evaluate).not.toHaveBeenCalled(); + await Promise.all([first, second]); + expect(popup.evaluate).toHaveBeenCalledTimes(1); + }); + + it('holds the original page queue lock throughout a bind transition', async () => { + const { provider, page } = makeProviderWithFakePage(); + const nav = await provider.dispatch({ + id: 'nav', + action: 'navigate', + session: 'before-bind', + surface: 'browser', + url: 'https://example.com/', + profileId: 'default', + }); + let markBindStarted!: () => void; + let finishBind!: () => void; + const bindStarted = new Promise((resolve) => { + markBindStarted = resolve; + }); + page.bringToFront.mockImplementation(() => { + markBindStarted(); + return new Promise((resolve) => { + finishBind = resolve; + }); + }); + page.evaluate.mockClear(); + + const bind = provider.dispatch({ + id: 'bind', + action: 'bind', + page: nav.page, + session: 'after-bind', + surface: 'browser', + profileId: 'default', + }); + await bindStarted; + const exec = provider.dispatch({ + id: 'exec', + action: 'exec', + page: nav.page, + session: 'after-bind', + surface: 'browser', + code: 'document.title', + profileId: 'default', + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(page.evaluate).not.toHaveBeenCalled(); + finishBind(); + await Promise.all([bind, exec]); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('joins target commands queued on opposite sides of a bind transition', async () => { + const { provider, page, pages, context } = makeProviderWithFakePage(); + const nav = await provider.dispatch({ + id: 'nav', + action: 'navigate', + session: 'source', + surface: 'browser', + url: 'https://example.com/', + profileId: 'default', + }); + + const priorTargetPage = fakePage('https://prior-target.example/'); + pages.push(priorTargetPage); + context.newPage.mockResolvedValueOnce(priorTargetPage); + let releaseBlocker!: () => void; + let markBlockerStarted!: () => void; + const blockerStarted = new Promise((resolve) => { + markBlockerStarted = resolve; + }); + priorTargetPage.evaluate.mockImplementationOnce(() => { + markBlockerStarted(); + return new Promise((resolve) => { + releaseBlocker = () => resolve({ ok: true }); + }); + }); + const blocker = provider.dispatch({ + id: 'blocker', + action: 'exec', + session: 'target', + surface: 'browser', + code: 'document.title', + profileId: 'default', + }); + await blockerStarted; + + let finishBind!: () => void; + let markBindStarted!: () => void; + const bindStarted = new Promise((resolve) => { + markBindStarted = resolve; + }); + page.bringToFront.mockImplementation(() => { + markBindStarted(); + return new Promise((resolve) => { + finishBind = resolve; + }); + }); + const bind = provider.dispatch({ + id: 'bind', + action: 'bind', + page: nav.page, + session: 'target', + surface: 'browser', + profileId: 'default', + }); + const beforeMapping = provider.dispatch({ + id: 'before-mapping', + action: 'exec', + page: nav.page, + session: 'target', + surface: 'browser', + code: 'document.title', + profileId: 'default', + }); + + releaseBlocker(); + await blocker; + await bindStarted; + + let finishFirstTargetExec!: () => void; + page.evaluate.mockImplementationOnce(() => ( + new Promise((resolve) => { + finishFirstTargetExec = () => resolve({ ok: true }); + }) + )); + const afterMapping = provider.dispatch({ + id: 'after-mapping', + action: 'exec', + session: 'target', + surface: 'browser', + code: 'document.title', + profileId: 'default', + }); + + finishBind(); + await vi.waitFor(() => { + expect(page.evaluate).toHaveBeenCalledTimes(1); + }, { interval: 1, timeout: 100 }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(page.evaluate).toHaveBeenCalledTimes(1); + + finishFirstTargetExec(); + await Promise.all([bind, beforeMapping, afterMapping]); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + it('evaluates JavaScript in the requested iframe', async () => { const { provider, page } = makeProviderWithFakePage(); const frame = { evaluate: vi.fn().mockResolvedValue('inside frame'), url: vi.fn(() => 'https://frame.example/'), name: vi.fn(() => 'frame') }; diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index a75227ef..74b0f696 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -2,7 +2,10 @@ import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus import type { BrowserRuntimeProvider, RuntimeStatusOptions } from '../provider.js'; import { dispatchCloakAction, resolveCloakCommandProfileId } from './actions.js'; import type { LaunchPersistentContext } from './session-manager.js'; -import { CloakSessionManager, resolveCloakBrowserVersion } from './session-manager.js'; +import { + CloakSessionManager, + resolveCloakBrowserVersion, +} from './session-manager.js'; export interface LocalCloakRuntimeProviderOptions { baseDir?: string; @@ -11,6 +14,7 @@ export interface LocalCloakRuntimeProviderOptions { export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { private readonly manager: CloakSessionManager; + private readonly sessionQueues = new Map>(); constructor(private readonly opts: LocalCloakRuntimeProviderOptions = {}) { this.manager = new CloakSessionManager(opts); @@ -33,10 +37,44 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { } async dispatch(command: BrowserRuntimeCommand): Promise { - return dispatchCloakAction(this.manager, command); + const key = this.commandQueueKey(command); + const previous = this.sessionQueues.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + this.sessionQueues.set(key, current); + + await previous.catch(() => {}); + try { + return await dispatchCloakAction(this.manager, command); + } finally { + release(); + if (this.sessionQueues.get(key) === current) { + this.sessionQueues.delete(key); + } + } } async shutdown(): Promise { await this.manager.shutdown(); } + + private commandQueueKey(command: BrowserRuntimeCommand): string { + if (command.page) { + const profileId = this.manager.profileIdForPage(command.page); + if (profileId) return `profile\u0000${profileId}`; + } + + let profileId: string; + try { + profileId = this.resolveProfileId(command); + } catch { + profileId = command.profileId + ?? command.contextId + ?? command.preferredContextId + ?? 'default'; + } + return `profile\u0000${profileId.trim() || 'default'}`; + } } diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 9cf0412a..005224c6 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -9,6 +9,7 @@ import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContex import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; import { CloakNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; +import { BrowserRunObservationStore } from '../../run/observation.js'; /** Installed `cloakbrowser` npm package version, for doctor/status display. */ export function resolveCloakBrowserVersion(): string | undefined { @@ -101,6 +102,7 @@ function isClosedContextError(error: unknown): boolean { export class CloakSessionManager { readonly networkCapture = new CloakNetworkCapture(); + readonly browserRunObservations = new BrowserRunObservationStore(); private readonly launchPersistentContext: LaunchPersistentContext; private readonly launchBackgroundPersistentContext: LaunchPersistentContext; @@ -188,6 +190,17 @@ export class CloakSessionManager { return null; } + profileIdForPage(pageId: string): string | null { + for (const [profileId, runtime] of this.profiles.entries()) { + for (const entry of runtime.pages.values()) { + if (entry.pageId === pageId && !pageIsClosed(entry.page)) { + return profileId; + } + } + } + return null; + } + pageIdFor(page: PlaywrightPage): string | undefined { for (const runtime of this.profiles.values()) { for (const entry of runtime.pages.values()) { @@ -362,6 +375,7 @@ export class CloakSessionManager { await runtime.context.close().catch(() => {}); } this.profiles.clear(); + this.browserRunObservations.clear(); } private async getProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { diff --git a/src/cli-argv-preprocess.test.ts b/src/cli-argv-preprocess.test.ts index 0753ba87..aa1dbb32 100644 --- a/src/cli-argv-preprocess.test.ts +++ b/src/cli-argv-preprocess.test.ts @@ -30,6 +30,17 @@ describe('rewriteBrowserArgv', () => { ]); }); + it('rewrites `browser run` with source options preserved', () => { + expect(rewriteBrowserArgv(['browser', 'mercury', 'run', '--file', 'task.js'])).toEqual([ + 'browser', + '--session', + 'mercury', + 'run', + '--file', + 'task.js', + ]); + }); + it('leaves argv alone when session omitted and a subcommand follows', () => { // Commander surfaces the required-flag error itself. expect(rewriteBrowserArgv(['browser', 'state'])).toEqual(['browser', 'state']); @@ -234,7 +245,7 @@ describe('rewriteBrowserArgv', () => { 'analyze', 'back', 'bind', 'check', 'click', 'close', 'console', 'dblclick', 'dialog', 'drag', 'eval', 'extract', 'fill', 'find', 'focus', 'frames', 'get', 'hover', 'init', 'keys', 'network', 'open', 'screenshot', 'scroll', - 'select', 'state', 'tab', 'type', 'unbind', 'uncheck', 'upload', 'verify', + 'run', 'select', 'state', 'tab', 'type', 'unbind', 'uncheck', 'upload', 'verify', 'wait', ]; for (const name of required) { diff --git a/src/cli-argv-preprocess.ts b/src/cli-argv-preprocess.ts index 9fe33967..8201be87 100644 --- a/src/cli-argv-preprocess.ts +++ b/src/cli-argv-preprocess.ts @@ -38,6 +38,7 @@ const BROWSER_SUBCOMMAND_NAMES: ReadonlySet = new Set([ 'keys', 'network', 'open', + 'run', 'screenshot', 'scroll', 'select', diff --git a/src/cli.test.ts b/src/cli.test.ts index ee69933a..7474acd7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1327,6 +1327,54 @@ describe('browser tab targeting commands', () => { expect(browserState.page?.snapshot).toHaveBeenCalled(); }); + it('reads browser-run --file locally and sends source rather than the path', async () => { + const sourcePath = path.join( + String(process.env.WEBCMD_CACHE_DIR), + 'browser-run-task.js', + ); + fs.writeFileSync(sourcePath, 'return 42;', 'utf8'); + mockSendCommand.mockResolvedValueOnce({ + ok: true, + result: 42, + logs: [], + observation: { mode: 'none' }, + }); + const program = createProgram('', ''); + + await program.parseAsync([ + 'node', + 'webcmd', + 'browser', + '--session', + 'test', + 'run', + '--file', + sourcePath, + '--timeout', + '12', + '--max-output', + '1000', + '--observe', + 'none', + '--tab', + 'tab-2', + ]); + + expect(mockSendCommand).toHaveBeenCalledWith('run', { + session: 'test', + surface: 'browser', + windowMode: 'background', + source: 'return 42;', + timeoutMs: 12_000, + timeout: 17, + maxOutputChars: 1000, + observe: 'none', + page: 'tab-2', + }); + expect(mockSendCommand.mock.calls[0]?.[1]).not.toHaveProperty('file'); + expect(lastJsonLog()).toMatchObject({ ok: true, result: 42 }); + }); + it('uses WEBCMD_WINDOW as an explicit direct-browser override', async () => { process.env.WEBCMD_WINDOW = 'foreground'; const program = createProgram('', ''); diff --git a/src/cli.ts b/src/cli.ts index 7a610af5..324e3a69 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -47,6 +47,8 @@ import { CLI_COMMAND } from './brand.js'; import type { BrowserDownloadWaitResult, IPage, ScreenshotOptions } from './types.js'; import type { BrowserWindowMode } from './runtime.js'; import { configureRootCommandSurface } from './root-command-surface.js'; +import { loadBrowserRunSource } from './browser/run/input.js'; +import { BrowserRunError } from './browser/run/types.js'; const CLI_FILE = fileURLToPath(import.meta.url); const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"'; @@ -1159,6 +1161,62 @@ Examples: console.log(JSON.stringify({ unbound: true, session }, null, 2)); })); + browser.command('run') + .description('Run a sandboxed Playwright-style program against the browser session') + .option('--stdin', 'Read the browser-run program from stdin', false) + .option('--file ', 'Read the browser-run program from a local file') + .option('--timeout ', 'Maximum run time in seconds', '30') + .option('--max-output ', 'Maximum returned result and log characters', '65536') + .addOption(new Option('--observe ', 'Final semantic observation: diff, full, or none').choices(['diff', 'full', 'none']).default('diff')) + .option('--tab ', BROWSER_TAB_OPTION_DESCRIPTION) + .action(browserSessionCommandAction(async ({ session, routing, windowMode }, opts) => { + const positiveInteger = (value: unknown, flag: string): number => { + const raw = String(value); + const parsed = Number.parseInt(raw, 10); + if ( + !/^\d+$/.test(raw) + || !Number.isSafeInteger(parsed) + || parsed <= 0 + || (flag === '--timeout' && parsed > Math.floor(Number.MAX_SAFE_INTEGER / 1000)) + ) { + throw new BrowserCommandError( + `${flag} must be a positive integer.`, + 'BROWSER_RUN_INVALID_INPUT', + ); + } + return parsed; + }; + let source: string; + try { + source = await loadBrowserRunSource({ + stdin: opts.stdin === true, + file: typeof opts.file === 'string' ? opts.file : undefined, + }); + } catch (error) { + if (error instanceof BrowserRunError) { + throw new BrowserCommandError(error.message, error.code, error.hint); + } + throw error; + } + const timeoutSeconds = positiveInteger(opts.timeout, '--timeout'); + const maxOutputChars = positiveInteger(opts.maxOutput, '--max-output'); + const data = await sendCommand('run', { + session, + surface: 'browser', + ...routing, + windowMode, + source, + timeoutMs: timeoutSeconds * 1000, + timeout: timeoutSeconds + 5, + maxOutputChars, + observe: opts.observe as 'diff' | 'full' | 'none', + ...(typeof opts.tab === 'string' && opts.tab.trim() + ? { page: opts.tab.trim() } + : {}), + }); + console.log(JSON.stringify(data, null, 2)); + })); + const browserTab = browser .command('tab') .description('Tab management — list, create, and close tabs in the browser session'); diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index e56d2e76..d3bd5c8e 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -115,6 +115,24 @@ describe('createDaemonServer', () => { expect(provider.commands[0]).toMatchObject({ id: 'cmd-1', action: 'navigate', session: 'work' }); }); + it('accepts the maximum browser-run source envelope', async () => { + const { provider, baseUrl } = await start(); + const source = 'x'.repeat(256 * 1024); + const res = await postCommand(baseUrl, { + id: 'run-max-source', + action: 'run', + session: 'work', + source, + }); + + expect(res.status).toBe(200); + expect(provider.commands[0]).toMatchObject({ + id: 'run-max-source', + action: 'run', + source, + }); + }); + it('clears custom command timeout timers after successful provider dispatch', async () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); diff --git a/src/hosted/availability.test.ts b/src/hosted/availability.test.ts index 994e7b85..9f3994ad 100644 --- a/src/hosted/availability.test.ts +++ b/src/hosted/availability.test.ts @@ -174,6 +174,8 @@ describe('hosted availability', () => { .toEqual({ mode: 'hosted' }); expect(deriveBrowserAvailability('bind')) .toEqual({ mode: 'local-only', reason: 'browser-bind' }); + expect(deriveBrowserAvailability('run')) + .toEqual({ mode: 'local-only', reason: 'local-tool' }); expect(deriveBrowserAvailability('open')).toEqual({ mode: 'hosted' }); }); diff --git a/src/hosted/availability.ts b/src/hosted/availability.ts index ed484252..135ad60a 100644 --- a/src/hosted/availability.ts +++ b/src/hosted/availability.ts @@ -21,7 +21,11 @@ export function deriveHostedAvailability(command: HostedAvailabilityMetadata): H } export function deriveBrowserAvailability(command: string): HostedAvailability { - return command === 'bind' - ? { mode: 'local-only', reason: 'browser-bind' } - : { mode: 'hosted' }; + if (command === 'bind') { + return { mode: 'local-only', reason: 'browser-bind' }; + } + if (command === 'run') { + return { mode: 'local-only', reason: 'local-tool' }; + } + return { mode: 'hosted' }; } diff --git a/src/hosted/browser-args.ts b/src/hosted/browser-args.ts index 4eaf2d8b..c0e660f4 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -81,7 +81,7 @@ Examples: leaf.argument(positional.required ? `<${positional.name}${suffix}>` : `[${positional.name}${suffix}]`, positional.description); } for (const option of contract.options) { - const flags = browserOptionFlags(option); + const flags = browserOptionFlags(option, contract.command); if (option.type === 'boolean') { leaf.option(flags, option.description, option.default as boolean | undefined); continue; diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 80dd2bd9..9220cb50 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -1437,12 +1437,14 @@ describe('runHostedCli', () => { } }); - it('dispatches every catalogued hosted browser command except bind to the cloud command endpoint', async () => { + it('dispatches every catalogued hosted browser command to the cloud command endpoint', async () => { const uploadDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-browser-upload-')); const uploadFile = path.join(uploadDir, 'sample-upload.txt'); await writeFile(uploadFile, 'hello browser upload'); try { - for (const contract of browserCommandCatalog.filter(command => command.command !== 'bind')) { + for (const contract of browserCommandCatalog.filter(command => ( + command.sessionPolicy !== 'local-only' + ))) { const requests: Array<{ pathname: string; body?: Record }> = []; const positionals = contract.command === 'upload' ? ['input[type=file]', uploadFile] @@ -1845,4 +1847,25 @@ describe('runHostedCli', () => { expect(stderr.text()).toMatch(/browser bind is not supported in hosted mode/i); expect(fetchImpl).not.toHaveBeenCalled(); }); + + it('rejects browser run as local-only before making a hosted request', async () => { + const stderr = sink(); + const fetchImpl = vi.fn(); + + const result = await runHostedCli( + ['browser', 'work', 'run', '--stdin'], + { + config: makeHostedConfig({ + apiBaseUrl: 'https://api.example.com', + apiKey: 'key', + }), + stderr: stderr.stream, + fetchImpl, + }, + ); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(stderr.text()).toMatch(/browser run is local-only/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); }); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 27530d33..c115e48a 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -488,12 +488,18 @@ function parseBrowserLeaf( } { const contract = hostedBrowserCommandsByPath.get(leaf); if (!contract || !contract.action) { - if (leaf === 'bind' || contract?.sessionPolicy === 'local-only') { + if (leaf === 'bind') { throw new ConfigError( 'Browser bind is not supported in hosted mode.', 'Use browser state or browser tabs to inspect the active hosted page.', ); } + if (contract?.sessionPolicy === 'local-only') { + throw new ConfigError( + `Browser ${leaf} is local-only and is not available in hosted mode.`, + 'Switch Webcmd to local mode to use this command.', + ); + } throw new ConfigError(`Hosted browser command is not supported yet: ${leaf}`); } From 0d3edc11ec581b595e0548a605dd4f2a74e3ac14 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Thu, 30 Jul 2026 14:27:00 +0530 Subject: [PATCH 03/60] feat(skills): adopt run-first browser workflows --- README.md | 14 + docs/cli-reference.mdx | 28 ++ skills/webcmd-adapter-author/SKILL.md | 24 +- .../references/adapter-template.md | 5 + .../references/api-discovery.md | 22 ++ .../references/recon-to-ipage.md | 166 +++++++++ .../references/site-recon.md | 5 + skills/webcmd-browser/SKILL.md | 321 +++++++++++++----- .../references/browser-run-playwright.md | 310 +++++++++++++++++ skills/webcmd-usage/SKILL.md | 4 + src/skills.test.ts | 151 ++++++++ 11 files changed, 958 insertions(+), 92 deletions(-) create mode 100644 skills/webcmd-adapter-author/references/recon-to-ipage.md create mode 100644 skills/webcmd-browser/references/browser-run-playwright.md diff --git a/README.md b/README.md index c0cf1e72..82ff1861 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,20 @@ On top of live browser control, WebCMD adds 3 layers of learnings. Each layer co | 3. CLI authoring | The action space is known, but the path is still too variable for one fixed sequence. | Explicitly author a reusable `webcmd ` adapter with structured output, so future agents spend tokens on the task instead of navigation. | | 4. Extend existing CLIs | The workflow is deterministic enough to stop browsing. | Extend the `webcmd ` adapter with a tailored command so the workflow runs instantly with the least amount of tokens. | +For local, multi-step browser exploration, agents can send one sandboxed +Playwright-style program to an existing CloakBrowser session: + +```bash +webcmd browser work run --file explore.js +printf 'const page = await browser.currentPage(); return await page.title();' \ + | webcmd browser work run --stdin +``` + +`browser run` is additive: the existing `state`, `click`, `type`, `get`, and +other primitive commands keep their current behavior. Reusable site adapters +also keep using Webcmd's existing `IPage` contract; a browser-run program is +reconnaissance or ad-hoc automation, not adapter source code. + ## Demo https://github.com/user-attachments/assets/04eceadc-d398-4303-984d-ae3197bfa664 diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 2ce43491..7dc9f5c3 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -34,6 +34,33 @@ webcmd pubmed search "agentic browser automation" --limit 5 -f json Agents should prefer existing adapters before browser exploration. That keeps tasks fast and repeatable. +## Local Browser Programs + +`browser run` executes one Playwright-style JavaScript program against an +existing local CloakBrowser session: + +```bash +webcmd browser work run --file explore.js +webcmd browser work run --stdin --timeout 45 --observe diff +``` + +Exactly one of `--file ` or `--stdin` is required. The CLI reads files +locally and sends source—not the path—to the local daemon. `--timeout` is in +seconds, `--max-output` bounds returned results and logs, `--observe` accepts +`diff`, `full`, or `none`, and `--tab ` targets an existing tab. + +The program runs in a fresh QuickJS sandbox. It can obtain the selected page +with `await browser.currentPage()`, use the supported Page/Frame/Locator +methods, and passively inspect request and response events. It cannot access +Node.js, the filesystem, environment variables, raw CDP endpoints, browser +launch/connect APIs, or browser-context ownership. Screenshot bytes are +written to a Webcmd-owned cache directory and returned as a receipt. + +This command is currently local-only. Existing primitive browser commands are +unchanged, and reusable adapters continue to use the existing `IPage` API. +Playwright-style programs are for reconnaissance and ad-hoc multi-step work; +they are not pasted into adapter modules. + ## Top-Level Commands | Command | Purpose | @@ -115,6 +142,7 @@ Register our internal `releasectl` binary as a Webcmd external CLI with a short | `~/.webcmd/` | User-level Webcmd state. | | `~/.webcmd/clis/` | Private adapters and local overrides. | | `~/.webcmd/cache/browser-network/` | Browser network capture cache. | +| `~/.webcmd/cache/browser-run/` | Host-owned browser-run screenshot artifacts. | | `~/.webcmd/external-clis.yaml` | User external CLI registry. | | `skills/` | Bundled agent skills shipped with the package. | diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index c8632d74..1f53c93a 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -8,7 +8,10 @@ allowed-tools: Bash(webcmd:*), Read, Edit, Write, Grep You are an agent writing an adapter for a site. The goal of this skill is a 30-minute loop from zero context to a passing `webcmd browser verify`. -Use the existing tools throughout: `webcmd browser *`, `webcmd doctor`, `webcmd browser init`, and `webcmd browser verify`. This skill does not introduce new commands. +Use the existing tools throughout: `webcmd browser *`, including sandboxed +`browser run` for reconnaissance, plus `webcmd doctor`, `webcmd browser init`, +and `webcmd browser verify`. Browser-run programs are discovery evidence, not +adapter source. Browser-profile auth commands must reuse `registerSiteAuthCommands`. Keep only site-specific `verify` and `openLogin` logic in the adapter. The login row must return `action_required` and `verify_command` (normally `webcmd whoami`); after the user reports done, agents run that returned command and verification must succeed before retrying the original workflow. Credentials, MFA, and CAPTCHA always use human handoff: CAPTCHA stops automation until the user reports done and verification succeeds, and adapter code must not collect or type passwords or secrets. @@ -97,6 +100,12 @@ Direct fetch verification, even for memory hits - site changed -> mark old endpoint stale and return to api-discovery | v +Adapter-compatible rehearsal (recon-to-ipage.md) + - translate Playwright evidence to the selected strategy + - prove the path with current IPage/pipeline/runtime APIs + - do not paste the browser-run program + | + v Field decoding - self-explanatory -> use directly - known code -> field-conventions.md @@ -146,6 +155,7 @@ Check these off step by step: [ ] 3. Recon (`site-recon.md`): [ ] **Preferred:** `webcmd browser analyze ` to get pattern, anti-bot signals, nearest adapter, and next step in one pass. [ ] If `analyze` is ambiguous, run manual checks: `open` -> `wait time 2` (or `wait xhr `) -> `network`. + [ ] Use `browser run` only when recon needs dependent Playwright-style steps, semantic locators, branching, or a request/response waiter armed before its trigger. [ ] Choose Pattern A / B / C / D / E. [ ] 4. API discovery (`api-discovery.md`) by Pattern: @@ -166,6 +176,13 @@ Check these off step by step: [ ] If choosing `PAGE_FETCH` / `INTERCEPT`, explain why `PUBLIC_API`, `COOKIE_API`, `UI_SELECTOR`, and `DOM_STATE` are not suitable. [ ] If choosing `UI_SELECTOR` / `DOM_STATE`, do not over-defend why it is not an API; state the semantic anchor and typed-error path. +[ ] 6A. Adapter-compatible rehearsal (`recon-to-ipage.md`) before filling the adapter: + [ ] Record the Playwright recon evidence separately from the final implementation. + [ ] Map every used Playwright method to an existing `IPage`, pipeline, Node-fetch, or interceptor path. + [ ] Reproduce the selected data/action path with that adapter-compatible path. + [ ] Record the rehearsal command/result and any unsupported Playwright code removed. + [ ] If compatibility is not proven, return to strategy selection. A successful browser-run program is not sufficient. + [ ] 7. Field decoding: [ ] Self-explanatory key -> use it directly. [ ] Known code -> look it up in `field-conventions.md`. @@ -180,6 +197,7 @@ Check these off step by step: [ ] `webcmd browser init /`, then set `strategy: Strategy.` in the generated file [ ] Find the closest same-site or same-type adapter and copy it. [ ] Edit name, URL, and field mapping. + [ ] Use only the adapter-compatible path proven in Step 6A; never paste Playwright locators, `waitForResponse`, or browser-run globals into `func`. [ ] 10. Verification fixtures: [ ] After the first passing run, immediately use `--write-fixture` to seed `~/.webcmd/sites//verify/.json`. @@ -235,6 +253,7 @@ Check these off step by step: | `references/site-recon.md` | Step 3: classify site type | | `references/api-discovery.md` | Step 4: find endpoint | | `references/strategy-selection.md` | Before Step 6 strategy note: contract model, observed fix frequency, `api_candidates` evidence, counterexamples | +| `references/recon-to-ipage.md` | Step 6A: translate Playwright-style recon into the unchanged adapter `IPage`/pipeline/runtime | | `references/field-conventions.md` | Step 7: known field-code lookup | | `references/field-decode-playbook.md` | Step 7: field not in dictionary | | `references/output-design.md` | Step 8: naming, types, order | @@ -250,6 +269,9 @@ Check these off step by step: ## Key Conventions - Adapters import only `@agentrhq/webcmd/registry` and `@agentrhq/webcmd/errors`; do not add third-party dependencies. +- Browser-run’s Playwright-style `page` and adapter `func(page,args)` are + different contracts. Preserve evidence and behavior, not syntax. Complete + the compatibility rehearsal in `recon-to-ipage.md` before adapter code. - The `columns` array and `func` return object keys must match exactly, including order. - **Intermediate parsing object keys must not overlap any `columns` entry.** Otherwise silent-column-drop audits can misread the adapter. Use dedicated internal names and destructure with aliases when pushing rows. - **The `browser:` field determines the `func` signature:** `browser:false -> (args)`, `browser:true -> (page, args)`. If this is reversed, `args` may actually be a debug flag and all external parameters can silently fall back to defaults. diff --git a/skills/webcmd-adapter-author/references/adapter-template.md b/skills/webcmd-adapter-author/references/adapter-template.md index f6d4931a..f06dd939 100644 --- a/skills/webcmd-adapter-author/references/adapter-template.md +++ b/skills/webcmd-adapter-author/references/adapter-template.md @@ -2,6 +2,11 @@ Use this after recon, endpoint verification, field decoding, output design, and strategy-note writing are complete. +If reconnaissance used `browser run`, complete `recon-to-ipage.md` first. +Playwright-style `page`, locators, and `waitForResponse` are not available in +adapter `func(page,args)`. Copy evidence and behavior into the adapter-compatible +path; never copy the browser-run program itself. + ## Create The File For private iteration: diff --git a/skills/webcmd-adapter-author/references/api-discovery.md b/skills/webcmd-adapter-author/references/api-discovery.md index d8ef8088..37b1c03e 100644 --- a/skills/webcmd-adapter-author/references/api-discovery.md +++ b/skills/webcmd-adapter-author/references/api-discovery.md @@ -69,6 +69,28 @@ webcmd browser eval "await fetch('', { credentials: 'include' }).then(r => If Node-side replay works without page runtime state, prefer `PUBLIC_API` or `COOKIE_API`. If the endpoint only works in page context, document why before selecting `PAGE_FETCH`. +For a request that exists only after a UI action, use `browser run` so the +listener is attached before the trigger: + +```js +const page = await browser.currentPage(); +const pending = page.waitForResponse( + response => response.url().includes('/api/target'), +); +await page.getByRole('button', { name: 'Load' }).click(); +const response = await pending; +return { + url: response.url(), + method: response.request().method(), + status: response.status(), + body: await response.json(), +}; +``` + +This is recon evidence only. Use `recon-to-ipage.md` to choose direct fetch, +cookie fetch, page fetch, DOM/UI, or interceptor and rehearse it before writing +the adapter. + ## Section 2 - State Extraction Use for Pattern B. diff --git a/skills/webcmd-adapter-author/references/recon-to-ipage.md b/skills/webcmd-adapter-author/references/recon-to-ipage.md new file mode 100644 index 00000000..ae1d3c53 --- /dev/null +++ b/skills/webcmd-adapter-author/references/recon-to-ipage.md @@ -0,0 +1,166 @@ +# Playwright Recon to Adapter `IPage` + +Use this after a `webcmd browser run` program discovers a viable UI +or network path and before filling the adapter scaffold. + +## Compatibility Boundary + +Browser-run exposes a sandboxed, Playwright-style client for reconnaissance. +Adapters execute ordinary registry modules in Node and receive Webcmd's +existing `IPage` when `browser: true`. + +These are different APIs. A browser-run program succeeding proves the website +path, not adapter compatibility. + +Keep three artifacts separate: + +| Artifact | Contains | +| --- | --- | +| Recon evidence | roles/names, selectors, endpoint URL/method/shape, auth, ordering, visible states | +| Strategy note | `PUBLIC_API`, `COOKIE_API`, `UI_SELECTOR`, `DOM_STATE`, `PAGE_FETCH`, or `INTERCEPT` and contract level | +| Adapter implementation | only current registry, pipeline, `IPage`, Node fetch, and typed errors | + +## Required Compatibility Note + +Write this before filling the adapter: + +```md +Recon evidence: +- Playwright methods: +- stable UI or endpoint contract: +- auth/session requirement: + +Adapter path: +- strategy: +- IPage/pipeline/Node/interceptor methods: +- readiness and error mapping: + +Rehearsal: +- command or minimal probe: +- observed result: +- one visible value/state comparison: + +Removed browser-run code: +- unsupported Playwright methods/globals: +``` + +If any adapter-path method is unknown or the rehearsal does not reproduce the +target result, return to strategy selection. Do not scaffold around the gap. + +## Translation Table + +| Browser-run recon evidence | Final adapter form | +| --- | --- | +| `page.goto(url)` | `IPage.goto(url)` or pipeline `{ navigate: url }` | +| `page.evaluate(fn, ...args)` | `IPage.evaluate(fn, ...args)`, `evaluateWithArgs`, or pipeline `evaluate` | +| public endpoint found by `waitForResponse` | `browser:false` + Node-side `fetch` | +| authenticated replayable endpoint | `browser:true`, `page.getCookies()`, then Node-side `fetch` | +| same-origin runtime-only endpoint | `page.fetchJson(...)` or `IPage.evaluate(fetch(...))` | +| irreproducible signed request naturally issued by UI | `installInterceptor` → trigger through `IPage` → `waitForCapture` → `getInterceptedRequests` | +| `getByRole` / `getByLabel` relationship | prove a stable selector or semantic anchor, then use current `IPage` click/fill/type methods or pipeline evaluation | +| locator `click`, `fill`, `press`, `selectOption` | `IPage.click`, `fillText`, `typeText`, `pressKey`, or a neighboring pipeline pattern | +| `waitForLoadState` / `waitForURL` | `IPage.goto` readiness plus `IPage.wait({selector|text|timeout})`; wait on a site condition, not a blind sleep | +| popup evidence | current `IPage.tabs/newTab/selectTab` only when the adapter truly needs the popup | +| screenshot | recon evidence, not adapter runtime by default | + +`waitForResponse` itself does not have a drop-in adapter equivalent. Its +evidence must select one of direct fetch, cookie fetch, page fetch, DOM/UI, or +interceptor. Preserve subscribe-before-trigger ordering only when the final +strategy remains `INTERCEPT`. + +## Rehearsal by Strategy + +### `PUBLIC_API` + +Replay the exact method, URL, parameters, and required non-secret headers +without browser state. Confirm status, response shape, non-empty target data, +and one value against the visible page. Final adapter uses `browser:false`. + +### `COOKIE_API` + +First prove the endpoint in the authenticated browser. Then make the smallest +adapter-runtime probe using only `page.getCookies()` and Node `fetch`; do not +copy Playwright request or response objects. Confirm HttpOnly cookies are +present through `getCookies`, auth failure is recognizable, and the response +matches one visible value. + +### `PAGE_FETCH` + +Rehearse through `page.fetchJson` or `page.evaluate` with a same-origin fetch. +Confirm why Node fetch cannot reproduce it. Keep the returned value bounded and +JSON-compatible. + +### `DOM_STATE` + +Translate the successful page evaluation into `IPage.evaluate` or a pipeline +`evaluate` step. Pass external args safely, prove selectors/state keys against +current HTML, and return only the fields used by the adapter. + +### `UI_SELECTOR` + +Use existing Webcmd primitives to prove the same selector and readiness path, +because those primitives route through the current page abstraction. Then map +the proven selector to `IPage.click`, `fillText`, `typeText`, `pressKey`, +`setChecked`, or an established neighboring adapter pattern. + +For writes, rehearse on a reversible/test state where possible. Do not repeat a +purchase, publish, send, or destructive action merely to satisfy verification; +verify preconditions and a non-mutating readiness path, then perform the +authorized write once and verify its post-action state. + +### `INTERCEPT` + +Use only when direct, cookie, page fetch, DOM state, and stable UI output cannot +provide the result. The adapter-compatible order is: + +```js +await page.installInterceptor('/api/target'); +await page.click(stableSelector); +await page.waitForCapture(timeoutMs); +const captured = await page.getInterceptedRequests(); +``` + +Prove the interceptor pattern is narrow, attach before the action, choose the +correct response deterministically, and cap returned data. + +## Example Translation + +Recon: + +```js +const responsePromise = page.waitForResponse( + response => response.url().includes('/api/items'), +); +await page.getByRole('button', { name: 'Search' }).click(); +return await (await responsePromise).json(); +``` + +If cookie-authenticated replay works, the adapter does not retain either +Playwright method: + +```js +func: async (page, args) => { + const cookies = await page.getCookies({ domain: '.example.com' }); + const cookie = cookies.map(({ name, value }) => `${name}=${value}`).join('; '); + const response = await fetch( + `https://example.com/api/items?q=${encodeURIComponent(String(args.query))}`, + { headers: { Cookie: cookie } }, + ); + // Validate status/shape, map typed errors, then return rows. +} +``` + +The recon established the endpoint, auth requirement, trigger semantics, and +response shape. The rehearsal established that the final runtime can reproduce +the data without Playwright. + +## Failure Gate + +Stop before final adapter code when: + +- a Playwright method has no mapped current-runtime equivalent; +- replay works only because browser-run retained a request/response object; +- authentication was assumed from the recon session but not proven in adapter + verification; +- a fast response requires ordering that the chosen implementation discarded; +- the only rehearsal is “the browser-run program worked.” diff --git a/skills/webcmd-adapter-author/references/site-recon.md b/skills/webcmd-adapter-author/references/site-recon.md index 9cb77a58..19f79d10 100644 --- a/skills/webcmd-adapter-author/references/site-recon.md +++ b/skills/webcmd-adapter-author/references/site-recon.md @@ -48,6 +48,11 @@ Read `network` output this way: If data is loaded asynchronously, `wait time 2` may not be enough. Prefer `webcmd browser wait xhr '/api/path-fragment'` for a specific interface over blind `wait time 5`. +When classification needs a dependent UI trigger plus a request/response +waiter, use one sandboxed `browser run` program and arm the waiter before the +trigger. Record the endpoint and UI evidence; do not treat the Playwright-style +program as adapter source. Translate it through `recon-to-ipage.md`. + --- ## Pattern A - SPA / JSON XHR diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index 75b1c109..8f937723 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -63,27 +63,153 @@ Bound sessions use the normal Webcmd session lifecycle; `unbind` releases the Cl --- -## Mental model +## Run-first decision loop + +After raw browser fallback has been selected, state the next unknown whose answer requires agent reasoning. + +1. Put every known navigation, wait, read, interaction, loop, pagination step, + verification, and safe screenshot (use `await page.screenshot()` when a + sandbox receipt is accepted) before that unknown into one `browser run`. +2. Return only the compact result needed for the next reasoning decision. +3. Start another run only when that returned result changes the plan. + +`browser run` may be the first raw browser command when the destination URL +and initial inspection are already known. Use `page.goto()` inside the run; +do not use `browser open` followed by a run that only reads the known page. + +The target is not one task-wide program. It is a small number of substantial +programs separated by genuine reasoning decisions. + +| Situation | Use | +| --- | --- | +| Known URL plus known inspection or action | One run beginning with `page.goto()` | +| Pagination, candidate search, retries, or bounded loops with a known stop condition | One run containing the loop | +| Several dependent form or navigation operations | One run containing action, wait, and verification | +| One isolated inspect, click, fill, keypress, read, wait, or host-path screenshot | Matching primitive | +| Output is genuinely required to decide what operation comes next | Primitive reconnaissance, then one run | +| Existing host file upload | `browser upload` primitive | +| Reusable site command | `webcmd-adapter-author`, not browser-run source | + +Use the browser screenshot primitive only for an isolated screenshot or when +the caller requires an exact host path. Otherwise, capture a known screenshot +inside its run with `await page.screenshot()` when a sandbox receipt is +accepted. + +### Do not alternate open and one-operation run + +Wrong: + +```text +browser open URL +browser run { one page.evaluate() } +browser open NEXT_URL +browser run { one page.evaluate() } +``` + +This is `open -> one-operation run` and saves no agent decision. If the run +would contain only one `page.evaluate()` that `eval`, `get`, `find`, or +`state` can perform, keep the isolated primitive. If navigation and reading +are both known, own both inside one run. + +Do not issue one run per pagination page, candidate, revision, or search +iteration when the loop and stopping condition are already known. + +### Recon-to-run locator translation + +Numeric refs are primitive-command handles; translate the evidence behind a +ref into a run locator: + +| Recon evidence | Run locator | +| --- | --- | +| `attrs.id` | `page.locator('#stable-id')` | +| role + accessible name | `page.getByRole('button', { name: 'Save' })` | +| associated label | `page.getByLabel('Email')` | +| placeholder | `page.getByPlaceholder('Search')` | +| test ID | `page.getByTestId('submit')` | +| stable attribute relationship | `page.locator('form#search input[name=q]')` | + +Prefer semantic locators, then stable IDs or attributes. A numeric ref is not +a reason to avoid `browser run`. -1. **Selector-first target contract.** Every interaction command (`click`, `type`, `select`, `get text/value/attributes`) takes one ``, which is *either* a numeric ref from `state`/`find` *or* a CSS selector. Use `--nth ` to disambiguate multiple CSS matches. -2. **Every envelope reports `matches_n` and `match_level`.** `match_level` is `exact`, `stable`, or `reidentified` — the CLI already rescued moderate DOM drift for you, but the level tells you how confident to be. -3. **Compact output first, full payload on demand.** `state` is a budget-aware snapshot; `get html --as json` supports `--depth/--children-max/--text-max`; `network` returns shape previews and you re-fetch a single body with `--detail `. If you emit a giant payload you are burning context you did not need to burn. -4. **Structured errors are machine-readable.** On failure the CLI emits `{error: {code, message, hint?, candidates?}}`. Branch on `code`, not on message strings. +### Known destination: start with run + +```bash +webcmd browser research run --stdin <<'JS' +await page.goto('https://example.com/articles'); +const matches = []; +let pagesChecked = 0; +for (;;) { + const rows = await page.locator('article').allTextContents(); + matches.push(...rows.filter(text => text.includes('target phrase'))); + pagesChecked += 1; + const next = page.getByRole('link', { name: 'Next' }); + if (pagesChecked >= 10 || !(await next.isVisible())) { + break; + } + const nextUrl = await next.evaluate(element => element.href); + const navigation = page.waitForURL(nextUrl); + await next.click(); + await navigation; +} +return { + matches: matches.slice(0, 10), + pagesChecked, + finalUrl: page.url(), +}; +JS +``` + +Navigation, pagination, reduction, and verification are known before the next +decision, so they belong to one invocation. Return the compact evidence, not +every page body. + +### Reconnaissance-to-run form example + +```bash +webcmd browser work state +webcmd browser work run --stdin <<'JS' +const page = await browser.currentPage(); +const email = page.getByLabel('Email'); +await email.fill('agent@example.com'); +if (await email.inputValue() !== 'agent@example.com') { + throw new Error('email did not stick'); +} +await page.getByLabel('Country').selectOption({ label: 'Canada' }); +await page.getByRole('button', { name: 'Submit' }).click(); +await page.getByText('Form submitted successfully!').waitFor(); +return { submitted: true, url: page.url() }; +JS +``` + +Use a quoted heredoc so the shell cannot expand program contents. Keep an +isolated screenshot or an exact host-path receipt as a primitive; otherwise, +capture a known screenshot with `await page.screenshot()` inside the run when a +sandbox receipt is accepted. Inspect after a run only when its evidence is +unexpected or insufficient and changes the next plan. Read +[`references/browser-run-playwright.md`](./references/browser-run-playwright.md) +for the full supported API. + +`browser run` is reconnaissance and ad-hoc automation. Its Playwright-style +objects are not the adapter `IPage` API, so never paste the program into an +adapter. --- ## Critical rules -1. **Always inspect before you act.** Run `state` or `find` first. Never hard-code a ref or selector from memory across sessions — indices are per-snapshot. +1. **Inspect when output is needed to decide.** Use `state` or `find` before an action only when the page or target is unknown; a known URL plus known inspection or action starts with `page.goto()` in a run. Never hard-code a ref or selector from memory across sessions — indices are per-snapshot. 2. **Prefer site adapters before raw browser driving.** Complete the adapter fallback gate above. If `webcmd ` already covers the task, use that adapter command first (`webcmd facebook notifications`, `webcmd reddit read`, `webcmd chatgpt model `, etc.). Use `webcmd browser ...` only for gaps, debugging, or one-off UI flows the adapter does not expose. -3. **Prefer numeric ref over CSS once you have it.** Numeric refs survive mild DOM shifts because the CLI fingerprints each tagged element. A CSS selector written by hand will break the first time the site re-renders. +3. **Prefer numeric refs for isolated primitives.** For `browser run`, translate recon evidence into a semantic locator, stable ID, or stable attribute relationship using the table above. 4. **Read `match_level` after every write.** `exact` = all good. `stable` = the element is the same but some soft attrs drifted — your action still applied. `reidentified` = the original ref was gone and the CLI found a unique replacement; double-check you hit the right element. 5. **Use the `compound` field for form controls.** Do not regex-guess a date format, do not `state` twice to get the full ``. -6. **Verify writes that matter.** After `type `, run `get value `. After `select`, run `get value`. Autocomplete widgets, React controlled inputs, and masked fields all silently eat characters. The CLI cannot detect this for you. -7. **`state` → action → `state` after a page change.** Navigations, form submits, and SPA route changes invalidate refs. Take a fresh snapshot. Do not reuse refs from before the transition. -8. **Chain with `&&` when reusing freshly parsed refs.** A chained sequence runs in one shell so the ref you just read from output can be passed directly to the next command. Separate shell invocations keep the named browser session, but any shell-local variables or copied refs from the previous command can go stale after page changes. -9. **`eval` is read-only.** Wrap the JS in an IIFE and return JSON. If you need to *change* the page, use the structured `click` / `type` / `select` / `keys` commands instead — they produce structured output and fingerprints, `eval` does not. +6. **Verify writes that matter.** For an isolated primitive, use `get value`. In a run, call `inputValue`, `isChecked`, or another targeted read before returning. Autocomplete widgets, controlled inputs, and masked fields can silently eat characters. +7. **Inspect only at genuine decision boundaries.** Use `state → primitive → state` when the result determines the next action. Known write chains plus verification belong in one run; inspect after that run only when its evidence is unexpected or insufficient and changes the next plan. A `reidentified` result may remain a genuine reconnaissance boundary. Never reuse refs across a page transition. +8. **Use `&&` only for isolated primitives.** A known multi-action segment belongs in `browser run`, not a shell chain. +9. **`eval` is read-only.** Use `browser run` for multi-action mutation and structured primitives for isolated mutation. 10. **Prefer `network` to screen-scraping.** If a page you care about fetches its data from a JSON API, the API is almost always more reliable than scraping the rendered DOM. Capture once, inspect the shape, then `--detail ` the body you need. +11. **Return only the decision result from `browser run`.** Keep large DOM, + response, and screenshot payloads out of the return value. The host already + supplies final page metadata and a bounded semantic observation. --- @@ -223,6 +349,19 @@ List entries look like `{key, method, status, url, ct, size, shape, body_truncat Default output keeps JSON/XML/plain-text and JS-like API responses, then drops obvious static assets and telemetry by URL. If an expected endpoint is missing, run `browser network --all` once and check whether an unusual content type or URL filter hid it. +### Sandboxed Playwright-style programs + +```bash +webcmd browser run --file +webcmd browser run --stdin \ + [--timeout ] [--max-output ] \ + [--observe diff|full|none] [--tab ] +``` + +Exactly one of `--file` or `--stdin` is required. This command is local-only. +See [`references/browser-run-playwright.md`](./references/browser-run-playwright.md) +for the supported Page, Frame, Locator, network, result, and isolation contract. + ### Tabs & session | command | purpose | @@ -272,7 +411,12 @@ Every date/time, select, and file input carries a `compound` field. Use it — d } ``` -`options[]` is capped at 50 entries. **`current` is always correct** even when the selected option is past the cap — it's computed by scanning every option, not from the truncated list. If `options_total > options.length` and you need an option that isn't in `options[]`, call `browser select "