onbuzz 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +267 -0
- package/README.md +425 -0
- package/bin/cli.js +556 -0
- package/bin/loxia-terminal-v2.js +162 -0
- package/bin/loxia-terminal.js +90 -0
- package/bin/start-with-terminal.js +200 -0
- package/node_modules/@isaacs/balanced-match/LICENSE.md +23 -0
- package/node_modules/@isaacs/balanced-match/README.md +60 -0
- package/node_modules/@isaacs/balanced-match/dist/commonjs/index.d.ts +9 -0
- package/node_modules/@isaacs/balanced-match/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/@isaacs/balanced-match/dist/commonjs/index.js +59 -0
- package/node_modules/@isaacs/balanced-match/dist/commonjs/index.js.map +1 -0
- package/node_modules/@isaacs/balanced-match/dist/commonjs/package.json +3 -0
- package/node_modules/@isaacs/balanced-match/dist/esm/index.d.ts +9 -0
- package/node_modules/@isaacs/balanced-match/dist/esm/index.d.ts.map +1 -0
- package/node_modules/@isaacs/balanced-match/dist/esm/index.js +54 -0
- package/node_modules/@isaacs/balanced-match/dist/esm/index.js.map +1 -0
- package/node_modules/@isaacs/balanced-match/dist/esm/package.json +3 -0
- package/node_modules/@isaacs/balanced-match/package.json +79 -0
- package/node_modules/@isaacs/brace-expansion/LICENSE +23 -0
- package/node_modules/@isaacs/brace-expansion/README.md +97 -0
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.d.ts +6 -0
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js +199 -0
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +1 -0
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/package.json +3 -0
- package/node_modules/@isaacs/brace-expansion/dist/esm/index.d.ts +6 -0
- package/node_modules/@isaacs/brace-expansion/dist/esm/index.d.ts.map +1 -0
- package/node_modules/@isaacs/brace-expansion/dist/esm/index.js +195 -0
- package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +1 -0
- package/node_modules/@isaacs/brace-expansion/dist/esm/package.json +3 -0
- package/node_modules/@isaacs/brace-expansion/package.json +60 -0
- package/node_modules/glob/LICENSE.md +63 -0
- package/node_modules/glob/README.md +1177 -0
- package/node_modules/glob/dist/commonjs/glob.d.ts +388 -0
- package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -0
- package/node_modules/glob/dist/commonjs/glob.js +247 -0
- package/node_modules/glob/dist/commonjs/glob.js.map +1 -0
- package/node_modules/glob/dist/commonjs/has-magic.d.ts +14 -0
- package/node_modules/glob/dist/commonjs/has-magic.d.ts.map +1 -0
- package/node_modules/glob/dist/commonjs/has-magic.js +27 -0
- package/node_modules/glob/dist/commonjs/has-magic.js.map +1 -0
- package/node_modules/glob/dist/commonjs/ignore.d.ts +24 -0
- package/node_modules/glob/dist/commonjs/ignore.d.ts.map +1 -0
- package/node_modules/glob/dist/commonjs/ignore.js +119 -0
- package/node_modules/glob/dist/commonjs/ignore.js.map +1 -0
- package/node_modules/glob/dist/commonjs/index.d.ts +97 -0
- package/node_modules/glob/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/glob/dist/commonjs/index.js +68 -0
- package/node_modules/glob/dist/commonjs/index.js.map +1 -0
- package/node_modules/glob/dist/commonjs/index.min.js +4 -0
- package/node_modules/glob/dist/commonjs/index.min.js.map +7 -0
- package/node_modules/glob/dist/commonjs/package.json +3 -0
- package/node_modules/glob/dist/commonjs/pattern.d.ts +76 -0
- package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -0
- package/node_modules/glob/dist/commonjs/pattern.js +219 -0
- package/node_modules/glob/dist/commonjs/pattern.js.map +1 -0
- package/node_modules/glob/dist/commonjs/processor.d.ts +59 -0
- package/node_modules/glob/dist/commonjs/processor.d.ts.map +1 -0
- package/node_modules/glob/dist/commonjs/processor.js +301 -0
- package/node_modules/glob/dist/commonjs/processor.js.map +1 -0
- package/node_modules/glob/dist/commonjs/walker.d.ts +97 -0
- package/node_modules/glob/dist/commonjs/walker.d.ts.map +1 -0
- package/node_modules/glob/dist/commonjs/walker.js +387 -0
- package/node_modules/glob/dist/commonjs/walker.js.map +1 -0
- package/node_modules/glob/dist/esm/glob.d.ts +388 -0
- package/node_modules/glob/dist/esm/glob.d.ts.map +1 -0
- package/node_modules/glob/dist/esm/glob.js +243 -0
- package/node_modules/glob/dist/esm/glob.js.map +1 -0
- package/node_modules/glob/dist/esm/has-magic.d.ts +14 -0
- package/node_modules/glob/dist/esm/has-magic.d.ts.map +1 -0
- package/node_modules/glob/dist/esm/has-magic.js +23 -0
- package/node_modules/glob/dist/esm/has-magic.js.map +1 -0
- package/node_modules/glob/dist/esm/ignore.d.ts +24 -0
- package/node_modules/glob/dist/esm/ignore.d.ts.map +1 -0
- package/node_modules/glob/dist/esm/ignore.js +115 -0
- package/node_modules/glob/dist/esm/ignore.js.map +1 -0
- package/node_modules/glob/dist/esm/index.d.ts +97 -0
- package/node_modules/glob/dist/esm/index.d.ts.map +1 -0
- package/node_modules/glob/dist/esm/index.js +55 -0
- package/node_modules/glob/dist/esm/index.js.map +1 -0
- package/node_modules/glob/dist/esm/index.min.js +4 -0
- package/node_modules/glob/dist/esm/index.min.js.map +7 -0
- package/node_modules/glob/dist/esm/package.json +3 -0
- package/node_modules/glob/dist/esm/pattern.d.ts +76 -0
- package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -0
- package/node_modules/glob/dist/esm/pattern.js +215 -0
- package/node_modules/glob/dist/esm/pattern.js.map +1 -0
- package/node_modules/glob/dist/esm/processor.d.ts +59 -0
- package/node_modules/glob/dist/esm/processor.d.ts.map +1 -0
- package/node_modules/glob/dist/esm/processor.js +294 -0
- package/node_modules/glob/dist/esm/processor.js.map +1 -0
- package/node_modules/glob/dist/esm/walker.d.ts +97 -0
- package/node_modules/glob/dist/esm/walker.d.ts.map +1 -0
- package/node_modules/glob/dist/esm/walker.js +381 -0
- package/node_modules/glob/dist/esm/walker.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/LICENSE.md +55 -0
- package/node_modules/glob/node_modules/minimatch/README.md +453 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +2 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js +14 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +20 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +591 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts +8 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +152 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +15 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +30 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +94 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +1029 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/package.json +3 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +22 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +38 -0
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +2 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js +10 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +20 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +587 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts +8 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +148 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +15 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +26 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +94 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +1016 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/package.json +3 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +22 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +34 -0
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -0
- package/node_modules/glob/node_modules/minimatch/package.json +67 -0
- package/node_modules/glob/package.json +101 -0
- package/node_modules/minipass/LICENSE +15 -0
- package/node_modules/minipass/README.md +825 -0
- package/node_modules/minipass/dist/commonjs/index.d.ts +549 -0
- package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/minipass/dist/commonjs/index.js +1028 -0
- package/node_modules/minipass/dist/commonjs/index.js.map +1 -0
- package/node_modules/minipass/dist/commonjs/package.json +3 -0
- package/node_modules/minipass/dist/esm/index.d.ts +549 -0
- package/node_modules/minipass/dist/esm/index.d.ts.map +1 -0
- package/node_modules/minipass/dist/esm/index.js +1018 -0
- package/node_modules/minipass/dist/esm/index.js.map +1 -0
- package/node_modules/minipass/dist/esm/package.json +3 -0
- package/node_modules/minipass/package.json +82 -0
- package/node_modules/package-json-from-dist/LICENSE.md +63 -0
- package/node_modules/package-json-from-dist/README.md +110 -0
- package/node_modules/package-json-from-dist/dist/commonjs/index.d.ts +89 -0
- package/node_modules/package-json-from-dist/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/package-json-from-dist/dist/commonjs/index.js +134 -0
- package/node_modules/package-json-from-dist/dist/commonjs/index.js.map +1 -0
- package/node_modules/package-json-from-dist/dist/commonjs/package.json +3 -0
- package/node_modules/package-json-from-dist/dist/esm/index.d.ts +89 -0
- package/node_modules/package-json-from-dist/dist/esm/index.d.ts.map +1 -0
- package/node_modules/package-json-from-dist/dist/esm/index.js +129 -0
- package/node_modules/package-json-from-dist/dist/esm/index.js.map +1 -0
- package/node_modules/package-json-from-dist/dist/esm/package.json +3 -0
- package/node_modules/package-json-from-dist/package.json +68 -0
- package/node_modules/path-scurry/LICENSE.md +55 -0
- package/node_modules/path-scurry/README.md +636 -0
- package/node_modules/path-scurry/dist/commonjs/index.d.ts +1115 -0
- package/node_modules/path-scurry/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/path-scurry/dist/commonjs/index.js +2018 -0
- package/node_modules/path-scurry/dist/commonjs/index.js.map +1 -0
- package/node_modules/path-scurry/dist/commonjs/package.json +3 -0
- package/node_modules/path-scurry/dist/esm/index.d.ts +1115 -0
- package/node_modules/path-scurry/dist/esm/index.d.ts.map +1 -0
- package/node_modules/path-scurry/dist/esm/index.js +1983 -0
- package/node_modules/path-scurry/dist/esm/index.js.map +1 -0
- package/node_modules/path-scurry/dist/esm/package.json +3 -0
- package/node_modules/path-scurry/node_modules/lru-cache/LICENSE.md +55 -0
- package/node_modules/path-scurry/node_modules/lru-cache/README.md +383 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +1323 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +1589 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/package.json +3 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +1323 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +1585 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/package.json +3 -0
- package/node_modules/path-scurry/node_modules/lru-cache/package.json +101 -0
- package/node_modules/path-scurry/package.json +88 -0
- package/node_modules/rimraf/LICENSE.md +55 -0
- package/node_modules/rimraf/README.md +226 -0
- package/node_modules/rimraf/dist/commonjs/default-tmp.d.ts +3 -0
- package/node_modules/rimraf/dist/commonjs/default-tmp.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/default-tmp.js +58 -0
- package/node_modules/rimraf/dist/commonjs/default-tmp.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/error.d.ts +6 -0
- package/node_modules/rimraf/dist/commonjs/error.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/error.js +10 -0
- package/node_modules/rimraf/dist/commonjs/error.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/fix-eperm.d.ts +3 -0
- package/node_modules/rimraf/dist/commonjs/fix-eperm.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/fix-eperm.js +38 -0
- package/node_modules/rimraf/dist/commonjs/fix-eperm.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/fs.d.ts +15 -0
- package/node_modules/rimraf/dist/commonjs/fs.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/fs.js +33 -0
- package/node_modules/rimraf/dist/commonjs/fs.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/ignore-enoent.d.ts +3 -0
- package/node_modules/rimraf/dist/commonjs/ignore-enoent.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/ignore-enoent.js +24 -0
- package/node_modules/rimraf/dist/commonjs/ignore-enoent.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/index.d.ts +50 -0
- package/node_modules/rimraf/dist/commonjs/index.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/index.js +78 -0
- package/node_modules/rimraf/dist/commonjs/index.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/opt-arg.d.ts +34 -0
- package/node_modules/rimraf/dist/commonjs/opt-arg.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/opt-arg.js +53 -0
- package/node_modules/rimraf/dist/commonjs/opt-arg.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/package.json +3 -0
- package/node_modules/rimraf/dist/commonjs/path-arg.d.ts +4 -0
- package/node_modules/rimraf/dist/commonjs/path-arg.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/path-arg.js +48 -0
- package/node_modules/rimraf/dist/commonjs/path-arg.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/readdir-or-error.d.ts +3 -0
- package/node_modules/rimraf/dist/commonjs/readdir-or-error.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/readdir-or-error.js +19 -0
- package/node_modules/rimraf/dist/commonjs/readdir-or-error.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/retry-busy.d.ts +8 -0
- package/node_modules/rimraf/dist/commonjs/retry-busy.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/retry-busy.js +65 -0
- package/node_modules/rimraf/dist/commonjs/retry-busy.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-manual.d.ts +3 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-manual.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-manual.js +8 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-manual.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-move-remove.d.ts +4 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-move-remove.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-move-remove.js +138 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-move-remove.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-native.d.ts +4 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-native.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-native.js +24 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-native.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-posix.d.ts +4 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-posix.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-posix.js +103 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-posix.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-windows.d.ts +4 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-windows.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-windows.js +159 -0
- package/node_modules/rimraf/dist/commonjs/rimraf-windows.js.map +1 -0
- package/node_modules/rimraf/dist/commonjs/use-native.d.ts +4 -0
- package/node_modules/rimraf/dist/commonjs/use-native.d.ts.map +1 -0
- package/node_modules/rimraf/dist/commonjs/use-native.js +18 -0
- package/node_modules/rimraf/dist/commonjs/use-native.js.map +1 -0
- package/node_modules/rimraf/dist/esm/bin.d.mts +3 -0
- package/node_modules/rimraf/dist/esm/bin.d.mts.map +1 -0
- package/node_modules/rimraf/dist/esm/bin.mjs +250 -0
- package/node_modules/rimraf/dist/esm/bin.mjs.map +1 -0
- package/node_modules/rimraf/dist/esm/default-tmp.d.ts +3 -0
- package/node_modules/rimraf/dist/esm/default-tmp.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/default-tmp.js +55 -0
- package/node_modules/rimraf/dist/esm/default-tmp.js.map +1 -0
- package/node_modules/rimraf/dist/esm/error.d.ts +6 -0
- package/node_modules/rimraf/dist/esm/error.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/error.js +5 -0
- package/node_modules/rimraf/dist/esm/error.js.map +1 -0
- package/node_modules/rimraf/dist/esm/fix-eperm.d.ts +3 -0
- package/node_modules/rimraf/dist/esm/fix-eperm.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/fix-eperm.js +33 -0
- package/node_modules/rimraf/dist/esm/fix-eperm.js.map +1 -0
- package/node_modules/rimraf/dist/esm/fs.d.ts +15 -0
- package/node_modules/rimraf/dist/esm/fs.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/fs.js +18 -0
- package/node_modules/rimraf/dist/esm/fs.js.map +1 -0
- package/node_modules/rimraf/dist/esm/ignore-enoent.d.ts +3 -0
- package/node_modules/rimraf/dist/esm/ignore-enoent.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/ignore-enoent.js +19 -0
- package/node_modules/rimraf/dist/esm/ignore-enoent.js.map +1 -0
- package/node_modules/rimraf/dist/esm/index.d.ts +50 -0
- package/node_modules/rimraf/dist/esm/index.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/index.js +70 -0
- package/node_modules/rimraf/dist/esm/index.js.map +1 -0
- package/node_modules/rimraf/dist/esm/opt-arg.d.ts +34 -0
- package/node_modules/rimraf/dist/esm/opt-arg.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/opt-arg.js +46 -0
- package/node_modules/rimraf/dist/esm/opt-arg.js.map +1 -0
- package/node_modules/rimraf/dist/esm/package.json +3 -0
- package/node_modules/rimraf/dist/esm/path-arg.d.ts +4 -0
- package/node_modules/rimraf/dist/esm/path-arg.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/path-arg.js +46 -0
- package/node_modules/rimraf/dist/esm/path-arg.js.map +1 -0
- package/node_modules/rimraf/dist/esm/readdir-or-error.d.ts +3 -0
- package/node_modules/rimraf/dist/esm/readdir-or-error.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/readdir-or-error.js +14 -0
- package/node_modules/rimraf/dist/esm/readdir-or-error.js.map +1 -0
- package/node_modules/rimraf/dist/esm/retry-busy.d.ts +8 -0
- package/node_modules/rimraf/dist/esm/retry-busy.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/retry-busy.js +60 -0
- package/node_modules/rimraf/dist/esm/retry-busy.js.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-manual.d.ts +3 -0
- package/node_modules/rimraf/dist/esm/rimraf-manual.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-manual.js +5 -0
- package/node_modules/rimraf/dist/esm/rimraf-manual.js.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-move-remove.d.ts +4 -0
- package/node_modules/rimraf/dist/esm/rimraf-move-remove.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-move-remove.js +133 -0
- package/node_modules/rimraf/dist/esm/rimraf-move-remove.js.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-native.d.ts +4 -0
- package/node_modules/rimraf/dist/esm/rimraf-native.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-native.js +19 -0
- package/node_modules/rimraf/dist/esm/rimraf-native.js.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-posix.d.ts +4 -0
- package/node_modules/rimraf/dist/esm/rimraf-posix.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-posix.js +98 -0
- package/node_modules/rimraf/dist/esm/rimraf-posix.js.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-windows.d.ts +4 -0
- package/node_modules/rimraf/dist/esm/rimraf-windows.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/rimraf-windows.js +154 -0
- package/node_modules/rimraf/dist/esm/rimraf-windows.js.map +1 -0
- package/node_modules/rimraf/dist/esm/use-native.d.ts +4 -0
- package/node_modules/rimraf/dist/esm/use-native.d.ts.map +1 -0
- package/node_modules/rimraf/dist/esm/use-native.js +15 -0
- package/node_modules/rimraf/dist/esm/use-native.js.map +1 -0
- package/node_modules/rimraf/package.json +92 -0
- package/package.json +152 -0
- package/scripts/install-scanners.js +258 -0
- package/scripts/watchdog.js +147 -0
- package/src/analyzers/CSSAnalyzer.js +297 -0
- package/src/analyzers/ConfigValidator.js +690 -0
- package/src/analyzers/ESLintAnalyzer.js +320 -0
- package/src/analyzers/JavaScriptAnalyzer.js +261 -0
- package/src/analyzers/PrettierFormatter.js +247 -0
- package/src/analyzers/PythonAnalyzer.js +283 -0
- package/src/analyzers/SecurityAnalyzer.js +729 -0
- package/src/analyzers/SparrowAnalyzer.js +341 -0
- package/src/analyzers/TypeScriptAnalyzer.js +247 -0
- package/src/analyzers/codeCloneDetector/analyzer.js +344 -0
- package/src/analyzers/codeCloneDetector/detector.js +250 -0
- package/src/analyzers/codeCloneDetector/index.js +192 -0
- package/src/analyzers/codeCloneDetector/parser.js +199 -0
- package/src/analyzers/codeCloneDetector/reporter.js +148 -0
- package/src/analyzers/codeCloneDetector/scanner.js +88 -0
- package/src/core/agentPool.js +1957 -0
- package/src/core/agentScheduler.js +3212 -0
- package/src/core/contextManager.js +709 -0
- package/src/core/flowExecutor.js +928 -0
- package/src/core/messageProcessor.js +808 -0
- package/src/core/orchestrator.js +584 -0
- package/src/core/stateManager.js +1500 -0
- package/src/index.js +972 -0
- package/src/interfaces/cli.js +553 -0
- package/src/interfaces/terminal/__tests__/smoke/advancedFeatures.test.js +208 -0
- package/src/interfaces/terminal/__tests__/smoke/agentControl.test.js +236 -0
- package/src/interfaces/terminal/__tests__/smoke/agents.test.js +138 -0
- package/src/interfaces/terminal/__tests__/smoke/components.test.js +137 -0
- package/src/interfaces/terminal/__tests__/smoke/connection.test.js +350 -0
- package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -0
- package/src/interfaces/terminal/__tests__/smoke/imports.test.js +332 -0
- package/src/interfaces/terminal/__tests__/smoke/messages.test.js +256 -0
- package/src/interfaces/terminal/__tests__/smoke/tools.test.js +388 -0
- package/src/interfaces/terminal/api/apiClient.js +299 -0
- package/src/interfaces/terminal/api/messageRouter.js +262 -0
- package/src/interfaces/terminal/api/session.js +266 -0
- package/src/interfaces/terminal/api/websocket.js +497 -0
- package/src/interfaces/terminal/components/AgentCreator.js +705 -0
- package/src/interfaces/terminal/components/AgentEditor.js +678 -0
- package/src/interfaces/terminal/components/AgentSwitcher.js +330 -0
- package/src/interfaces/terminal/components/ErrorBoundary.js +92 -0
- package/src/interfaces/terminal/components/ErrorPanel.js +264 -0
- package/src/interfaces/terminal/components/Header.js +28 -0
- package/src/interfaces/terminal/components/HelpPanel.js +231 -0
- package/src/interfaces/terminal/components/InputBox.js +118 -0
- package/src/interfaces/terminal/components/Layout.js +603 -0
- package/src/interfaces/terminal/components/LoadingSpinner.js +71 -0
- package/src/interfaces/terminal/components/MessageList.js +281 -0
- package/src/interfaces/terminal/components/MultilineTextInput.js +251 -0
- package/src/interfaces/terminal/components/SearchPanel.js +265 -0
- package/src/interfaces/terminal/components/SettingsPanel.js +415 -0
- package/src/interfaces/terminal/components/StatusBar.js +65 -0
- package/src/interfaces/terminal/components/TextInput.js +127 -0
- package/src/interfaces/terminal/config/agentEditorConstants.js +227 -0
- package/src/interfaces/terminal/config/constants.js +393 -0
- package/src/interfaces/terminal/index.js +168 -0
- package/src/interfaces/terminal/state/useAgentControl.js +496 -0
- package/src/interfaces/terminal/state/useAgents.js +537 -0
- package/src/interfaces/terminal/state/useConnection.js +444 -0
- package/src/interfaces/terminal/state/useMessages.js +630 -0
- package/src/interfaces/terminal/state/useTools.js +554 -0
- package/src/interfaces/terminal/utils/debugLogger.js +44 -0
- package/src/interfaces/terminal/utils/settingsStorage.js +232 -0
- package/src/interfaces/terminal/utils/theme.js +85 -0
- package/src/interfaces/webServer.js +5457 -0
- package/src/modules/fileExplorer/controller.js +413 -0
- package/src/modules/fileExplorer/index.js +37 -0
- package/src/modules/fileExplorer/middleware.js +92 -0
- package/src/modules/fileExplorer/routes.js +158 -0
- package/src/modules/fileExplorer/types.js +44 -0
- package/src/services/agentActivityService.js +399 -0
- package/src/services/aiService.js +2618 -0
- package/src/services/apiKeyManager.js +334 -0
- package/src/services/benchmarkService.js +196 -0
- package/src/services/budgetService.js +565 -0
- package/src/services/contextInjectionService.js +268 -0
- package/src/services/conversationCompactionService.js +1103 -0
- package/src/services/credentialVault.js +685 -0
- package/src/services/errorHandler.js +810 -0
- package/src/services/fileAttachmentService.js +547 -0
- package/src/services/flowContextService.js +189 -0
- package/src/services/memoryService.js +521 -0
- package/src/services/modelRouterService.js +365 -0
- package/src/services/modelsService.js +323 -0
- package/src/services/ollamaService.js +452 -0
- package/src/services/portRegistry.js +336 -0
- package/src/services/portTracker.js +223 -0
- package/src/services/projectDetector.js +404 -0
- package/src/services/promptService.js +372 -0
- package/src/services/qualityInspector.js +796 -0
- package/src/services/scheduleService.js +725 -0
- package/src/services/serviceRegistry.js +386 -0
- package/src/services/skillsService.js +486 -0
- package/src/services/telegramService.js +920 -0
- package/src/services/tokenCountingService.js +316 -0
- package/src/services/visualEditorBridge.js +1033 -0
- package/src/services/visualEditorServer.js +1727 -0
- package/src/services/whatsappService.js +663 -0
- package/src/tools/__tests__/webTool.e2e.test.js +569 -0
- package/src/tools/__tests__/webTool.unit.test.js +195 -0
- package/src/tools/agentCommunicationTool.js +1343 -0
- package/src/tools/agentDelayTool.js +498 -0
- package/src/tools/asyncToolManager.js +604 -0
- package/src/tools/baseTool.js +887 -0
- package/src/tools/browserTool.js +897 -0
- package/src/tools/cloneDetectionTool.js +581 -0
- package/src/tools/codeMapTool.js +857 -0
- package/src/tools/dependencyResolverTool.js +1212 -0
- package/src/tools/docxTool.js +623 -0
- package/src/tools/excelTool.js +636 -0
- package/src/tools/fileContentReplaceTool.js +840 -0
- package/src/tools/fileTreeTool.js +833 -0
- package/src/tools/filesystemTool.js +1217 -0
- package/src/tools/helpTool.js +198 -0
- package/src/tools/imageTool.js +1034 -0
- package/src/tools/importAnalyzerTool.js +1056 -0
- package/src/tools/jobDoneTool.js +388 -0
- package/src/tools/memoryTool.js +554 -0
- package/src/tools/pdfTool.js +627 -0
- package/src/tools/seekTool.js +883 -0
- package/src/tools/skillsTool.js +276 -0
- package/src/tools/staticAnalysisTool.js +2146 -0
- package/src/tools/taskManagerTool.js +2836 -0
- package/src/tools/terminalTool.js +2486 -0
- package/src/tools/userPromptTool.js +474 -0
- package/src/tools/videoTool.js +1139 -0
- package/src/tools/visionTool.js +507 -0
- package/src/tools/visualEditorTool.js +1175 -0
- package/src/tools/webTool.js +3114 -0
- package/src/tools/whatsappTool.js +457 -0
- package/src/types/agent.js +519 -0
- package/src/types/contextReference.js +972 -0
- package/src/types/conversation.js +730 -0
- package/src/types/toolCommand.js +747 -0
- package/src/utilities/attachmentValidator.js +288 -0
- package/src/utilities/browserStealth.js +630 -0
- package/src/utilities/configManager.js +618 -0
- package/src/utilities/constants.js +870 -0
- package/src/utilities/directoryAccessManager.js +566 -0
- package/src/utilities/fileProcessor.js +307 -0
- package/src/utilities/humanBehavior.js +453 -0
- package/src/utilities/jsonRepair.js +242 -0
- package/src/utilities/logger.js +436 -0
- package/src/utilities/platformUtils.js +255 -0
- package/src/utilities/platformUtils.test.js +98 -0
- package/src/utilities/stealthConstants.js +377 -0
- package/src/utilities/structuredFileValidator.js +699 -0
- package/src/utilities/tagParser.js +878 -0
- package/src/utilities/toolConstants.js +415 -0
- package/src/utilities/userDataDir.js +300 -0
- package/web-ui/build/brands/autopilot/favicon.svg +1 -0
- package/web-ui/build/brands/autopilot/logo.webp +0 -0
- package/web-ui/build/brands/onbuzz/favicon.svg +1 -0
- package/web-ui/build/brands/onbuzz/logo-text.webp +0 -0
- package/web-ui/build/brands/onbuzz/logo.webp +0 -0
- package/web-ui/build/index.html +15 -0
- package/web-ui/build/logo.png +0 -0
- package/web-ui/build/logo2.png +0 -0
- package/web-ui/build/static/index-SmQFfvBs.js +746 -0
- package/web-ui/build/static/index-V2ySwjHp.css +1 -0
|
@@ -0,0 +1,3114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebTool - Web browsing and automation with Puppeteer
|
|
3
|
+
*
|
|
4
|
+
* Purpose:
|
|
5
|
+
* - Search the web using known search engines
|
|
6
|
+
* - Fetch web content in various formats
|
|
7
|
+
* - Interactive browser automation with command chaining
|
|
8
|
+
* - Tab management with agent isolation
|
|
9
|
+
* - Screenshot capture and AI-powered analysis
|
|
10
|
+
* - Mouse and keyboard event simulation
|
|
11
|
+
*
|
|
12
|
+
* NOTE: This tool replaces the deprecated BrowserTool (December 2024).
|
|
13
|
+
* WebTool provides equivalent functionality with a singleton browser instance
|
|
14
|
+
* architecture for better resource management.
|
|
15
|
+
*
|
|
16
|
+
* ============================================================================
|
|
17
|
+
* TODO: FEATURES TO ADD (migrated from deprecated BrowserTool)
|
|
18
|
+
* ============================================================================
|
|
19
|
+
*
|
|
20
|
+
* TODO 1: TIME-BASED WAIT ACTION
|
|
21
|
+
* -----------------------------------------------------------------------------
|
|
22
|
+
* Add a time-based wait capability alongside the existing 'wait-for-selector'.
|
|
23
|
+
* The deprecated BrowserTool supported: { "action": "wait", "waitTime": 3000 }
|
|
24
|
+
*
|
|
25
|
+
* Implementation details:
|
|
26
|
+
* - Add new action type: 'wait' or 'delay' (distinct from 'wait-for-selector')
|
|
27
|
+
* - Accept 'waitTime' parameter in milliseconds (e.g., 1000 = 1 second)
|
|
28
|
+
* - Use: await new Promise(resolve => setTimeout(resolve, waitTime))
|
|
29
|
+
* - Max limit suggestion: 30000ms (30 seconds) to prevent abuse
|
|
30
|
+
* - Use case: Wait for JavaScript-rendered content, animations, or rate limiting
|
|
31
|
+
*
|
|
32
|
+
* Example invocation:
|
|
33
|
+
* ```json
|
|
34
|
+
* {
|
|
35
|
+
* "toolId": "web",
|
|
36
|
+
* "operation": "interactive",
|
|
37
|
+
* "tabName": "my-tab",
|
|
38
|
+
* "commands": [
|
|
39
|
+
* { "action": "navigate", "url": "https://example.com" },
|
|
40
|
+
* { "action": "wait", "waitTime": 2000 },
|
|
41
|
+
* { "action": "screenshot" }
|
|
42
|
+
* ]
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* TODO 2: IMPROVED TYPE ACTION WITH ELEMENT WAITING AND CLEARING
|
|
47
|
+
* -----------------------------------------------------------------------------
|
|
48
|
+
* Enhance the 'type' action to automatically wait for the element and optionally
|
|
49
|
+
* clear existing content before typing. The deprecated BrowserTool had better
|
|
50
|
+
* handling for form inputs.
|
|
51
|
+
*
|
|
52
|
+
* Implementation details:
|
|
53
|
+
* - Before typing, wait for the selector to be visible (with configurable timeout)
|
|
54
|
+
* - Add 'clearFirst' option (boolean, default: true) to clear input before typing
|
|
55
|
+
* - Add 'delay' option for human-like typing speed (ms between keystrokes)
|
|
56
|
+
* - Use page.click(selector) to focus, then page.keyboard.type() for realistic input
|
|
57
|
+
* - Consider using page.$eval to clear: element.value = ''
|
|
58
|
+
*
|
|
59
|
+
* Example invocation:
|
|
60
|
+
* ```json
|
|
61
|
+
* {
|
|
62
|
+
* "action": "type",
|
|
63
|
+
* "selector": "#search-input",
|
|
64
|
+
* "text": "search query",
|
|
65
|
+
* "clearFirst": true,
|
|
66
|
+
* "delay": 50,
|
|
67
|
+
* "waitForSelector": true,
|
|
68
|
+
* "timeout": 5000
|
|
69
|
+
* }
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* TODO 3: FLAT ACTION STRUCTURE (NO WRAPPER REQUIRED)
|
|
73
|
+
* -----------------------------------------------------------------------------
|
|
74
|
+
* Allow executing single actions without requiring the 'open-tab' or 'interactive'
|
|
75
|
+
* wrapper structure. This makes simple operations more concise.
|
|
76
|
+
*
|
|
77
|
+
* Implementation details:
|
|
78
|
+
* - Detect when params contain a single 'action' at the top level
|
|
79
|
+
* - Auto-create or reuse a default tab for the agent
|
|
80
|
+
* - Execute the action directly without requiring explicit tab management
|
|
81
|
+
* - Useful for quick one-off operations like screenshots or navigation
|
|
82
|
+
*
|
|
83
|
+
* Example invocation (simplified):
|
|
84
|
+
* ```json
|
|
85
|
+
* {
|
|
86
|
+
* "toolId": "web",
|
|
87
|
+
* "action": "navigate",
|
|
88
|
+
* "url": "https://example.com"
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
91
|
+
* Instead of:
|
|
92
|
+
* ```json
|
|
93
|
+
* {
|
|
94
|
+
* "toolId": "web",
|
|
95
|
+
* "operation": "interactive",
|
|
96
|
+
* "tabName": "default",
|
|
97
|
+
* "commands": [{ "action": "navigate", "url": "https://example.com" }]
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* TODO 4: HUMAN-LIKE BROWSING DELAYS
|
|
102
|
+
* -----------------------------------------------------------------------------
|
|
103
|
+
* Add configurable delays between actions to make browsing appear more human-like.
|
|
104
|
+
* This helps avoid bot detection and rate limiting on websites.
|
|
105
|
+
*
|
|
106
|
+
* Implementation details:
|
|
107
|
+
* - Add 'humanMode' or 'naturalDelay' option to interactive operations
|
|
108
|
+
* - When enabled, add random delays (e.g., 500-2000ms) between commands
|
|
109
|
+
* - Add random mouse movements before clicks
|
|
110
|
+
* - Vary typing speed with random delays between keystrokes
|
|
111
|
+
* - Consider adding scroll jitter and viewport variations
|
|
112
|
+
*
|
|
113
|
+
* Example:
|
|
114
|
+
* ```json
|
|
115
|
+
* {
|
|
116
|
+
* "toolId": "web",
|
|
117
|
+
* "operation": "interactive",
|
|
118
|
+
* "humanMode": true,
|
|
119
|
+
* "commands": [...]
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
* ============================================================================
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
import { BaseTool } from './baseTool.js';
|
|
126
|
+
import TagParser from '../utilities/tagParser.js';
|
|
127
|
+
import path from 'path';
|
|
128
|
+
import fs from 'fs/promises';
|
|
129
|
+
import os from 'os';
|
|
130
|
+
|
|
131
|
+
import {
|
|
132
|
+
TOOL_STATUS,
|
|
133
|
+
SYSTEM_DEFAULTS
|
|
134
|
+
} from '../utilities/constants.js';
|
|
135
|
+
|
|
136
|
+
// Stealth browser and human behavior utilities
|
|
137
|
+
import {
|
|
138
|
+
createStealthBrowser,
|
|
139
|
+
createStealthPage,
|
|
140
|
+
getRandomViewport
|
|
141
|
+
} from '../utilities/browserStealth.js';
|
|
142
|
+
|
|
143
|
+
import {
|
|
144
|
+
createHumanCursor,
|
|
145
|
+
humanType,
|
|
146
|
+
humanWait,
|
|
147
|
+
humanScroll,
|
|
148
|
+
humanSubmit
|
|
149
|
+
} from '../utilities/humanBehavior.js';
|
|
150
|
+
|
|
151
|
+
import { BROWSER_CONFIG, KNOWN_SITES, LOGIN_FIELD_PATTERNS } from '../utilities/stealthConstants.js';
|
|
152
|
+
|
|
153
|
+
// Credential vault for secure authentication
|
|
154
|
+
import { getCredentialVault } from '../services/credentialVault.js';
|
|
155
|
+
|
|
156
|
+
class WebTool extends BaseTool {
|
|
157
|
+
constructor(config = {}, logger = null) {
|
|
158
|
+
super(config, logger);
|
|
159
|
+
|
|
160
|
+
// Tool metadata
|
|
161
|
+
this.requiresProject = false;
|
|
162
|
+
this.isAsync = true;
|
|
163
|
+
this.builtinDelay = 3000; // 3 second delay after browser operations
|
|
164
|
+
|
|
165
|
+
// Browser instance (singleton per system)
|
|
166
|
+
this.browser = null;
|
|
167
|
+
this.browserInitializing = false;
|
|
168
|
+
|
|
169
|
+
// Tab tracking: Map<agentId, Map<tabName, tabInfo>>
|
|
170
|
+
this.agentTabs = new Map();
|
|
171
|
+
|
|
172
|
+
// Known search engines
|
|
173
|
+
this.searchEngines = [
|
|
174
|
+
{
|
|
175
|
+
name: 'google',
|
|
176
|
+
url: 'https://www.google.com/search?q=',
|
|
177
|
+
searchSelector: 'input[name="q"]',
|
|
178
|
+
submitSelector: 'input[type="submit"], button[type="submit"]',
|
|
179
|
+
// Google's DOM varies by region/bot-detection — use broad selectors
|
|
180
|
+
resultsSelector: '#search .g a[href], #rso a[href], .g a[href], div[data-hveid] a[href]',
|
|
181
|
+
waitSelector: '#search, #rso, #main'
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'bing',
|
|
185
|
+
url: 'https://www.bing.com/search?q=',
|
|
186
|
+
searchSelector: 'input[name="q"]',
|
|
187
|
+
submitSelector: 'input[type="submit"]',
|
|
188
|
+
resultsSelector: '.b_algo a, .b_algo h2 a, li.b_algo a',
|
|
189
|
+
waitSelector: '#b_results, #b_content'
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: 'duckduckgo',
|
|
193
|
+
url: 'https://duckduckgo.com/?q=',
|
|
194
|
+
searchSelector: 'input[name="q"]',
|
|
195
|
+
submitSelector: 'button[type="submit"]',
|
|
196
|
+
resultsSelector: '.result__a, a[data-testid="result-title-a"], article a[href]',
|
|
197
|
+
waitSelector: '#links, [data-testid="mainline"], .results'
|
|
198
|
+
}
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
// Configuration
|
|
202
|
+
this.TAB_IDLE_TIMEOUT = config.tabIdleTimeout || 60 * 60 * 1000; // 1 hour
|
|
203
|
+
this.CLEANUP_INTERVAL = config.cleanupInterval || 5 * 60 * 1000; // 5 minutes
|
|
204
|
+
this.DEFAULT_TIMEOUT = config.defaultTimeout || 60000; // 60 seconds
|
|
205
|
+
this.TEMP_DIR = config.tempDir || path.join(os.tmpdir(), 'webtool-screenshots');
|
|
206
|
+
|
|
207
|
+
// Start cleanup timer
|
|
208
|
+
this.cleanupTimer = null;
|
|
209
|
+
this.startCleanupTimer();
|
|
210
|
+
|
|
211
|
+
// Ensure temp directory exists
|
|
212
|
+
this.ensureTempDir();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get tool description for LLM consumption
|
|
217
|
+
* @returns {string} Tool description
|
|
218
|
+
*/
|
|
219
|
+
getDescription() {
|
|
220
|
+
return `
|
|
221
|
+
Web Tool: Browse, search, and automate web interactions using Puppeteer.
|
|
222
|
+
|
|
223
|
+
## AUTHENTICATED BROWSING WORKFLOW (IMPORTANT - READ FIRST)
|
|
224
|
+
|
|
225
|
+
When you need to browse a site that requires login, follow this workflow:
|
|
226
|
+
|
|
227
|
+
### Option A: Keep tab open (RECOMMENDED for immediate follow-up browsing)
|
|
228
|
+
\`\`\`json
|
|
229
|
+
Step 1: Authenticate with keepTabOpen
|
|
230
|
+
{"toolId": "web", "operation": "authenticate", "site": "mysite", "loginUrl": "https://example.com/login", "tabName": "session", "keepTabOpen": true}
|
|
231
|
+
|
|
232
|
+
Step 2: Continue browsing in same tab (MUST use stealthLevel: "maximum")
|
|
233
|
+
{"toolId": "web", "operation": "interactive", "stealthLevel": "maximum", "actions": [
|
|
234
|
+
{"type": "switch-tab", "name": "session"},
|
|
235
|
+
{"type": "navigate", "url": "https://example.com/dashboard"},
|
|
236
|
+
{"type": "extract-text", "selector": ".content"}
|
|
237
|
+
]}
|
|
238
|
+
\`\`\`
|
|
239
|
+
|
|
240
|
+
### Option B: Open new tab after authentication (cookies auto-restored)
|
|
241
|
+
\`\`\`json
|
|
242
|
+
Step 1: Authenticate (tab closes after login, cookies saved)
|
|
243
|
+
{"toolId": "web", "operation": "authenticate", "site": "mysite", "loginUrl": "https://example.com/login"}
|
|
244
|
+
|
|
245
|
+
Step 2: Open new tab - cookies are automatically restored (MUST use stealthLevel: "maximum")
|
|
246
|
+
{"toolId": "web", "operation": "interactive", "stealthLevel": "maximum", "actions": [
|
|
247
|
+
{"type": "open-tab", "name": "browsing", "url": "https://example.com/dashboard", "nestedActions": [
|
|
248
|
+
{"type": "extract-text", "selector": ".content"}
|
|
249
|
+
]}
|
|
250
|
+
]}
|
|
251
|
+
\`\`\`
|
|
252
|
+
|
|
253
|
+
**CRITICAL RULES:**
|
|
254
|
+
1. ALWAYS use stealthLevel: "maximum" after authentication (changing levels restarts browser)
|
|
255
|
+
2. NEVER type passwords with interactive - always use authenticate operation
|
|
256
|
+
3. The agent is PAUSED while waiting for user credentials - this is normal
|
|
257
|
+
|
|
258
|
+
## AUTHENTICATION DETAILS
|
|
259
|
+
|
|
260
|
+
A secure modal appears for user to enter credentials. You NEVER see or type credentials.
|
|
261
|
+
|
|
262
|
+
### Pre-configured sites (no selectors needed):
|
|
263
|
+
linkedin, github, google, twitter
|
|
264
|
+
\`\`\`json
|
|
265
|
+
{"toolId": "web", "operation": "authenticate", "site": "linkedin"}
|
|
266
|
+
\`\`\`
|
|
267
|
+
|
|
268
|
+
### Custom sites (provide selectors):
|
|
269
|
+
First FETCH the login page to identify form selectors, then authenticate:
|
|
270
|
+
\`\`\`json
|
|
271
|
+
{"toolId": "web", "operation": "authenticate", "site": "customsite", "loginUrl": "https://example.com/login", "usernameSelector": "input[name='email']", "passwordSelector": "input[type='password']", "submitSelector": "button[type='submit']"}
|
|
272
|
+
\`\`\`
|
|
273
|
+
|
|
274
|
+
## STEALTH LEVELS
|
|
275
|
+
- "standard" (default): Headless browser, invisible. Good for public pages.
|
|
276
|
+
- "maximum": Visible Chrome window. REQUIRED for authenticated browsing and bot detection bypass.
|
|
277
|
+
|
|
278
|
+
## OPERATIONS
|
|
279
|
+
|
|
280
|
+
1. SEARCH - Search the web
|
|
281
|
+
\`\`\`json
|
|
282
|
+
{"toolId": "web", "operation": "search", "query": "search terms", "engine": "google", "maxResults": 10}
|
|
283
|
+
\`\`\`
|
|
284
|
+
|
|
285
|
+
2. FETCH - Get page content (public pages only)
|
|
286
|
+
\`\`\`json
|
|
287
|
+
{"toolId": "web", "operation": "fetch", "url": "https://example.com", "formats": ["title", "text", "links"]}
|
|
288
|
+
\`\`\`
|
|
289
|
+
|
|
290
|
+
3. INTERACTIVE - Browser automation
|
|
291
|
+
\`\`\`json
|
|
292
|
+
{"toolId": "web", "operation": "interactive", "stealthLevel": "maximum", "actions": [
|
|
293
|
+
{"type": "open-tab", "name": "main", "url": "https://example.com", "nestedActions": [
|
|
294
|
+
{"type": "wait-for", "selector": ".content"},
|
|
295
|
+
{"type": "click", "selector": "button"},
|
|
296
|
+
{"type": "extract-text", "selector": "#content"},
|
|
297
|
+
{"type": "screenshot", "format": "file", "path": "screenshot.png"}
|
|
298
|
+
]}
|
|
299
|
+
]}
|
|
300
|
+
\`\`\`
|
|
301
|
+
|
|
302
|
+
## INTERACTIVE ACTIONS
|
|
303
|
+
- Tab: open-tab, close-tab, switch-tab, list-tabs
|
|
304
|
+
- Navigation: navigate, wait-for, wait (time-based delay)
|
|
305
|
+
- Input: click, type, press, hover, scroll, select, submit
|
|
306
|
+
- Extract: extract-text, extract-links, get-source, screenshot, get-field-values, evaluate
|
|
307
|
+
|
|
308
|
+
## FORM FILLING WORKFLOW (RECOMMENDED)
|
|
309
|
+
|
|
310
|
+
When filling and submitting web forms, follow this pattern:
|
|
311
|
+
|
|
312
|
+
### Step 1: Discover the form — fetch page text, then get-source to find selectors
|
|
313
|
+
\`\`\`json
|
|
314
|
+
{"toolId": "web", "operation": "interactive", "stealthLevel": "maximum", "actions": [
|
|
315
|
+
{"type": "open-tab", "name": "form", "url": "https://example.com/signup", "nestedActions": [
|
|
316
|
+
{"type": "extract-text", "selector": "body"},
|
|
317
|
+
{"type": "get-source"}
|
|
318
|
+
]}
|
|
319
|
+
]}
|
|
320
|
+
\`\`\`
|
|
321
|
+
|
|
322
|
+
### Step 2: Fill the form — type into inputs, click checkboxes, select dropdowns
|
|
323
|
+
\`\`\`json
|
|
324
|
+
{"toolId": "web", "operation": "interactive", "stealthLevel": "maximum", "actions": [
|
|
325
|
+
{"type": "open-tab", "name": "form", "nestedActions": [
|
|
326
|
+
{"type": "type", "selector": "#name", "text": "John Doe"},
|
|
327
|
+
{"type": "type", "selector": "#email", "text": "john@example.com"},
|
|
328
|
+
{"type": "select", "selector": "#country", "value": "US"},
|
|
329
|
+
{"type": "click", "selector": "input[name='agree']"},
|
|
330
|
+
{"type": "type", "selector": "#phone", "text": "+1234567890"}
|
|
331
|
+
]}
|
|
332
|
+
]}
|
|
333
|
+
\`\`\`
|
|
334
|
+
NOTE: For checkboxes, use attribute selectors like input[name="agree"] or input[value="Option text"] instead of dynamic IDs.
|
|
335
|
+
NOTE: click on checkbox/radio returns { checked: true/false } so you can verify the toggle state.
|
|
336
|
+
|
|
337
|
+
### Step 3: Verify form state before submitting
|
|
338
|
+
\`\`\`json
|
|
339
|
+
{"toolId": "web", "operation": "interactive", "stealthLevel": "maximum", "actions": [
|
|
340
|
+
{"type": "open-tab", "name": "form", "nestedActions": [
|
|
341
|
+
{"type": "get-field-values", "selectors": ["#name", "#email", "#country", "input[name='agree']"]}
|
|
342
|
+
]}
|
|
343
|
+
]}
|
|
344
|
+
\`\`\`
|
|
345
|
+
Returns: { fields: { "#name": { value: "John Doe" }, "input[name='agree']": { checked: true } } }
|
|
346
|
+
|
|
347
|
+
### Step 4: Submit and check result
|
|
348
|
+
\`\`\`json
|
|
349
|
+
{"toolId": "web", "operation": "interactive", "stealthLevel": "maximum", "actions": [
|
|
350
|
+
{"type": "open-tab", "name": "form", "nestedActions": [
|
|
351
|
+
{"type": "submit", "selector": "button[type='submit']"}
|
|
352
|
+
]}
|
|
353
|
+
]}
|
|
354
|
+
\`\`\`
|
|
355
|
+
Submit returns: { submitConfirmed: true/false, successMessage: "...", networkResponse: [...], formErrors: [...] }
|
|
356
|
+
If submitConfirmed is false, check formErrors for validation messages.
|
|
357
|
+
If submit button is disabled, it returns an error — use get-field-values to find unfilled required fields.
|
|
358
|
+
|
|
359
|
+
## KEY ACTIONS REFERENCE
|
|
360
|
+
|
|
361
|
+
**evaluate** — Run arbitrary JavaScript in the page. Use for custom logic the other actions can't cover.
|
|
362
|
+
\`\`\`json
|
|
363
|
+
{"type": "evaluate", "script": "return document.querySelector('#myField').value"}
|
|
364
|
+
{"type": "evaluate", "script": "return document.querySelectorAll('.item').length"}
|
|
365
|
+
\`\`\`
|
|
366
|
+
|
|
367
|
+
**select** — Select a dropdown option by value or visible text. Works with native <select> and custom dropdowns.
|
|
368
|
+
\`\`\`json
|
|
369
|
+
{"type": "select", "selector": "#country", "value": "United States"}
|
|
370
|
+
\`\`\`
|
|
371
|
+
|
|
372
|
+
**get-field-values** — Read current values of multiple form fields at once (inputs, checkboxes, selects).
|
|
373
|
+
\`\`\`json
|
|
374
|
+
{"type": "get-field-values", "selectors": ["#name", "#email", "input[type='checkbox']", "select#country"]}
|
|
375
|
+
\`\`\`
|
|
376
|
+
|
|
377
|
+
## BOT DETECTION
|
|
378
|
+
If blocked (CAPTCHA, access denied), use stealthLevel: "maximum" (visible browser).
|
|
379
|
+
`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Parse parameters from tool command content
|
|
384
|
+
* @param {string} content - Raw tool command content
|
|
385
|
+
* @returns {Object} Parsed parameters
|
|
386
|
+
*/
|
|
387
|
+
parseParameters(content) {
|
|
388
|
+
try {
|
|
389
|
+
// Try JSON first
|
|
390
|
+
if (content.trim().startsWith('{')) {
|
|
391
|
+
return JSON.parse(content);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Parse XML-style tags
|
|
395
|
+
const params = {};
|
|
396
|
+
|
|
397
|
+
// Extract operation
|
|
398
|
+
const operationMatches = TagParser.extractContent(content, 'operation');
|
|
399
|
+
if (operationMatches.length > 0) {
|
|
400
|
+
params.operation = operationMatches[0].trim();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Extract based on operation
|
|
404
|
+
switch (params.operation) {
|
|
405
|
+
case 'search':
|
|
406
|
+
params.query = TagParser.extractContent(content, 'query')[0]?.trim();
|
|
407
|
+
params.engine = TagParser.extractContent(content, 'engine')[0]?.trim() || 'google';
|
|
408
|
+
const maxResults = TagParser.extractContent(content, 'max-results')[0]?.trim();
|
|
409
|
+
params.maxResults = maxResults ? parseInt(maxResults, 10) : 10;
|
|
410
|
+
// stealthLevel for search (default standard, use maximum if getting blocked)
|
|
411
|
+
params.stealthLevel = TagParser.extractContent(content, 'stealthLevel')[0]?.trim() || 'standard';
|
|
412
|
+
break;
|
|
413
|
+
|
|
414
|
+
case 'fetch':
|
|
415
|
+
params.url = TagParser.extractContent(content, 'url')[0]?.trim();
|
|
416
|
+
const formatStr = TagParser.extractContent(content, 'format')[0]?.trim();
|
|
417
|
+
params.formats = formatStr ? formatStr.split(',').map(f => f.trim()) : ['title', 'text'];
|
|
418
|
+
// stealthLevel for fetch
|
|
419
|
+
params.stealthLevel = TagParser.extractContent(content, 'stealthLevel')[0]?.trim() || 'standard';
|
|
420
|
+
break;
|
|
421
|
+
|
|
422
|
+
case 'interactive':
|
|
423
|
+
// Extract stealthLevel (standard = headless, maximum = visible window)
|
|
424
|
+
const stealthLevelStr = TagParser.extractContent(content, 'stealthLevel')[0]?.trim();
|
|
425
|
+
params.stealthLevel = stealthLevelStr || 'standard';
|
|
426
|
+
|
|
427
|
+
// Legacy headless parameter - maps to stealthLevel for backwards compatibility
|
|
428
|
+
const headlessStr = TagParser.extractContent(content, 'headless')[0]?.trim();
|
|
429
|
+
if (headlessStr === 'false' && !stealthLevelStr) {
|
|
430
|
+
params.stealthLevel = 'maximum'; // headless:false = maximum stealth
|
|
431
|
+
}
|
|
432
|
+
params.headless = params.stealthLevel === 'standard';
|
|
433
|
+
|
|
434
|
+
// Extract humanMode (default true for anti-detection)
|
|
435
|
+
const humanModeStr = TagParser.extractContent(content, 'humanMode')[0]?.trim();
|
|
436
|
+
params.humanMode = humanModeStr !== 'false'; // Default true
|
|
437
|
+
|
|
438
|
+
// Extract actions block
|
|
439
|
+
const actionsContent = TagParser.extractContent(content, 'actions')[0];
|
|
440
|
+
if (actionsContent) {
|
|
441
|
+
params.actions = this.parseActions(actionsContent);
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
|
|
445
|
+
case 'authenticate':
|
|
446
|
+
// Site ID for credential lookup
|
|
447
|
+
params.siteId = TagParser.extractContent(content, 'site')[0]?.trim() ||
|
|
448
|
+
TagParser.extractContent(content, 'siteId')[0]?.trim();
|
|
449
|
+
// Optional custom login URL (required for custom sites)
|
|
450
|
+
params.loginUrl = TagParser.extractContent(content, 'loginUrl')[0]?.trim();
|
|
451
|
+
// Optional tab name (to keep open after auth for continued browsing)
|
|
452
|
+
params.tabName = TagParser.extractContent(content, 'tabName')[0]?.trim();
|
|
453
|
+
// stealthLevel - default to 'maximum' for login pages (visible browser)
|
|
454
|
+
params.stealthLevel = TagParser.extractContent(content, 'stealthLevel')[0]?.trim() || 'maximum';
|
|
455
|
+
// Custom selectors - agent can provide these after analyzing the login page
|
|
456
|
+
params.usernameSelector = TagParser.extractContent(content, 'usernameSelector')[0]?.trim();
|
|
457
|
+
params.passwordSelector = TagParser.extractContent(content, 'passwordSelector')[0]?.trim();
|
|
458
|
+
params.submitSelector = TagParser.extractContent(content, 'submitSelector')[0]?.trim();
|
|
459
|
+
// Keep tab open for continued browsing (requires tabName)
|
|
460
|
+
const keepTabOpenStr = TagParser.extractContent(content, 'keepTabOpen')[0]?.trim()?.toLowerCase();
|
|
461
|
+
params.keepTabOpen = keepTabOpenStr === 'true' || keepTabOpenStr === '1';
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
params.rawContent = content.trim();
|
|
466
|
+
return params;
|
|
467
|
+
|
|
468
|
+
} catch (error) {
|
|
469
|
+
throw new Error(`Failed to parse web tool parameters: ${error.message}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Parse actions from XML content
|
|
475
|
+
* @param {string} content - Actions XML content
|
|
476
|
+
* @returns {Array} Parsed actions
|
|
477
|
+
* @private
|
|
478
|
+
*/
|
|
479
|
+
parseActions(content) {
|
|
480
|
+
const actions = [];
|
|
481
|
+
|
|
482
|
+
// Parse open-tab actions
|
|
483
|
+
const openTabRegex = /<open-tab[^>]*name="([^"]+)"[^>]*>([\s\S]*?)<\/open-tab>/g;
|
|
484
|
+
let match;
|
|
485
|
+
|
|
486
|
+
while ((match = openTabRegex.exec(content)) !== null) {
|
|
487
|
+
const [, name, nestedContent] = match;
|
|
488
|
+
const url = TagParser.extractContent(nestedContent, 'navigate')[0]?.trim();
|
|
489
|
+
|
|
490
|
+
actions.push({
|
|
491
|
+
type: 'open-tab',
|
|
492
|
+
name,
|
|
493
|
+
url,
|
|
494
|
+
nestedActions: this.parseNestedActions(nestedContent)
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Parse other actions (close-tab, switch-tab, list-tabs, etc.)
|
|
499
|
+
const simpleActions = [
|
|
500
|
+
'close-tab', 'switch-tab', 'list-tabs', 'navigate',
|
|
501
|
+
'click', 'type', 'press', 'wait-for', 'screenshot',
|
|
502
|
+
'analyze-screenshot', 'extract-text', 'extract-links',
|
|
503
|
+
'get-source', 'get-console', 'scroll', 'hover', 'mouse-move',
|
|
504
|
+
'wait', 'delay', 'submit', 'evaluate', 'get-field-values', 'select'
|
|
505
|
+
];
|
|
506
|
+
|
|
507
|
+
for (const actionType of simpleActions) {
|
|
508
|
+
const regex = new RegExp(`<${actionType}([^>]*)>([^<]*)<\/${actionType}>`, 'g');
|
|
509
|
+
let actionMatch;
|
|
510
|
+
|
|
511
|
+
while ((actionMatch = regex.exec(content)) !== null) {
|
|
512
|
+
const [, attrs, value] = actionMatch;
|
|
513
|
+
const action = { type: actionType };
|
|
514
|
+
|
|
515
|
+
// Parse attributes
|
|
516
|
+
const attrRegex = /(\w+(?:-\w+)*)="([^"]*)"/g;
|
|
517
|
+
let attrMatch;
|
|
518
|
+
while ((attrMatch = attrRegex.exec(attrs)) !== null) {
|
|
519
|
+
action[attrMatch[1]] = attrMatch[2];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Add value if present
|
|
523
|
+
if (value && value.trim()) {
|
|
524
|
+
action.value = value.trim();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
actions.push(action);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return actions;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Parse nested actions within a tab
|
|
536
|
+
* @param {string} content - Nested actions content
|
|
537
|
+
* @returns {Array} Parsed nested actions
|
|
538
|
+
* @private
|
|
539
|
+
*/
|
|
540
|
+
parseNestedActions(content) {
|
|
541
|
+
const actions = [];
|
|
542
|
+
|
|
543
|
+
const actionTypes = [
|
|
544
|
+
'navigate', 'click', 'type', 'press', 'wait-for', 'screenshot',
|
|
545
|
+
'analyze-screenshot', 'extract-text', 'extract-links',
|
|
546
|
+
'get-source', 'get-console', 'scroll', 'hover', 'mouse-move',
|
|
547
|
+
'wait', 'delay', 'submit', 'evaluate', 'get-field-values', 'select'
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
for (const actionType of actionTypes) {
|
|
551
|
+
const regex = new RegExp(`<${actionType}([^>]*)>([^<]*)<\/${actionType}>|<${actionType}([^>]*)\/>`, 'g');
|
|
552
|
+
let match;
|
|
553
|
+
|
|
554
|
+
while ((match = regex.exec(content)) !== null) {
|
|
555
|
+
const [, attrs1, value, attrs2] = match;
|
|
556
|
+
const attrs = attrs1 || attrs2 || '';
|
|
557
|
+
const action = { type: actionType };
|
|
558
|
+
|
|
559
|
+
// Parse attributes
|
|
560
|
+
const attrRegex = /(\w+(?:-\w+)*)="([^"]*)"/g;
|
|
561
|
+
let attrMatch;
|
|
562
|
+
while ((attrMatch = attrRegex.exec(attrs)) !== null) {
|
|
563
|
+
action[attrMatch[1]] = attrMatch[2];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Add value if present
|
|
567
|
+
if (value && value.trim()) {
|
|
568
|
+
action.value = value.trim();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
actions.push(action);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return actions;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Get required parameters based on operation
|
|
580
|
+
* @returns {Array<string>} Array of required parameter names
|
|
581
|
+
*/
|
|
582
|
+
getRequiredParameters() {
|
|
583
|
+
return ['operation'];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Custom parameter validation
|
|
588
|
+
* @param {Object} params - Parameters to validate
|
|
589
|
+
* @returns {Object} Validation result
|
|
590
|
+
*/
|
|
591
|
+
customValidateParameters(params) {
|
|
592
|
+
const errors = [];
|
|
593
|
+
|
|
594
|
+
if (!['search', 'fetch', 'interactive', 'authenticate'].includes(params.operation)) {
|
|
595
|
+
errors.push('operation must be one of: search, fetch, interactive, authenticate');
|
|
596
|
+
return { valid: false, errors };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
switch (params.operation) {
|
|
600
|
+
case 'search':
|
|
601
|
+
if (!params.query) {
|
|
602
|
+
errors.push('query is required for search operation');
|
|
603
|
+
}
|
|
604
|
+
break;
|
|
605
|
+
|
|
606
|
+
case 'fetch':
|
|
607
|
+
if (!params.url) {
|
|
608
|
+
errors.push('url is required for fetch operation');
|
|
609
|
+
}
|
|
610
|
+
break;
|
|
611
|
+
|
|
612
|
+
case 'interactive':
|
|
613
|
+
if (!params.actions || !Array.isArray(params.actions) || params.actions.length === 0) {
|
|
614
|
+
errors.push('actions array is required for interactive operation');
|
|
615
|
+
}
|
|
616
|
+
break;
|
|
617
|
+
|
|
618
|
+
case 'authenticate':
|
|
619
|
+
if (!params.siteId && !params.site) {
|
|
620
|
+
errors.push('site or siteId is required for authenticate operation');
|
|
621
|
+
}
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
valid: errors.length === 0,
|
|
627
|
+
errors
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Execute tool with parsed parameters
|
|
633
|
+
* @param {Object} params - Parsed parameters
|
|
634
|
+
* @param {Object} context - Execution context
|
|
635
|
+
* @returns {Promise<Object>} Execution result
|
|
636
|
+
*/
|
|
637
|
+
async execute(params, context) {
|
|
638
|
+
// Unwrap tag parser format {value, attributes} for all params
|
|
639
|
+
const unwrappedParams = {};
|
|
640
|
+
for (const [key, value] of Object.entries(params)) {
|
|
641
|
+
if (value && typeof value === 'object' && 'value' in value) {
|
|
642
|
+
unwrappedParams[key] = value.value;
|
|
643
|
+
} else {
|
|
644
|
+
unwrappedParams[key] = value;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
params = unwrappedParams;
|
|
648
|
+
|
|
649
|
+
const { operation } = params;
|
|
650
|
+
const { agentId } = context;
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
let result;
|
|
654
|
+
|
|
655
|
+
// Fix 1: Compute effective stealth level BEFORE ensuring browser
|
|
656
|
+
// This preserves current browser stealth level instead of defaulting to 'standard'
|
|
657
|
+
// which prevents browser restart when agent forgets to specify stealthLevel after authenticate
|
|
658
|
+
const currentBrowserStealth = this.browser?._stealthConfig?.stealthLevel;
|
|
659
|
+
const effectiveStealthLevel = params.stealthLevel || currentBrowserStealth || 'standard';
|
|
660
|
+
|
|
661
|
+
// Ensure browser is initialized with the correct stealth level
|
|
662
|
+
// Note: Each operation also calls ensureBrowser internally, but this pre-check
|
|
663
|
+
// ensures we don't unnecessarily restart the browser
|
|
664
|
+
await this.ensureBrowser({ stealthLevel: effectiveStealthLevel });
|
|
665
|
+
|
|
666
|
+
switch (operation) {
|
|
667
|
+
case 'search':
|
|
668
|
+
result = await this.search(params.query, {
|
|
669
|
+
engine: params.engine || 'google',
|
|
670
|
+
maxResults: params.maxResults || 10,
|
|
671
|
+
stealthLevel: effectiveStealthLevel,
|
|
672
|
+
agentId
|
|
673
|
+
});
|
|
674
|
+
break;
|
|
675
|
+
|
|
676
|
+
case 'fetch':
|
|
677
|
+
result = await this.fetch(params.url, {
|
|
678
|
+
formats: params.formats || ['title', 'text'],
|
|
679
|
+
stealthLevel: effectiveStealthLevel,
|
|
680
|
+
agentId
|
|
681
|
+
});
|
|
682
|
+
break;
|
|
683
|
+
|
|
684
|
+
case 'interactive':
|
|
685
|
+
result = await this.interactive(params.actions, {
|
|
686
|
+
stealthLevel: effectiveStealthLevel,
|
|
687
|
+
humanMode: params.humanMode !== false, // Default true for anti-detection
|
|
688
|
+
agentId,
|
|
689
|
+
context
|
|
690
|
+
});
|
|
691
|
+
break;
|
|
692
|
+
|
|
693
|
+
case 'authenticate':
|
|
694
|
+
// Accept both 'site' and 'siteId' for flexibility
|
|
695
|
+
const siteId = params.siteId || params.site;
|
|
696
|
+
if (!siteId) {
|
|
697
|
+
throw new Error('site or siteId is required for authenticate operation');
|
|
698
|
+
}
|
|
699
|
+
result = await this.authenticate(siteId, {
|
|
700
|
+
loginUrl: params.loginUrl,
|
|
701
|
+
tabName: params.tabName,
|
|
702
|
+
stealthLevel: params.stealthLevel || 'maximum', // Default maximum for login
|
|
703
|
+
keepTabOpen: params.keepTabOpen || false, // Keep tab open for continued browsing
|
|
704
|
+
agentId,
|
|
705
|
+
context,
|
|
706
|
+
// Custom selectors - agent can provide these after analyzing the login page
|
|
707
|
+
customSelectors: (params.usernameSelector || params.passwordSelector || params.submitSelector) ? {
|
|
708
|
+
username: params.usernameSelector,
|
|
709
|
+
password: params.passwordSelector,
|
|
710
|
+
submit: params.submitSelector
|
|
711
|
+
} : null
|
|
712
|
+
});
|
|
713
|
+
break;
|
|
714
|
+
|
|
715
|
+
default:
|
|
716
|
+
throw new Error(`Unknown operation: ${operation}`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Flatten result for easier access (result.title instead of result.result.title)
|
|
720
|
+
// IMPORTANT: Respect the operation's own success flag — don't override it to true
|
|
721
|
+
const operationSuccess = result.success !== undefined ? result.success : true;
|
|
722
|
+
return {
|
|
723
|
+
success: operationSuccess,
|
|
724
|
+
operation,
|
|
725
|
+
toolUsed: 'web',
|
|
726
|
+
// Spread operation-specific data at top level
|
|
727
|
+
data: result,
|
|
728
|
+
// Also keep common properties at top level for convenience
|
|
729
|
+
...(result.title !== undefined && { title: result.title }),
|
|
730
|
+
...(result.text !== undefined && { text: result.text }),
|
|
731
|
+
...(result.url !== undefined && { url: result.url }),
|
|
732
|
+
...(result.results !== undefined && { results: result.results }),
|
|
733
|
+
...(result.resultsCount !== undefined && { resultsCount: result.resultsCount }),
|
|
734
|
+
...(result.error !== undefined && { error: result.error }),
|
|
735
|
+
...(result.warning !== undefined && { warning: result.warning }),
|
|
736
|
+
...(result.httpStatus !== undefined && { httpStatus: result.httpStatus }),
|
|
737
|
+
...(result.suggestion !== undefined && { suggestion: result.suggestion }),
|
|
738
|
+
// Surface page-level errors for agent awareness
|
|
739
|
+
...(result.jsErrors?.length > 0 && { jsErrors: result.jsErrors }),
|
|
740
|
+
...(result.networkFailures?.length > 0 && { networkFailures: result.networkFailures }),
|
|
741
|
+
...(result.httpErrors?.length > 0 && { httpErrors: result.httpErrors })
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
} catch (error) {
|
|
745
|
+
this.logger?.error('Web tool execution failed', {
|
|
746
|
+
operation,
|
|
747
|
+
error: error.message,
|
|
748
|
+
agentId
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// Fix 3: Provide better error context for common browser issues
|
|
752
|
+
let enhancedError = error.message;
|
|
753
|
+
let suggestion = null;
|
|
754
|
+
|
|
755
|
+
// Detect browser connection issues
|
|
756
|
+
if (error.message.includes('Connection closed') ||
|
|
757
|
+
error.message.includes('Target closed') ||
|
|
758
|
+
error.message.includes('Protocol error') ||
|
|
759
|
+
error.message.includes('Session closed')) {
|
|
760
|
+
enhancedError = `Browser connection lost: ${error.message}`;
|
|
761
|
+
suggestion = 'The browser or tab was closed unexpectedly. Try the operation again.';
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Detect stealth level mismatch errors (from Fix 2)
|
|
765
|
+
if (error.message.includes('Cannot change stealth level')) {
|
|
766
|
+
suggestion = 'Use the same stealthLevel as your authenticated session, or close tabs first.';
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Detect tab not found errors
|
|
770
|
+
if (error.message.includes('not found for agent')) {
|
|
771
|
+
enhancedError = `Tab lost: ${error.message}`;
|
|
772
|
+
suggestion = 'The tab may have been closed or the browser was restarted. Re-authenticate and try again.';
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Detect navigation timeout
|
|
776
|
+
if (error.message.includes('Navigation timeout') || error.message.includes('Timeout')) {
|
|
777
|
+
suggestion = 'The page took too long to load. Check your internet connection or try again.';
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
success: false,
|
|
782
|
+
operation,
|
|
783
|
+
error: enhancedError,
|
|
784
|
+
...(suggestion && { suggestion }),
|
|
785
|
+
toolUsed: 'web'
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Ensure browser is initialized
|
|
792
|
+
* @private
|
|
793
|
+
*/
|
|
794
|
+
/**
|
|
795
|
+
* Ensure browser is running with the specified stealth level
|
|
796
|
+
* @param {Object} options - Browser options
|
|
797
|
+
* @param {string} options.stealthLevel - 'standard' (headless) or 'maximum' (visible window)
|
|
798
|
+
*/
|
|
799
|
+
async ensureBrowser(options = {}) {
|
|
800
|
+
const { stealthLevel = 'standard' } = options;
|
|
801
|
+
const requestedLevel = stealthLevel.toLowerCase();
|
|
802
|
+
|
|
803
|
+
// Check if browser exists and matches requested stealth level
|
|
804
|
+
if (this.browser && this.browser.isConnected()) {
|
|
805
|
+
const currentLevel = this.browser._stealthConfig?.stealthLevel || 'standard';
|
|
806
|
+
|
|
807
|
+
// If stealth level matches, reuse browser
|
|
808
|
+
if (currentLevel === requestedLevel) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Stealth level changed - check for active tabs before restarting
|
|
813
|
+
// Fix 2: Warn/block stealth level change when there are active tabs
|
|
814
|
+
let totalActiveTabs = 0;
|
|
815
|
+
const agentsWithTabs = [];
|
|
816
|
+
for (const [agentId, tabsMap] of this.agentTabs.entries()) {
|
|
817
|
+
if (tabsMap.size > 0) {
|
|
818
|
+
totalActiveTabs += tabsMap.size;
|
|
819
|
+
agentsWithTabs.push({ agentId, tabCount: tabsMap.size, tabs: Array.from(tabsMap.keys()) });
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (totalActiveTabs > 0) {
|
|
824
|
+
// Throw an error with clear context about what would happen
|
|
825
|
+
const tabDetails = agentsWithTabs.map(a => `${a.agentId}: [${a.tabs.join(', ')}]`).join('; ');
|
|
826
|
+
throw new Error(
|
|
827
|
+
`Cannot change stealth level from '${currentLevel}' to '${requestedLevel}': ` +
|
|
828
|
+
`${totalActiveTabs} active tab(s) would be destroyed. ` +
|
|
829
|
+
`Active tabs: ${tabDetails}. ` +
|
|
830
|
+
`Either close tabs first with close-tab action, or use stealthLevel: "${currentLevel}" to preserve session.`
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
this.logger?.info('[WebTool] Stealth level changed, restarting browser (no active tabs)', {
|
|
835
|
+
from: currentLevel,
|
|
836
|
+
to: requestedLevel
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
await this.closeBrowser();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (this.browserInitializing) {
|
|
843
|
+
// Wait for browser to finish initializing
|
|
844
|
+
while (this.browserInitializing) {
|
|
845
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
846
|
+
}
|
|
847
|
+
// After waiting, check if the initialized browser matches our stealth level
|
|
848
|
+
if (this.browser && this.browser._stealthConfig?.stealthLevel === requestedLevel) {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
// Otherwise, close and reinitialize
|
|
852
|
+
if (this.browser) {
|
|
853
|
+
await this.closeBrowser();
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
this.browserInitializing = true;
|
|
858
|
+
|
|
859
|
+
try {
|
|
860
|
+
this.logger?.info('[WebTool] Initializing stealth browser', {
|
|
861
|
+
stealthLevel: requestedLevel,
|
|
862
|
+
headless: requestedLevel === 'standard' ? 'new' : false
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// Use stealth browser with specified level
|
|
866
|
+
this.browser = await createStealthBrowser({
|
|
867
|
+
stealthLevel: requestedLevel,
|
|
868
|
+
logger: this.logger
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
this.logger?.info('[WebTool] Stealth browser initialized successfully', {
|
|
872
|
+
stealthLevel: requestedLevel
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
} catch (error) {
|
|
876
|
+
this.logger?.error('[WebTool] Failed to initialize browser', { error: error.message });
|
|
877
|
+
throw new Error(`Browser initialization failed: ${error.message}`);
|
|
878
|
+
} finally {
|
|
879
|
+
this.browserInitializing = false;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Create a stealth page with human-like cursor
|
|
885
|
+
* @param {Object} options - Page options
|
|
886
|
+
* @param {boolean} options.humanMode - Enable human-like behavior
|
|
887
|
+
* @returns {Promise<Object>} Object with page and optional cursor
|
|
888
|
+
*/
|
|
889
|
+
async createPage(options = {}) {
|
|
890
|
+
const { humanMode = false } = options;
|
|
891
|
+
|
|
892
|
+
// Note: ensureBrowser() is NOT called here because the caller should have already
|
|
893
|
+
// called it with the appropriate stealth level. This prevents accidentally
|
|
894
|
+
// resetting the browser to 'standard' stealth when 'maximum' is needed.
|
|
895
|
+
if (!this.browser || !this.browser.isConnected()) {
|
|
896
|
+
throw new Error('Browser not initialized. Call ensureBrowser() first.');
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const page = await createStealthPage(this.browser, {
|
|
900
|
+
logger: this.logger
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
let cursor = null;
|
|
904
|
+
if (humanMode) {
|
|
905
|
+
cursor = createHumanCursor(page);
|
|
906
|
+
this.logger?.info('[WebTool] Human-like cursor enabled for page');
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return { page, cursor };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Search the web using a known search engine
|
|
914
|
+
* @param {string} query - Search query
|
|
915
|
+
* @param {Object} options - Search options
|
|
916
|
+
* @returns {Promise<Object>} Search results
|
|
917
|
+
*/
|
|
918
|
+
async search(query, options = {}) {
|
|
919
|
+
const { engine = 'google', maxResults = 10, agentId, humanMode = true, stealthLevel = 'standard' } = options;
|
|
920
|
+
|
|
921
|
+
// Validate query
|
|
922
|
+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
923
|
+
throw new Error('Search query is required and must be a non-empty string');
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const searchEngine = this.searchEngines.find(e => e.name === engine);
|
|
927
|
+
if (!searchEngine) {
|
|
928
|
+
throw new Error(`Unknown search engine: ${engine}. Available: ${this.searchEngines.map(e => e.name).join(', ')}`);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
this.logger?.info('[WebTool] Performing web search', { query, engine, agentId, humanMode, stealthLevel });
|
|
932
|
+
|
|
933
|
+
// Ensure browser with specified stealth level
|
|
934
|
+
await this.ensureBrowser({ stealthLevel });
|
|
935
|
+
|
|
936
|
+
// Create stealth page with optional human-like cursor
|
|
937
|
+
const { page, cursor } = await this.createPage({ humanMode });
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
// Navigate to search engine
|
|
941
|
+
const searchUrl = `${searchEngine.url}${encodeURIComponent(query)}`;
|
|
942
|
+
const searchNavResponse = await page.goto(searchUrl, {
|
|
943
|
+
waitUntil: BROWSER_CONFIG.WAIT_UNTIL,
|
|
944
|
+
timeout: this.DEFAULT_TIMEOUT
|
|
945
|
+
});
|
|
946
|
+
const searchHttpStatus = searchNavResponse ? searchNavResponse.status() : null;
|
|
947
|
+
if (searchHttpStatus && searchHttpStatus >= 400) {
|
|
948
|
+
return {
|
|
949
|
+
success: false,
|
|
950
|
+
query,
|
|
951
|
+
engine,
|
|
952
|
+
httpStatus: searchHttpStatus,
|
|
953
|
+
error: `Search engine returned HTTP ${searchHttpStatus}. The search engine may be blocking automated requests.`,
|
|
954
|
+
suggestion: 'Try a different search engine or increase stealth level.'
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Human-like wait after navigation
|
|
959
|
+
if (humanMode) {
|
|
960
|
+
await humanWait('navigation');
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Wait for results — try each selector with a shorter timeout, proceed even if none match
|
|
964
|
+
const waitSelectors = searchEngine.waitSelector.split(',').map(s => s.trim());
|
|
965
|
+
let waitResolved = false;
|
|
966
|
+
for (const ws of waitSelectors) {
|
|
967
|
+
try {
|
|
968
|
+
await page.waitForSelector(ws, { timeout: 10000 });
|
|
969
|
+
waitResolved = true;
|
|
970
|
+
break;
|
|
971
|
+
} catch {
|
|
972
|
+
// Try next selector
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (!waitResolved) {
|
|
976
|
+
// Last resort: wait a bit and try to extract whatever is on the page
|
|
977
|
+
this.logger?.warn('[WebTool] No wait selector matched, attempting extraction anyway', { engine });
|
|
978
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Extract results
|
|
982
|
+
const results = await page.evaluate((selector, max) => {
|
|
983
|
+
const links = Array.from(document.querySelectorAll(selector));
|
|
984
|
+
return links.slice(0, max).map(link => ({
|
|
985
|
+
url: link.href,
|
|
986
|
+
title: link.textContent.trim(),
|
|
987
|
+
description: link.closest('.g, .b_algo, .result')?.textContent.trim() || ''
|
|
988
|
+
})).filter(result => result.url && result.url.startsWith('http'));
|
|
989
|
+
}, searchEngine.resultsSelector, maxResults);
|
|
990
|
+
|
|
991
|
+
this.logger?.info('[WebTool] Search completed', { resultsCount: results.length, agentId });
|
|
992
|
+
|
|
993
|
+
const searchResult = {
|
|
994
|
+
success: true,
|
|
995
|
+
query,
|
|
996
|
+
engine,
|
|
997
|
+
resultsCount: results.length,
|
|
998
|
+
results
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// Warn when 0 results — may indicate selector mismatch or CAPTCHA
|
|
1002
|
+
if (results.length === 0) {
|
|
1003
|
+
searchResult.warning = 'Search returned 0 results. This may indicate: (1) no matching results exist, (2) the search engine blocked the request (CAPTCHA), or (3) the page layout changed and results could not be extracted.';
|
|
1004
|
+
// Grab page title for context
|
|
1005
|
+
try { searchResult.pageTitle = await page.title(); } catch {}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
return searchResult;
|
|
1009
|
+
|
|
1010
|
+
} finally {
|
|
1011
|
+
try { await page.close(); } catch {}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Fetch web content in various formats
|
|
1017
|
+
* @param {string} url - URL to fetch
|
|
1018
|
+
* @param {Object} options - Fetch options
|
|
1019
|
+
* @returns {Promise<Object>} Fetched content
|
|
1020
|
+
*/
|
|
1021
|
+
async fetch(url, options = {}) {
|
|
1022
|
+
const { formats = ['title', 'text'], agentId, stealthLevel = 'standard' } = options;
|
|
1023
|
+
|
|
1024
|
+
// Validate URL before doing anything expensive
|
|
1025
|
+
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
|
1026
|
+
return { success: false, error: 'URL is required for fetch operation' };
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
new URL(url);
|
|
1030
|
+
} catch {
|
|
1031
|
+
return { success: false, error: `Invalid URL format: "${url}". Must include protocol (e.g. https://example.com)` };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Ensure browser with specified stealth level
|
|
1035
|
+
await this.ensureBrowser({ stealthLevel });
|
|
1036
|
+
|
|
1037
|
+
this.logger?.info('Fetching web content', { url, formats, agentId, stealthLevel });
|
|
1038
|
+
|
|
1039
|
+
// Create temporary page
|
|
1040
|
+
const page = await this.browser.newPage();
|
|
1041
|
+
|
|
1042
|
+
try {
|
|
1043
|
+
// Listen for console messages if requested
|
|
1044
|
+
const consoleMessages = [];
|
|
1045
|
+
if (formats.includes('console')) {
|
|
1046
|
+
page.on('console', msg => {
|
|
1047
|
+
consoleMessages.push({
|
|
1048
|
+
type: msg.type(),
|
|
1049
|
+
text: msg.text()
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Navigate to URL and capture HTTP response
|
|
1055
|
+
const fetchResponse = await page.goto(url, { waitUntil: 'networkidle2', timeout: this.DEFAULT_TIMEOUT });
|
|
1056
|
+
const fetchStatus = fetchResponse ? fetchResponse.status() : null;
|
|
1057
|
+
|
|
1058
|
+
if (fetchStatus && fetchStatus >= 400) {
|
|
1059
|
+
const errorResult = {
|
|
1060
|
+
success: false,
|
|
1061
|
+
url,
|
|
1062
|
+
httpStatus: fetchStatus,
|
|
1063
|
+
error: `Fetch failed with HTTP ${fetchStatus} (${fetchStatus >= 500 ? 'server error' : fetchStatus === 404 ? 'page not found' : fetchStatus === 403 ? 'access forbidden' : 'client error'})`
|
|
1064
|
+
};
|
|
1065
|
+
// Still try to get title for context
|
|
1066
|
+
try { errorResult.title = await page.title(); } catch {}
|
|
1067
|
+
// Don't close page here — finally block handles it
|
|
1068
|
+
return errorResult;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const result = { url, httpStatus: fetchStatus };
|
|
1072
|
+
|
|
1073
|
+
// Extract requested formats
|
|
1074
|
+
for (const format of formats) {
|
|
1075
|
+
switch (format) {
|
|
1076
|
+
case 'title':
|
|
1077
|
+
result.title = await page.title();
|
|
1078
|
+
break;
|
|
1079
|
+
|
|
1080
|
+
case 'text':
|
|
1081
|
+
result.text = await page.evaluate(() => document.body.innerText);
|
|
1082
|
+
break;
|
|
1083
|
+
|
|
1084
|
+
case 'links':
|
|
1085
|
+
result.links = await page.evaluate(() => {
|
|
1086
|
+
return Array.from(document.querySelectorAll('a[href]')).map(a => ({
|
|
1087
|
+
href: a.href,
|
|
1088
|
+
text: a.textContent.trim()
|
|
1089
|
+
}));
|
|
1090
|
+
});
|
|
1091
|
+
break;
|
|
1092
|
+
|
|
1093
|
+
case 'html':
|
|
1094
|
+
result.html = await page.content();
|
|
1095
|
+
break;
|
|
1096
|
+
|
|
1097
|
+
case 'console':
|
|
1098
|
+
result.consoleMessages = consoleMessages;
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
this.logger?.info('Fetch completed', { url, formats, agentId });
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
success: true,
|
|
1107
|
+
...result
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
} finally {
|
|
1111
|
+
try { await page.close(); } catch {}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Interactive browser automation with command chaining
|
|
1117
|
+
* @param {Array} actions - Array of actions to execute
|
|
1118
|
+
* @param {Object} options - Options
|
|
1119
|
+
* @returns {Promise<Object>} Results of all actions
|
|
1120
|
+
*/
|
|
1121
|
+
async interactive(actions, options = {}) {
|
|
1122
|
+
const { stealthLevel = 'standard', agentId, context, humanMode = true } = options;
|
|
1123
|
+
|
|
1124
|
+
// Derive headless from stealthLevel (standard = headless, maximum = visible)
|
|
1125
|
+
const headless = stealthLevel === 'standard';
|
|
1126
|
+
|
|
1127
|
+
// Ensure browser with specified stealth level
|
|
1128
|
+
await this.ensureBrowser({ stealthLevel });
|
|
1129
|
+
|
|
1130
|
+
this.logger?.info('[WebTool] Starting interactive session', {
|
|
1131
|
+
actionsCount: actions.length,
|
|
1132
|
+
stealthLevel,
|
|
1133
|
+
humanMode,
|
|
1134
|
+
agentId
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const results = [];
|
|
1138
|
+
|
|
1139
|
+
// Initialize agent tabs if not exists
|
|
1140
|
+
if (!this.agentTabs.has(agentId)) {
|
|
1141
|
+
this.agentTabs.set(agentId, new Map());
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
1145
|
+
|
|
1146
|
+
for (const action of actions) {
|
|
1147
|
+
// Accept both "type" and "action" as the action discriminator for resilience
|
|
1148
|
+
const actionType = action.type || action.action;
|
|
1149
|
+
try {
|
|
1150
|
+
let actionResult;
|
|
1151
|
+
|
|
1152
|
+
switch (actionType) {
|
|
1153
|
+
case 'open-tab':
|
|
1154
|
+
actionResult = await this.openTab(
|
|
1155
|
+
agentId, action.name, action.url, headless,
|
|
1156
|
+
action.nestedActions || action.actions, context, { humanMode }
|
|
1157
|
+
);
|
|
1158
|
+
break;
|
|
1159
|
+
|
|
1160
|
+
case 'close-tab':
|
|
1161
|
+
actionResult = await this.closeTab(agentId, action.name);
|
|
1162
|
+
break;
|
|
1163
|
+
|
|
1164
|
+
case 'switch-tab':
|
|
1165
|
+
actionResult = await this.switchTab(agentId, action.name);
|
|
1166
|
+
break;
|
|
1167
|
+
|
|
1168
|
+
case 'list-tabs':
|
|
1169
|
+
actionResult = await this.listTabs(agentId);
|
|
1170
|
+
break;
|
|
1171
|
+
|
|
1172
|
+
default:
|
|
1173
|
+
// For actions that need a tab context, we need to specify which tab
|
|
1174
|
+
// For now, we'll skip these at the top level
|
|
1175
|
+
actionResult = {
|
|
1176
|
+
success: false,
|
|
1177
|
+
error: `Action ${actionType} must be executed within a tab context (use open-tab with nestedActions)`
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
results.push({
|
|
1182
|
+
action: actionType,
|
|
1183
|
+
...actionResult
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
this.logger?.error('Action failed', {
|
|
1188
|
+
action: actionType,
|
|
1189
|
+
error: error.message,
|
|
1190
|
+
agentId
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
results.push({
|
|
1194
|
+
action: actionType,
|
|
1195
|
+
success: false,
|
|
1196
|
+
error: error.message
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return {
|
|
1202
|
+
success: results.every(r => r.success !== false),
|
|
1203
|
+
actionsExecuted: results.length,
|
|
1204
|
+
results
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Authenticate to a website using stored credentials
|
|
1210
|
+
* The agent never sees the actual credentials - they are retrieved from the vault
|
|
1211
|
+
*
|
|
1212
|
+
* @param {string} siteId - Site identifier (e.g., 'linkedin', 'github')
|
|
1213
|
+
* @param {Object} options - Authentication options
|
|
1214
|
+
* @param {string} options.loginUrl - Custom login URL (optional)
|
|
1215
|
+
* @param {string} options.tabName - Tab name to reuse (optional)
|
|
1216
|
+
* @param {string} options.agentId - Agent identifier
|
|
1217
|
+
* @param {Object} options.context - Execution context
|
|
1218
|
+
* @returns {Promise<Object>} Authentication result (success/failure, no credentials exposed)
|
|
1219
|
+
*/
|
|
1220
|
+
async authenticate(siteId, options = {}) {
|
|
1221
|
+
const { loginUrl, tabName, agentId, context = {}, stealthLevel = 'maximum', customSelectors, keepTabOpen = false } = options;
|
|
1222
|
+
|
|
1223
|
+
// Validate siteId
|
|
1224
|
+
if (!siteId || typeof siteId !== 'string') {
|
|
1225
|
+
return {
|
|
1226
|
+
success: false,
|
|
1227
|
+
error: 'siteId is required and must be a string (e.g., "linkedin", "github")',
|
|
1228
|
+
requiresCredentials: false
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const normalizedSiteId = siteId.toLowerCase().trim();
|
|
1233
|
+
|
|
1234
|
+
this.logger?.info('[WebTool] Authentication requested', {
|
|
1235
|
+
siteId: normalizedSiteId,
|
|
1236
|
+
agentId,
|
|
1237
|
+
stealthLevel
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
// Get credential vault
|
|
1241
|
+
const vault = getCredentialVault(this.logger);
|
|
1242
|
+
await vault.initialize();
|
|
1243
|
+
|
|
1244
|
+
// Check if we have stored credentials
|
|
1245
|
+
let credentials = vault.getCredentials(normalizedSiteId);
|
|
1246
|
+
|
|
1247
|
+
// If no credentials stored, we need to request them from the user
|
|
1248
|
+
if (!credentials) {
|
|
1249
|
+
// Check if we have a webSocketManager to request credentials
|
|
1250
|
+
const wsManager = global.loxiaWebServer;
|
|
1251
|
+
|
|
1252
|
+
this.logger?.info('[WebTool] No stored credentials, checking for WebSocket manager', {
|
|
1253
|
+
hasWsManager: !!wsManager,
|
|
1254
|
+
sessionId: context?.sessionId,
|
|
1255
|
+
siteId: normalizedSiteId
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
if (!wsManager) {
|
|
1259
|
+
this.logger?.warn('[WebTool] No WebSocket manager available - cannot request credentials from UI');
|
|
1260
|
+
return {
|
|
1261
|
+
success: false,
|
|
1262
|
+
error: `No credentials stored for ${normalizedSiteId}. Please add credentials in Settings > Saved Logins.`,
|
|
1263
|
+
requiresCredentials: true,
|
|
1264
|
+
siteId: normalizedSiteId
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Create a credential request
|
|
1269
|
+
const { requestInfo, promise } = vault.createCredentialRequest(normalizedSiteId, {
|
|
1270
|
+
loginUrl,
|
|
1271
|
+
agentId
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
this.logger?.info('[WebTool] Broadcasting credential request to UI', {
|
|
1275
|
+
requestId: requestInfo.requestId,
|
|
1276
|
+
siteId: requestInfo.siteId,
|
|
1277
|
+
sessionId: context?.sessionId
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
// Block scheduling while waiting for user to enter credentials.
|
|
1281
|
+
// We set awaitingUserInput (checked by agentActivityService) instead of
|
|
1282
|
+
// changing status to PAUSED, so the agent stays visible to other agents.
|
|
1283
|
+
const agentPool = context?.agentPool;
|
|
1284
|
+
let agent = null;
|
|
1285
|
+
|
|
1286
|
+
if (agentPool && agentId) {
|
|
1287
|
+
try {
|
|
1288
|
+
agent = await agentPool.getAgent(agentId);
|
|
1289
|
+
if (agent) {
|
|
1290
|
+
agent.awaitingUserInput = {
|
|
1291
|
+
type: 'credentials',
|
|
1292
|
+
siteId: normalizedSiteId,
|
|
1293
|
+
requestId: requestInfo.requestId,
|
|
1294
|
+
startedAt: new Date().toISOString()
|
|
1295
|
+
};
|
|
1296
|
+
await agentPool.persistAgentState(agentId);
|
|
1297
|
+
this.logger?.info('[WebTool] Agent awaiting credentials (scheduling blocked)', {
|
|
1298
|
+
agentId,
|
|
1299
|
+
siteId: normalizedSiteId
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// Notify UI that agent is paused awaiting user input
|
|
1303
|
+
if (wsManager?.broadcastToSession) {
|
|
1304
|
+
wsManager.broadcastToSession(context.sessionId, {
|
|
1305
|
+
type: 'agent_awaiting_input',
|
|
1306
|
+
data: {
|
|
1307
|
+
agentId,
|
|
1308
|
+
inputType: 'credentials',
|
|
1309
|
+
siteId: normalizedSiteId,
|
|
1310
|
+
message: `Waiting for ${normalizedSiteId} credentials...`,
|
|
1311
|
+
timestamp: new Date().toISOString()
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
} catch (pauseError) {
|
|
1317
|
+
this.logger?.warn('[WebTool] Failed to pause agent (non-fatal):', pauseError.message);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Broadcast credential request to UI
|
|
1322
|
+
wsManager.broadcastCredentialRequest(requestInfo, context.sessionId);
|
|
1323
|
+
|
|
1324
|
+
// Wait for credentials to be submitted (or timeout/cancel)
|
|
1325
|
+
try {
|
|
1326
|
+
const result = await promise;
|
|
1327
|
+
credentials = {
|
|
1328
|
+
...vault.getCredentials(normalizedSiteId),
|
|
1329
|
+
...result.credentials
|
|
1330
|
+
};
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
// RESUME the agent even on error
|
|
1333
|
+
if (agent && agentPool) {
|
|
1334
|
+
try {
|
|
1335
|
+
delete agent.awaitingUserInput;
|
|
1336
|
+
await agentPool.persistAgentState(agentId);
|
|
1337
|
+
this.logger?.info('[WebTool] Agent resumed after credential error', { agentId });
|
|
1338
|
+
|
|
1339
|
+
// Notify UI that agent is no longer waiting
|
|
1340
|
+
if (wsManager?.broadcastToSession) {
|
|
1341
|
+
wsManager.broadcastToSession(context.sessionId, {
|
|
1342
|
+
type: 'agent_input_complete',
|
|
1343
|
+
data: {
|
|
1344
|
+
agentId,
|
|
1345
|
+
inputType: 'credentials',
|
|
1346
|
+
success: false,
|
|
1347
|
+
reason: error.message.includes('cancelled') ? 'cancelled' : 'timeout',
|
|
1348
|
+
timestamp: new Date().toISOString()
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
} catch (resumeError) {
|
|
1353
|
+
this.logger?.warn('[WebTool] Failed to resume agent (non-fatal):', resumeError.message);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
return {
|
|
1358
|
+
success: false,
|
|
1359
|
+
error: error.message,
|
|
1360
|
+
cancelled: error.message.includes('cancelled'),
|
|
1361
|
+
timedOut: error.message.includes('timed out'),
|
|
1362
|
+
siteId: normalizedSiteId
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// RESUME the agent after credentials received
|
|
1367
|
+
if (agent && agentPool) {
|
|
1368
|
+
try {
|
|
1369
|
+
delete agent.awaitingUserInput;
|
|
1370
|
+
await agentPool.persistAgentState(agentId);
|
|
1371
|
+
this.logger?.info('[WebTool] Agent resumed after credentials received', { agentId });
|
|
1372
|
+
|
|
1373
|
+
// Notify UI that agent is no longer waiting
|
|
1374
|
+
if (wsManager?.broadcastToSession) {
|
|
1375
|
+
wsManager.broadcastToSession(context.sessionId, {
|
|
1376
|
+
type: 'agent_input_complete',
|
|
1377
|
+
data: {
|
|
1378
|
+
agentId,
|
|
1379
|
+
inputType: 'credentials',
|
|
1380
|
+
success: true,
|
|
1381
|
+
timestamp: new Date().toISOString()
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
} catch (resumeError) {
|
|
1386
|
+
this.logger?.warn('[WebTool] Failed to resume agent (non-fatal):', resumeError.message);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Get site configuration
|
|
1392
|
+
const knownSite = KNOWN_SITES[normalizedSiteId] || {};
|
|
1393
|
+
const actualLoginUrl = loginUrl || credentials.loginUrl || knownSite.loginUrl;
|
|
1394
|
+
|
|
1395
|
+
if (!actualLoginUrl) {
|
|
1396
|
+
return {
|
|
1397
|
+
success: false,
|
|
1398
|
+
error: `No login URL configured for ${normalizedSiteId}. Provide loginUrl parameter.`,
|
|
1399
|
+
siteId: normalizedSiteId
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Resolve selectors - priority: customSelectors > credentials.selectors > knownSite.selectors
|
|
1404
|
+
const selectors = customSelectors && (customSelectors.username || customSelectors.password)
|
|
1405
|
+
? {
|
|
1406
|
+
username: customSelectors.username,
|
|
1407
|
+
password: customSelectors.password,
|
|
1408
|
+
submit: customSelectors.submit || 'button[type="submit"], input[type="submit"]',
|
|
1409
|
+
loginSuccess: knownSite.selectors?.loginSuccess,
|
|
1410
|
+
loginError: knownSite.selectors?.loginError
|
|
1411
|
+
}
|
|
1412
|
+
: (credentials.selectors || knownSite.selectors);
|
|
1413
|
+
|
|
1414
|
+
if (!selectors || !selectors.username || !selectors.password) {
|
|
1415
|
+
return {
|
|
1416
|
+
success: false,
|
|
1417
|
+
error: `No login form selectors for ${normalizedSiteId}. Provide usernameSelector and passwordSelector parameters, or use a supported site (linkedin, github, google, twitter).`,
|
|
1418
|
+
siteId: normalizedSiteId
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
this.logger?.info('[WebTool] Using selectors for authentication', {
|
|
1423
|
+
siteId: normalizedSiteId,
|
|
1424
|
+
usernameSelector: selectors.username,
|
|
1425
|
+
passwordSelector: selectors.password,
|
|
1426
|
+
submitSelector: selectors.submit,
|
|
1427
|
+
isCustom: !!customSelectors
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
// Check for existing session cookies
|
|
1431
|
+
const existingSession = vault.getSession(normalizedSiteId);
|
|
1432
|
+
|
|
1433
|
+
// Create or reuse a tab for authentication with specified stealth level
|
|
1434
|
+
// Default to 'maximum' (visible browser) for login pages to avoid detection
|
|
1435
|
+
await this.ensureBrowser({ stealthLevel });
|
|
1436
|
+
const { page, cursor } = await this.createPage({ humanMode: true });
|
|
1437
|
+
|
|
1438
|
+
try {
|
|
1439
|
+
// If we have existing session cookies, try them first
|
|
1440
|
+
if (existingSession && existingSession.cookies) {
|
|
1441
|
+
this.logger?.info('[WebTool] Attempting session restore', { siteId: normalizedSiteId });
|
|
1442
|
+
|
|
1443
|
+
await page.setCookie(...existingSession.cookies);
|
|
1444
|
+
await page.goto(actualLoginUrl, {
|
|
1445
|
+
waitUntil: BROWSER_CONFIG.WAIT_UNTIL,
|
|
1446
|
+
timeout: this.DEFAULT_TIMEOUT
|
|
1447
|
+
});
|
|
1448
|
+
await humanWait('navigation');
|
|
1449
|
+
|
|
1450
|
+
// Check if we're already logged in
|
|
1451
|
+
if (selectors.loginSuccess) {
|
|
1452
|
+
try {
|
|
1453
|
+
await page.waitForSelector(selectors.loginSuccess, { timeout: 5000 });
|
|
1454
|
+
this.logger?.info('[WebTool] Session restore successful', { siteId: normalizedSiteId });
|
|
1455
|
+
|
|
1456
|
+
await page.close();
|
|
1457
|
+
return {
|
|
1458
|
+
success: true,
|
|
1459
|
+
message: `Already logged into ${credentials.name || normalizedSiteId} (session restored)`,
|
|
1460
|
+
siteId: normalizedSiteId,
|
|
1461
|
+
method: 'session_restore'
|
|
1462
|
+
};
|
|
1463
|
+
} catch {
|
|
1464
|
+
// Session invalid, need to login
|
|
1465
|
+
this.logger?.info('[WebTool] Session expired, performing fresh login', { siteId: normalizedSiteId });
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
} else {
|
|
1469
|
+
// Navigate to login page
|
|
1470
|
+
await page.goto(actualLoginUrl, {
|
|
1471
|
+
waitUntil: BROWSER_CONFIG.WAIT_UNTIL,
|
|
1472
|
+
timeout: this.DEFAULT_TIMEOUT
|
|
1473
|
+
});
|
|
1474
|
+
await humanWait('navigation');
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Wait for login form
|
|
1478
|
+
await page.waitForSelector(selectors.username, { timeout: this.DEFAULT_TIMEOUT });
|
|
1479
|
+
|
|
1480
|
+
// Fill in credentials with human-like typing
|
|
1481
|
+
await humanType(page, selectors.username, credentials.username, {
|
|
1482
|
+
clearFirst: true,
|
|
1483
|
+
simulateTypos: false
|
|
1484
|
+
});
|
|
1485
|
+
await humanWait('action');
|
|
1486
|
+
|
|
1487
|
+
// Handle multi-step login (e.g., Google)
|
|
1488
|
+
if (knownSite.multiStep && selectors.submitEmail) {
|
|
1489
|
+
await humanSubmit(page, selectors.submitEmail, { cursor, waitForNavigation: true });
|
|
1490
|
+
await humanWait('navigation');
|
|
1491
|
+
await page.waitForSelector(selectors.password, { timeout: this.DEFAULT_TIMEOUT });
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// Enter password
|
|
1495
|
+
await humanType(page, selectors.password, credentials.password, {
|
|
1496
|
+
clearFirst: true,
|
|
1497
|
+
simulateTypos: false
|
|
1498
|
+
});
|
|
1499
|
+
await humanWait('action');
|
|
1500
|
+
|
|
1501
|
+
// Submit the form
|
|
1502
|
+
const submitSelector = selectors.submitPassword || selectors.submit;
|
|
1503
|
+
await humanSubmit(page, submitSelector, { cursor, waitForNavigation: true });
|
|
1504
|
+
await humanWait('afterSubmit');
|
|
1505
|
+
|
|
1506
|
+
// Check for success or error
|
|
1507
|
+
let loginSuccess = false;
|
|
1508
|
+
let loginError = null;
|
|
1509
|
+
|
|
1510
|
+
// Wait for either success or error indicator
|
|
1511
|
+
try {
|
|
1512
|
+
if (selectors.loginSuccess) {
|
|
1513
|
+
await page.waitForSelector(selectors.loginSuccess, { timeout: 15000 });
|
|
1514
|
+
loginSuccess = true;
|
|
1515
|
+
} else {
|
|
1516
|
+
// No explicit success selector — check if URL changed (common indicator of successful login)
|
|
1517
|
+
await humanWait('navigation');
|
|
1518
|
+
const postLoginUrl = page.url();
|
|
1519
|
+
const urlChanged = postLoginUrl !== actualLoginUrl;
|
|
1520
|
+
|
|
1521
|
+
// Also check if error indicator appeared
|
|
1522
|
+
let errorVisible = false;
|
|
1523
|
+
if (selectors.loginError) {
|
|
1524
|
+
try {
|
|
1525
|
+
const errorEl = await page.$(selectors.loginError);
|
|
1526
|
+
if (errorEl) {
|
|
1527
|
+
errorVisible = true;
|
|
1528
|
+
loginError = await page.evaluate(el => el.textContent?.trim(), errorEl);
|
|
1529
|
+
}
|
|
1530
|
+
} catch {}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
if (errorVisible) {
|
|
1534
|
+
loginSuccess = false;
|
|
1535
|
+
} else if (urlChanged) {
|
|
1536
|
+
loginSuccess = true;
|
|
1537
|
+
} else {
|
|
1538
|
+
// URL didn't change and no error selector found — ambiguous
|
|
1539
|
+
loginSuccess = false;
|
|
1540
|
+
loginError = 'Login result is ambiguous: no success indicator found and URL did not change after submit. The login may have failed silently. Provide a loginSuccess selector for reliable detection.';
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
} catch (waitErr) {
|
|
1544
|
+
// Success selector wait timed out — login likely failed
|
|
1545
|
+
loginSuccess = false;
|
|
1546
|
+
loginError = `Login success indicator "${selectors.loginSuccess}" not found within 15s.`;
|
|
1547
|
+
|
|
1548
|
+
// Also check for explicit error message
|
|
1549
|
+
if (selectors.loginError) {
|
|
1550
|
+
try {
|
|
1551
|
+
const errorElement = await page.$(selectors.loginError);
|
|
1552
|
+
if (errorElement) {
|
|
1553
|
+
const extractedError = await page.evaluate(el => el.textContent?.trim(), errorElement);
|
|
1554
|
+
if (extractedError) {
|
|
1555
|
+
loginError = `Login failed: ${extractedError}`;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
} catch (errExtract) {
|
|
1559
|
+
this.logger?.warn('[WebTool] Error extraction failed', { error: errExtract.message });
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (loginSuccess) {
|
|
1565
|
+
// Save session cookies for future use
|
|
1566
|
+
const cookies = await page.cookies();
|
|
1567
|
+
await vault.saveSession(normalizedSiteId, cookies);
|
|
1568
|
+
|
|
1569
|
+
this.logger?.info('[WebTool] Login successful', { siteId: normalizedSiteId });
|
|
1570
|
+
|
|
1571
|
+
// Optionally keep the tab open for continued browsing
|
|
1572
|
+
if (keepTabOpen && tabName && agentId) {
|
|
1573
|
+
// Initialize agent tabs if needed
|
|
1574
|
+
if (!this.agentTabs.has(agentId)) {
|
|
1575
|
+
this.agentTabs.set(agentId, new Map());
|
|
1576
|
+
}
|
|
1577
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
1578
|
+
|
|
1579
|
+
// Store the authenticated page as a named tab
|
|
1580
|
+
agentTabsMap.set(tabName, {
|
|
1581
|
+
page,
|
|
1582
|
+
url: actualLoginUrl,
|
|
1583
|
+
lastActivity: Date.now(),
|
|
1584
|
+
headless: stealthLevel === 'standard',
|
|
1585
|
+
consoleMessages: [],
|
|
1586
|
+
name: tabName,
|
|
1587
|
+
humanMode: true,
|
|
1588
|
+
authenticated: true,
|
|
1589
|
+
siteId: normalizedSiteId
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
this.logger?.info('[WebTool] Tab kept open after authentication', {
|
|
1593
|
+
tabName,
|
|
1594
|
+
siteId: normalizedSiteId
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
return {
|
|
1598
|
+
success: true,
|
|
1599
|
+
message: `Successfully logged into ${credentials.name || normalizedSiteId}. Tab '${tabName}' is ready for continued browsing.`,
|
|
1600
|
+
siteId: normalizedSiteId,
|
|
1601
|
+
method: 'credentials',
|
|
1602
|
+
tabName,
|
|
1603
|
+
tabKeptOpen: true
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
await page.close();
|
|
1608
|
+
return {
|
|
1609
|
+
success: true,
|
|
1610
|
+
message: `Successfully logged into ${credentials.name || normalizedSiteId}`,
|
|
1611
|
+
siteId: normalizedSiteId,
|
|
1612
|
+
method: 'credentials'
|
|
1613
|
+
};
|
|
1614
|
+
} else {
|
|
1615
|
+
await page.close();
|
|
1616
|
+
return {
|
|
1617
|
+
success: false,
|
|
1618
|
+
error: loginError || `Login failed for ${normalizedSiteId}`,
|
|
1619
|
+
siteId: normalizedSiteId
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
await page.close();
|
|
1625
|
+
this.logger?.error('[WebTool] Authentication failed', {
|
|
1626
|
+
siteId: normalizedSiteId,
|
|
1627
|
+
error: error.message
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
return {
|
|
1631
|
+
success: false,
|
|
1632
|
+
error: `Authentication failed: ${error.message}`,
|
|
1633
|
+
siteId: normalizedSiteId
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Open a new tab with nested actions
|
|
1640
|
+
* @param {string} agentId - Agent identifier
|
|
1641
|
+
* @param {string} tabName - Unique tab name
|
|
1642
|
+
* @param {string} url - Initial URL
|
|
1643
|
+
* @param {boolean} headless - Headless mode
|
|
1644
|
+
* @param {Array} nestedActions - Actions to execute in this tab
|
|
1645
|
+
* @param {Object} context - Execution context
|
|
1646
|
+
* @param {Object} options - Additional options
|
|
1647
|
+
* @param {boolean} options.humanMode - Enable human-like behavior
|
|
1648
|
+
* @returns {Promise<Object>} Result
|
|
1649
|
+
*/
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Deduplicate an array of error strings, returning "error x N" for repeats.
|
|
1653
|
+
* Keeps output compact for the agent.
|
|
1654
|
+
* @param {string[]} errors - Array of error message strings
|
|
1655
|
+
* @returns {string[]} Deduplicated array with counts
|
|
1656
|
+
* @private
|
|
1657
|
+
*/
|
|
1658
|
+
static _dedupeErrors(errors) {
|
|
1659
|
+
if (!errors || errors.length === 0) return [];
|
|
1660
|
+
const counts = new Map();
|
|
1661
|
+
for (const e of errors) {
|
|
1662
|
+
counts.set(e, (counts.get(e) || 0) + 1);
|
|
1663
|
+
}
|
|
1664
|
+
return Array.from(counts.entries()).map(([msg, count]) =>
|
|
1665
|
+
count > 1 ? `${msg} (x${count})` : msg
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
async openTab(agentId, tabName, url, headless, nestedActions = [], context = {}, options = {}) {
|
|
1670
|
+
const { humanMode = true } = options; // Default to human mode
|
|
1671
|
+
|
|
1672
|
+
// Initialize agent tabs if not exists
|
|
1673
|
+
if (!this.agentTabs.has(agentId)) {
|
|
1674
|
+
this.agentTabs.set(agentId, new Map());
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
1678
|
+
|
|
1679
|
+
// If tab already exists, reuse it — execute nested actions on the existing page
|
|
1680
|
+
if (agentTabsMap.has(tabName)) {
|
|
1681
|
+
const existingTab = agentTabsMap.get(tabName);
|
|
1682
|
+
existingTab.lastActivity = Date.now();
|
|
1683
|
+
const results = [];
|
|
1684
|
+
|
|
1685
|
+
if (url) {
|
|
1686
|
+
const reuseNavResponse = await existingTab.page.goto(url, {
|
|
1687
|
+
waitUntil: BROWSER_CONFIG.WAIT_UNTIL,
|
|
1688
|
+
timeout: BROWSER_CONFIG.DEFAULT_TIMEOUT_MS
|
|
1689
|
+
});
|
|
1690
|
+
const reuseNavStatus = reuseNavResponse ? reuseNavResponse.status() : null;
|
|
1691
|
+
if (humanMode) await humanWait('navigation');
|
|
1692
|
+
if (reuseNavStatus && reuseNavStatus >= 400) {
|
|
1693
|
+
results.push({
|
|
1694
|
+
action: 'navigate',
|
|
1695
|
+
success: false,
|
|
1696
|
+
url: existingTab.page.url(),
|
|
1697
|
+
httpStatus: reuseNavStatus,
|
|
1698
|
+
error: `Navigation got HTTP ${reuseNavStatus}`
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
for (const nestedAction of (nestedActions || [])) {
|
|
1704
|
+
// Snapshot error counts before action
|
|
1705
|
+
const prePageErrors = (existingTab.pageErrors || []).length;
|
|
1706
|
+
const preNetworkFailures = (existingTab.networkFailures || []).length;
|
|
1707
|
+
const preHttpErrors = (existingTab.httpErrors || []).length;
|
|
1708
|
+
|
|
1709
|
+
const actionResult = await this.executeTabAction(existingTab.page, nestedAction, existingTab, context);
|
|
1710
|
+
|
|
1711
|
+
const actionEntry = { action: nestedAction.type || nestedAction.action, ...actionResult };
|
|
1712
|
+
|
|
1713
|
+
// Attach errors that occurred during this action
|
|
1714
|
+
const newPageErrors = (existingTab.pageErrors || []).slice(prePageErrors);
|
|
1715
|
+
const newNetworkFails = (existingTab.networkFailures || []).slice(preNetworkFailures);
|
|
1716
|
+
const newHttpErrs = (existingTab.httpErrors || []).slice(preHttpErrors);
|
|
1717
|
+
const dJs = WebTool._dedupeErrors(newPageErrors.map(e => e.message));
|
|
1718
|
+
const dNet = WebTool._dedupeErrors(newNetworkFails.map(f => `${f.method} ${f.url} → ${f.errorText}`));
|
|
1719
|
+
const dHttp = WebTool._dedupeErrors(newHttpErrs.map(e => `${e.method} ${e.url} → ${e.status}`));
|
|
1720
|
+
if (dJs.length > 0) actionEntry.jsErrors = dJs;
|
|
1721
|
+
if (dNet.length > 0) actionEntry.networkFailures = dNet;
|
|
1722
|
+
if (dHttp.length > 0) actionEntry.httpErrors = dHttp;
|
|
1723
|
+
|
|
1724
|
+
results.push(actionEntry);
|
|
1725
|
+
existingTab.lastActivity = Date.now();
|
|
1726
|
+
if (humanMode) await humanWait('action');
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const anyFailed = results.some(r => r.success === false);
|
|
1730
|
+
return {
|
|
1731
|
+
success: !anyFailed,
|
|
1732
|
+
tabName,
|
|
1733
|
+
url: existingTab.page.url(),
|
|
1734
|
+
actionsExecuted: results.length,
|
|
1735
|
+
results,
|
|
1736
|
+
reused: true,
|
|
1737
|
+
...(anyFailed && { warning: `${results.filter(r => r.success === false).length} of ${results.length} action(s) failed` }),
|
|
1738
|
+
...((existingTab.pageErrors || []).length > 0 && { jsErrors: WebTool._dedupeErrors(existingTab.pageErrors.map(e => e.message)) }),
|
|
1739
|
+
...((existingTab.networkFailures || []).length > 0 && { networkFailures: WebTool._dedupeErrors(existingTab.networkFailures.map(f => `${f.method} ${f.url} → ${f.errorText}`)) }),
|
|
1740
|
+
...((existingTab.httpErrors || []).length > 0 && { httpErrors: WebTool._dedupeErrors(existingTab.httpErrors.map(e => `${e.method} ${e.url} → ${e.status}`)) })
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
this.logger?.info('[WebTool] Opening stealth tab', { agentId, tabName, url, headless, humanMode });
|
|
1745
|
+
|
|
1746
|
+
// Create stealth page with optional human cursor
|
|
1747
|
+
const { page, cursor } = await this.createPage({ humanMode });
|
|
1748
|
+
|
|
1749
|
+
// RESTORE SESSION COOKIES if available for this domain
|
|
1750
|
+
// This enables authenticated browsing after using the authenticate operation
|
|
1751
|
+
if (url) {
|
|
1752
|
+
try {
|
|
1753
|
+
const vault = getCredentialVault(this.logger);
|
|
1754
|
+
await vault.initialize();
|
|
1755
|
+
|
|
1756
|
+
// Extract domain from URL to find matching sessions
|
|
1757
|
+
const urlObj = new URL(url);
|
|
1758
|
+
const domain = urlObj.hostname.replace(/^www\./, '');
|
|
1759
|
+
|
|
1760
|
+
// Check all stored sessions for matching domain
|
|
1761
|
+
const allSessions = vault.getAllSessions ? vault.getAllSessions() : {};
|
|
1762
|
+
for (const [siteId, session] of Object.entries(allSessions)) {
|
|
1763
|
+
if (session.cookies && session.cookies.length > 0) {
|
|
1764
|
+
// Check if any cookie domain matches our target URL
|
|
1765
|
+
const hasMatchingCookies = session.cookies.some(cookie => {
|
|
1766
|
+
const cookieDomain = (cookie.domain || '').replace(/^\./, '');
|
|
1767
|
+
return domain.includes(cookieDomain) || cookieDomain.includes(domain);
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
if (hasMatchingCookies) {
|
|
1771
|
+
this.logger?.info('[WebTool] Restoring session cookies for domain', {
|
|
1772
|
+
siteId,
|
|
1773
|
+
domain,
|
|
1774
|
+
cookieCount: session.cookies.length
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
// Filter cookies that match this domain
|
|
1778
|
+
const relevantCookies = session.cookies.filter(cookie => {
|
|
1779
|
+
const cookieDomain = (cookie.domain || '').replace(/^\./, '');
|
|
1780
|
+
return domain.includes(cookieDomain) || cookieDomain.includes(domain);
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
if (relevantCookies.length > 0) {
|
|
1784
|
+
await page.setCookie(...relevantCookies);
|
|
1785
|
+
this.logger?.debug('[WebTool] Session cookies restored', {
|
|
1786
|
+
domain,
|
|
1787
|
+
restoredCount: relevantCookies.length
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
break; // Only restore from first matching session
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
} catch (cookieError) {
|
|
1795
|
+
this.logger?.warn('[WebTool] Failed to restore session cookies (non-fatal)', {
|
|
1796
|
+
error: cookieError.message
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// Track console messages
|
|
1802
|
+
const consoleMessages = [];
|
|
1803
|
+
page.on('console', msg => {
|
|
1804
|
+
consoleMessages.push({
|
|
1805
|
+
type: msg.type(),
|
|
1806
|
+
text: msg.text(),
|
|
1807
|
+
timestamp: Date.now()
|
|
1808
|
+
});
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
// Track JS errors (uncaught exceptions in the page)
|
|
1812
|
+
const pageErrors = [];
|
|
1813
|
+
page.on('pageerror', err => {
|
|
1814
|
+
pageErrors.push({
|
|
1815
|
+
message: err.message || String(err),
|
|
1816
|
+
timestamp: Date.now()
|
|
1817
|
+
});
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
// Track failed network requests (DNS, CORS, timeouts, connection refused, etc.)
|
|
1821
|
+
const networkFailures = [];
|
|
1822
|
+
page.on('requestfailed', req => {
|
|
1823
|
+
const failure = req.failure();
|
|
1824
|
+
networkFailures.push({
|
|
1825
|
+
url: req.url(),
|
|
1826
|
+
method: req.method(),
|
|
1827
|
+
resourceType: req.resourceType(),
|
|
1828
|
+
errorText: failure ? failure.errorText : 'unknown',
|
|
1829
|
+
timestamp: Date.now()
|
|
1830
|
+
});
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
// Track failed HTTP responses on XHR/fetch (API errors that don't throw but return 4xx/5xx)
|
|
1834
|
+
const httpErrors = [];
|
|
1835
|
+
page.on('response', res => {
|
|
1836
|
+
const status = res.status();
|
|
1837
|
+
const resourceType = res.request().resourceType();
|
|
1838
|
+
// Only track XHR/fetch failures, not static assets like images/css (too noisy)
|
|
1839
|
+
if (status >= 400 && (resourceType === 'xhr' || resourceType === 'fetch')) {
|
|
1840
|
+
httpErrors.push({
|
|
1841
|
+
url: res.url(),
|
|
1842
|
+
status,
|
|
1843
|
+
method: res.request().method(),
|
|
1844
|
+
timestamp: Date.now()
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
// Store tab info with cursor for human-like actions
|
|
1850
|
+
const tabInfo = {
|
|
1851
|
+
page,
|
|
1852
|
+
url,
|
|
1853
|
+
lastActivity: Date.now(),
|
|
1854
|
+
headless,
|
|
1855
|
+
consoleMessages,
|
|
1856
|
+
pageErrors,
|
|
1857
|
+
networkFailures,
|
|
1858
|
+
httpErrors,
|
|
1859
|
+
name: tabName,
|
|
1860
|
+
humanMode,
|
|
1861
|
+
cursor // Store cursor for reuse in actions
|
|
1862
|
+
};
|
|
1863
|
+
|
|
1864
|
+
agentTabsMap.set(tabName, tabInfo);
|
|
1865
|
+
|
|
1866
|
+
const results = [];
|
|
1867
|
+
|
|
1868
|
+
try {
|
|
1869
|
+
// Navigate to initial URL if provided
|
|
1870
|
+
if (url) {
|
|
1871
|
+
const openTabResponse = await page.goto(url, {
|
|
1872
|
+
waitUntil: BROWSER_CONFIG.WAIT_UNTIL,
|
|
1873
|
+
timeout: this.DEFAULT_TIMEOUT
|
|
1874
|
+
});
|
|
1875
|
+
tabInfo.url = page.url();
|
|
1876
|
+
tabInfo.lastActivity = Date.now();
|
|
1877
|
+
const openTabStatus = openTabResponse ? openTabResponse.status() : null;
|
|
1878
|
+
tabInfo.httpStatus = openTabStatus;
|
|
1879
|
+
|
|
1880
|
+
// Human-like wait after navigation
|
|
1881
|
+
if (humanMode) {
|
|
1882
|
+
await humanWait('navigation');
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// Warn on HTTP errors but don't fail — tab is still usable
|
|
1886
|
+
if (openTabStatus && openTabStatus >= 400) {
|
|
1887
|
+
results.push({
|
|
1888
|
+
action: 'navigate',
|
|
1889
|
+
success: false,
|
|
1890
|
+
url: tabInfo.url,
|
|
1891
|
+
httpStatus: openTabStatus,
|
|
1892
|
+
error: `Initial navigation got HTTP ${openTabStatus}`
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Execute nested actions — drain page/network errors after each action
|
|
1898
|
+
for (const action of nestedActions) {
|
|
1899
|
+
// Snapshot error counts before action
|
|
1900
|
+
const prePageErrors = pageErrors.length;
|
|
1901
|
+
const preNetworkFailures = networkFailures.length;
|
|
1902
|
+
const preHttpErrors = httpErrors.length;
|
|
1903
|
+
const preConsoleErrors = consoleMessages.filter(m => m.type === 'error').length;
|
|
1904
|
+
|
|
1905
|
+
const actionResult = await this.executeTabAction(page, action, tabInfo, context);
|
|
1906
|
+
|
|
1907
|
+
// Collect errors that occurred during this action
|
|
1908
|
+
const newPageErrors = pageErrors.slice(prePageErrors);
|
|
1909
|
+
const newNetworkFailures = networkFailures.slice(preNetworkFailures);
|
|
1910
|
+
const newHttpErrors = httpErrors.slice(preHttpErrors);
|
|
1911
|
+
const newConsoleErrors = consoleMessages.filter(m => m.type === 'error').slice(preConsoleErrors);
|
|
1912
|
+
|
|
1913
|
+
const actionEntry = {
|
|
1914
|
+
action: action.type || action.action,
|
|
1915
|
+
...actionResult
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
// Attach detected issues to the action result (deduplicated)
|
|
1919
|
+
const dedupedJs = WebTool._dedupeErrors(newPageErrors.map(e => e.message));
|
|
1920
|
+
const dedupedNet = WebTool._dedupeErrors(newNetworkFailures.map(f => `${f.method} ${f.url} → ${f.errorText}`));
|
|
1921
|
+
const dedupedHttp = WebTool._dedupeErrors(newHttpErrors.map(e => `${e.method} ${e.url} → ${e.status}`));
|
|
1922
|
+
const dedupedConsole = WebTool._dedupeErrors(newConsoleErrors.map(e => e.text));
|
|
1923
|
+
if (dedupedJs.length > 0) actionEntry.jsErrors = dedupedJs;
|
|
1924
|
+
if (dedupedNet.length > 0) actionEntry.networkFailures = dedupedNet;
|
|
1925
|
+
if (dedupedHttp.length > 0) actionEntry.httpErrors = dedupedHttp;
|
|
1926
|
+
if (dedupedConsole.length > 0) actionEntry.consoleErrors = dedupedConsole;
|
|
1927
|
+
|
|
1928
|
+
results.push(actionEntry);
|
|
1929
|
+
tabInfo.lastActivity = Date.now();
|
|
1930
|
+
|
|
1931
|
+
// Human-like delay between actions
|
|
1932
|
+
if (humanMode) {
|
|
1933
|
+
await humanWait('action');
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const anyFailed = results.some(r => r.success === false);
|
|
1938
|
+
// Summarize all page issues for the agent (deduplicated)
|
|
1939
|
+
const allJsErrors = WebTool._dedupeErrors(pageErrors.map(e => e.message));
|
|
1940
|
+
const allNetworkFails = WebTool._dedupeErrors(networkFailures.map(f => `${f.method} ${f.url} → ${f.errorText}`));
|
|
1941
|
+
const allHttpErrs = WebTool._dedupeErrors(httpErrors.map(e => `${e.method} ${e.url} → ${e.status}`));
|
|
1942
|
+
|
|
1943
|
+
return {
|
|
1944
|
+
success: !anyFailed,
|
|
1945
|
+
tabName,
|
|
1946
|
+
url: tabInfo.url,
|
|
1947
|
+
actionsExecuted: results.length,
|
|
1948
|
+
results,
|
|
1949
|
+
...(anyFailed && { warning: `${results.filter(r => r.success === false).length} of ${results.length} action(s) failed` }),
|
|
1950
|
+
// Surface all detected issues at the top level for agent awareness
|
|
1951
|
+
...(allJsErrors.length > 0 && { jsErrors: allJsErrors }),
|
|
1952
|
+
...(allNetworkFails.length > 0 && { networkFailures: allNetworkFails }),
|
|
1953
|
+
...(allHttpErrs.length > 0 && { httpErrors: allHttpErrs })
|
|
1954
|
+
};
|
|
1955
|
+
|
|
1956
|
+
} catch (error) {
|
|
1957
|
+
this.logger?.error('Failed to open tab', {
|
|
1958
|
+
agentId,
|
|
1959
|
+
tabName,
|
|
1960
|
+
error: error.message
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
// Check if the page is still usable before deciding to clean up
|
|
1964
|
+
let pageStillAlive = false;
|
|
1965
|
+
try {
|
|
1966
|
+
await page.evaluate(() => document.readyState);
|
|
1967
|
+
pageStillAlive = true;
|
|
1968
|
+
} catch { /* page is dead */ }
|
|
1969
|
+
|
|
1970
|
+
if (pageStillAlive) {
|
|
1971
|
+
// Keep the tab alive so subsequent operations can reuse it
|
|
1972
|
+
// (the error was likely in a nested action, not in navigation itself)
|
|
1973
|
+
this.logger?.warn('[WebTool] Tab kept alive despite error — page is still usable', { tabName });
|
|
1974
|
+
return {
|
|
1975
|
+
success: false,
|
|
1976
|
+
tabName,
|
|
1977
|
+
url: tabInfo.url || page.url(),
|
|
1978
|
+
actionsExecuted: results.length,
|
|
1979
|
+
results,
|
|
1980
|
+
error: error.message,
|
|
1981
|
+
warning: 'Tab is still open and reusable despite the error above.'
|
|
1982
|
+
};
|
|
1983
|
+
} else {
|
|
1984
|
+
// Page is genuinely dead — clean up
|
|
1985
|
+
try { await page.close(); } catch { /* ignore close errors */ }
|
|
1986
|
+
agentTabsMap.delete(tabName);
|
|
1987
|
+
throw error;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* Execute an action in a tab context
|
|
1994
|
+
* @param {Page} page - Puppeteer page
|
|
1995
|
+
* @param {Object} action - Action to execute
|
|
1996
|
+
* @param {Object} tabInfo - Tab information (includes humanMode and cursor)
|
|
1997
|
+
* @param {Object} context - Execution context
|
|
1998
|
+
* @returns {Promise<Object>} Action result
|
|
1999
|
+
* @private
|
|
2000
|
+
*/
|
|
2001
|
+
async executeTabAction(page, action, tabInfo, context) {
|
|
2002
|
+
const { humanMode = false, cursor = null } = tabInfo;
|
|
2003
|
+
// Accept both "type" and "action" as the action discriminator
|
|
2004
|
+
if (!action.type && action.action) action.type = action.action;
|
|
2005
|
+
|
|
2006
|
+
switch (action.type) {
|
|
2007
|
+
case 'navigate': {
|
|
2008
|
+
const navUrl = action.value || action.url;
|
|
2009
|
+
// Validate URL format
|
|
2010
|
+
if (!navUrl || typeof navUrl !== 'string') {
|
|
2011
|
+
return { success: false, error: 'URL is required for navigate action' };
|
|
2012
|
+
}
|
|
2013
|
+
try {
|
|
2014
|
+
new URL(navUrl);
|
|
2015
|
+
} catch {
|
|
2016
|
+
return { success: false, error: `Invalid URL format: "${navUrl}". Must include protocol (https://)` };
|
|
2017
|
+
}
|
|
2018
|
+
const navResponse = await page.goto(navUrl, {
|
|
2019
|
+
waitUntil: BROWSER_CONFIG.WAIT_UNTIL,
|
|
2020
|
+
timeout: this.DEFAULT_TIMEOUT
|
|
2021
|
+
});
|
|
2022
|
+
tabInfo.url = page.url();
|
|
2023
|
+
// Check HTTP status
|
|
2024
|
+
const navStatus = navResponse ? navResponse.status() : null;
|
|
2025
|
+
if (humanMode) {
|
|
2026
|
+
await humanWait('navigation');
|
|
2027
|
+
}
|
|
2028
|
+
if (navStatus && navStatus >= 400) {
|
|
2029
|
+
return {
|
|
2030
|
+
success: false,
|
|
2031
|
+
url: tabInfo.url,
|
|
2032
|
+
httpStatus: navStatus,
|
|
2033
|
+
error: `Navigation failed with HTTP ${navStatus} (${navStatus >= 500 ? 'server error' : navStatus === 404 ? 'page not found' : navStatus === 403 ? 'access forbidden' : 'client error'})`
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
return { success: true, url: tabInfo.url, httpStatus: navStatus };
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
case 'click': {
|
|
2040
|
+
if (!action.selector) {
|
|
2041
|
+
return { success: false, error: 'selector is required for click action' };
|
|
2042
|
+
}
|
|
2043
|
+
// Pre-check selector exists
|
|
2044
|
+
const clickTarget = await page.$(action.selector);
|
|
2045
|
+
if (!clickTarget) {
|
|
2046
|
+
const pageUrl = page.url();
|
|
2047
|
+
return {
|
|
2048
|
+
success: false,
|
|
2049
|
+
error: `Element not found: "${action.selector}" on ${pageUrl}`,
|
|
2050
|
+
selector: action.selector,
|
|
2051
|
+
suggestion: 'The element may not exist on this page, may have a different selector, or may not have loaded yet. Try wait-for first, or use extract-text/extract-links to inspect available elements.'
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
// Scroll element into view before interacting (prevents offscreen failures)
|
|
2055
|
+
await page.evaluate((sel) => {
|
|
2056
|
+
const el = document.querySelector(sel);
|
|
2057
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2058
|
+
}, action.selector);
|
|
2059
|
+
await new Promise(r => setTimeout(r, 300)); // Brief settle after scroll
|
|
2060
|
+
if (humanMode && cursor) {
|
|
2061
|
+
try {
|
|
2062
|
+
await cursor.click(action.selector);
|
|
2063
|
+
} catch (cursorErr) {
|
|
2064
|
+
// Fallback to standard Puppeteer click if ghost-cursor fails
|
|
2065
|
+
// (e.g., element obscured by overlay, zero-size, or offscreen)
|
|
2066
|
+
this.logger?.warn('[WebTool] ghost-cursor click failed, falling back to standard click', {
|
|
2067
|
+
selector: action.selector,
|
|
2068
|
+
error: cursorErr.message
|
|
2069
|
+
});
|
|
2070
|
+
try {
|
|
2071
|
+
await page.click(action.selector, { button: action.button || 'left' });
|
|
2072
|
+
} catch (puppeteerClickErr) {
|
|
2073
|
+
// Final fallback: use JavaScript .click() for hidden/styled elements
|
|
2074
|
+
// (e.g., custom checkboxes, radio buttons with display:none inputs)
|
|
2075
|
+
this.logger?.warn('[WebTool] Puppeteer click also failed, using JS click fallback', {
|
|
2076
|
+
selector: action.selector,
|
|
2077
|
+
error: puppeteerClickErr.message
|
|
2078
|
+
});
|
|
2079
|
+
await page.evaluate((sel) => {
|
|
2080
|
+
const el = document.querySelector(sel);
|
|
2081
|
+
if (el) {
|
|
2082
|
+
// For checkboxes/radios, try clicking the associated label first
|
|
2083
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
2084
|
+
const label = el.id ? document.querySelector(`label[for="${el.id}"]`) : null;
|
|
2085
|
+
if (label) {
|
|
2086
|
+
label.click();
|
|
2087
|
+
} else {
|
|
2088
|
+
el.click();
|
|
2089
|
+
}
|
|
2090
|
+
} else {
|
|
2091
|
+
el.click();
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}, action.selector);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
} else {
|
|
2098
|
+
try {
|
|
2099
|
+
await page.click(action.selector, {
|
|
2100
|
+
button: action.button || 'left'
|
|
2101
|
+
});
|
|
2102
|
+
} catch (clickErr) {
|
|
2103
|
+
// JS fallback for non-humanMode too
|
|
2104
|
+
this.logger?.warn('[WebTool] Standard click failed, using JS click fallback', {
|
|
2105
|
+
selector: action.selector,
|
|
2106
|
+
error: clickErr.message
|
|
2107
|
+
});
|
|
2108
|
+
await page.evaluate((sel) => {
|
|
2109
|
+
const el = document.querySelector(sel);
|
|
2110
|
+
if (el) {
|
|
2111
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
2112
|
+
const label = el.id ? document.querySelector(`label[for="${el.id}"]`) : null;
|
|
2113
|
+
if (label) { label.click(); } else { el.click(); }
|
|
2114
|
+
} else {
|
|
2115
|
+
el.click();
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
}, action.selector);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
// Verify checkbox/radio state after click for agent awareness
|
|
2122
|
+
const clickedElState = await page.evaluate((sel) => {
|
|
2123
|
+
const el = document.querySelector(sel);
|
|
2124
|
+
if (!el) return null;
|
|
2125
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
2126
|
+
return { isToggle: true, checked: el.checked, type: el.type };
|
|
2127
|
+
}
|
|
2128
|
+
return { isToggle: false };
|
|
2129
|
+
}, action.selector);
|
|
2130
|
+
return {
|
|
2131
|
+
success: true,
|
|
2132
|
+
selector: action.selector,
|
|
2133
|
+
...(clickedElState?.isToggle && { checked: clickedElState.checked, elementType: clickedElState.type })
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
case 'type': {
|
|
2138
|
+
if (!action.selector) {
|
|
2139
|
+
return { success: false, error: 'selector is required for type action' };
|
|
2140
|
+
}
|
|
2141
|
+
const typeText = action.text || action.value || '';
|
|
2142
|
+
// Pre-check selector exists
|
|
2143
|
+
const typeTarget = await page.$(action.selector);
|
|
2144
|
+
if (!typeTarget) {
|
|
2145
|
+
return {
|
|
2146
|
+
success: false,
|
|
2147
|
+
error: `Element not found: "${action.selector}" on ${page.url()}`,
|
|
2148
|
+
selector: action.selector,
|
|
2149
|
+
suggestion: 'The input element may not exist or may have a different selector. Use extract-text to inspect the page.'
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
// Scroll element into view before interacting (prevents offscreen failures)
|
|
2153
|
+
await page.evaluate((sel) => {
|
|
2154
|
+
const el = document.querySelector(sel);
|
|
2155
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2156
|
+
}, action.selector);
|
|
2157
|
+
await new Promise(r => setTimeout(r, 300)); // Brief settle after scroll
|
|
2158
|
+
if (humanMode) {
|
|
2159
|
+
try {
|
|
2160
|
+
await humanType(page, action.selector, typeText, {
|
|
2161
|
+
clearFirst: action.clearFirst !== false,
|
|
2162
|
+
simulateTypos: action.simulateTypos || false
|
|
2163
|
+
});
|
|
2164
|
+
} catch (humanTypeErr) {
|
|
2165
|
+
// Fallback to standard Puppeteer typing if humanType fails
|
|
2166
|
+
this.logger?.warn('[WebTool] humanType failed, falling back to standard type', {
|
|
2167
|
+
selector: action.selector,
|
|
2168
|
+
error: humanTypeErr.message
|
|
2169
|
+
});
|
|
2170
|
+
if (action.clearFirst !== false) {
|
|
2171
|
+
await page.click(action.selector, { clickCount: 3 });
|
|
2172
|
+
await page.keyboard.press('Backspace');
|
|
2173
|
+
}
|
|
2174
|
+
await page.type(action.selector, typeText);
|
|
2175
|
+
}
|
|
2176
|
+
} else {
|
|
2177
|
+
if (action.clearFirst !== false) {
|
|
2178
|
+
await page.click(action.selector, { clickCount: 3 });
|
|
2179
|
+
await page.keyboard.press('Backspace');
|
|
2180
|
+
}
|
|
2181
|
+
await page.type(action.selector, typeText);
|
|
2182
|
+
}
|
|
2183
|
+
// Verify the value was set — fallback to direct value injection if needed
|
|
2184
|
+
let verificationNote = null;
|
|
2185
|
+
try {
|
|
2186
|
+
const currentVal = await page.evaluate((sel) => {
|
|
2187
|
+
const el = document.querySelector(sel);
|
|
2188
|
+
return el ? (el.value || el.textContent) : null;
|
|
2189
|
+
}, action.selector);
|
|
2190
|
+
if (currentVal !== null && !currentVal.includes(typeText.slice(0, 5)) && typeText.length > 0) {
|
|
2191
|
+
this.logger?.warn('[WebTool] Type verification failed, using direct value injection', { selector: action.selector });
|
|
2192
|
+
await page.evaluate((sel, val) => {
|
|
2193
|
+
const el = document.querySelector(sel);
|
|
2194
|
+
if (el) {
|
|
2195
|
+
el.value = val;
|
|
2196
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2197
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2198
|
+
}
|
|
2199
|
+
}, action.selector, typeText);
|
|
2200
|
+
verificationNote = 'Keyboard typing did not produce expected value; used direct value injection as fallback.';
|
|
2201
|
+
}
|
|
2202
|
+
} catch (verifyErr) {
|
|
2203
|
+
verificationNote = `Type verification check failed: ${verifyErr.message}`;
|
|
2204
|
+
}
|
|
2205
|
+
return {
|
|
2206
|
+
success: true,
|
|
2207
|
+
selector: action.selector,
|
|
2208
|
+
text: action.text,
|
|
2209
|
+
...(verificationNote && { warning: verificationNote })
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
case 'press':
|
|
2214
|
+
await page.keyboard.press(action.key || action.value);
|
|
2215
|
+
return { success: true, key: action.key };
|
|
2216
|
+
|
|
2217
|
+
case 'wait-for': {
|
|
2218
|
+
if (!action.selector) return { success: false, error: 'selector is required for wait-for action' };
|
|
2219
|
+
const waitTimeout = action.timeout ? parseInt(action.timeout, 10) : this.DEFAULT_TIMEOUT;
|
|
2220
|
+
try {
|
|
2221
|
+
await page.waitForSelector(action.selector, { timeout: waitTimeout });
|
|
2222
|
+
return { success: true, selector: action.selector };
|
|
2223
|
+
} catch (waitErr) {
|
|
2224
|
+
return {
|
|
2225
|
+
success: false,
|
|
2226
|
+
selector: action.selector,
|
|
2227
|
+
error: `Element "${action.selector}" did not appear within ${Math.round(waitTimeout / 1000)}s on ${page.url()}`,
|
|
2228
|
+
suggestion: 'The element may not exist on this page, may use a different selector, or may require user interaction first.'
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
case 'screenshot':
|
|
2234
|
+
return await this.takeScreenshot(page, action, context);
|
|
2235
|
+
|
|
2236
|
+
case 'analyze-screenshot':
|
|
2237
|
+
return await this.analyzeScreenshot(page, action.value, context);
|
|
2238
|
+
|
|
2239
|
+
case 'extract-text': {
|
|
2240
|
+
if (!action.selector) {
|
|
2241
|
+
return { success: false, error: 'selector is required for extract-text action' };
|
|
2242
|
+
}
|
|
2243
|
+
const extractedText = await page.evaluate((sel) => {
|
|
2244
|
+
const element = document.querySelector(sel);
|
|
2245
|
+
return element ? element.innerText : null;
|
|
2246
|
+
}, action.selector);
|
|
2247
|
+
if (extractedText === null) {
|
|
2248
|
+
return {
|
|
2249
|
+
success: false,
|
|
2250
|
+
selector: action.selector,
|
|
2251
|
+
error: `Element not found: "${action.selector}" on ${page.url()}`,
|
|
2252
|
+
text: null
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
return { success: true, selector: action.selector, text: extractedText };
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
case 'extract-links':
|
|
2259
|
+
const links = await page.evaluate((sel) => {
|
|
2260
|
+
const elements = document.querySelectorAll(sel);
|
|
2261
|
+
return Array.from(elements).map(a => ({
|
|
2262
|
+
href: a.href,
|
|
2263
|
+
text: a.textContent.trim()
|
|
2264
|
+
}));
|
|
2265
|
+
}, action.selector);
|
|
2266
|
+
return { success: true, selector: action.selector, links };
|
|
2267
|
+
|
|
2268
|
+
case 'get-source':
|
|
2269
|
+
const html = await page.content();
|
|
2270
|
+
return { success: true, html };
|
|
2271
|
+
|
|
2272
|
+
case 'get-console':
|
|
2273
|
+
return {
|
|
2274
|
+
success: true,
|
|
2275
|
+
consoleMessages: [...tabInfo.consoleMessages]
|
|
2276
|
+
};
|
|
2277
|
+
|
|
2278
|
+
case 'scroll':
|
|
2279
|
+
if (humanMode) {
|
|
2280
|
+
// Human-like smooth scroll
|
|
2281
|
+
await humanScroll(page, {
|
|
2282
|
+
direction: action.direction || 'down',
|
|
2283
|
+
distance: action.distance || 300
|
|
2284
|
+
});
|
|
2285
|
+
} else {
|
|
2286
|
+
await page.evaluate((sel) => {
|
|
2287
|
+
if (sel) {
|
|
2288
|
+
document.querySelector(sel)?.scrollIntoView();
|
|
2289
|
+
} else {
|
|
2290
|
+
window.scrollTo(0, document.body.scrollHeight);
|
|
2291
|
+
}
|
|
2292
|
+
}, action.selector);
|
|
2293
|
+
}
|
|
2294
|
+
return { success: true };
|
|
2295
|
+
|
|
2296
|
+
case 'hover': {
|
|
2297
|
+
if (!action.selector) return { success: false, error: 'selector is required for hover action' };
|
|
2298
|
+
const hoverTarget = await page.$(action.selector);
|
|
2299
|
+
if (!hoverTarget) {
|
|
2300
|
+
return { success: false, error: `Element not found: "${action.selector}" on ${page.url()}`, selector: action.selector };
|
|
2301
|
+
}
|
|
2302
|
+
// Scroll into view first
|
|
2303
|
+
await page.evaluate((sel) => {
|
|
2304
|
+
const el = document.querySelector(sel);
|
|
2305
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2306
|
+
}, action.selector);
|
|
2307
|
+
await new Promise(r => setTimeout(r, 300));
|
|
2308
|
+
if (humanMode && cursor) {
|
|
2309
|
+
try {
|
|
2310
|
+
await cursor.hover(action.selector);
|
|
2311
|
+
} catch (cursorErr) {
|
|
2312
|
+
this.logger?.warn('[WebTool] ghost-cursor hover failed, falling back to standard hover', {
|
|
2313
|
+
selector: action.selector, error: cursorErr.message
|
|
2314
|
+
});
|
|
2315
|
+
await page.hover(action.selector);
|
|
2316
|
+
}
|
|
2317
|
+
} else {
|
|
2318
|
+
await page.hover(action.selector);
|
|
2319
|
+
}
|
|
2320
|
+
return { success: true, selector: action.selector };
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
case 'mouse-move': {
|
|
2324
|
+
if (!action.selector) return { success: false, error: 'selector is required for mouse-move action' };
|
|
2325
|
+
const moveTarget = await page.$(action.selector);
|
|
2326
|
+
if (!moveTarget) {
|
|
2327
|
+
return { success: false, error: `Element not found: "${action.selector}" on ${page.url()}`, selector: action.selector };
|
|
2328
|
+
}
|
|
2329
|
+
// Scroll into view first
|
|
2330
|
+
await page.evaluate((sel) => {
|
|
2331
|
+
const el = document.querySelector(sel);
|
|
2332
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2333
|
+
}, action.selector);
|
|
2334
|
+
await new Promise(r => setTimeout(r, 300));
|
|
2335
|
+
if (humanMode && cursor) {
|
|
2336
|
+
try {
|
|
2337
|
+
await cursor.moveTo(action.selector);
|
|
2338
|
+
} catch (cursorErr) {
|
|
2339
|
+
this.logger?.warn('[WebTool] ghost-cursor moveTo failed, falling back to standard hover', {
|
|
2340
|
+
selector: action.selector, error: cursorErr.message
|
|
2341
|
+
});
|
|
2342
|
+
await page.hover(action.selector);
|
|
2343
|
+
}
|
|
2344
|
+
} else {
|
|
2345
|
+
await page.hover(action.selector);
|
|
2346
|
+
}
|
|
2347
|
+
return { success: true, selector: action.selector };
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
case 'wait':
|
|
2351
|
+
case 'delay':
|
|
2352
|
+
// Time-based wait (max 30 seconds to prevent abuse)
|
|
2353
|
+
const waitTime = Math.min(action.waitTime || action.value || 1000, 30000);
|
|
2354
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
2355
|
+
return { success: true, waited: waitTime };
|
|
2356
|
+
|
|
2357
|
+
case 'submit': {
|
|
2358
|
+
if (!action.selector) return { success: false, error: 'selector is required for submit action' };
|
|
2359
|
+
const submitTarget = await page.$(action.selector);
|
|
2360
|
+
if (!submitTarget) {
|
|
2361
|
+
return { success: false, error: `Element not found: "${action.selector}" on ${page.url()}`, selector: action.selector };
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// Check if submit button is disabled
|
|
2365
|
+
const submitDisabled = await page.evaluate((sel) => {
|
|
2366
|
+
const el = document.querySelector(sel);
|
|
2367
|
+
return el?.disabled || el?.getAttribute('aria-disabled') === 'true';
|
|
2368
|
+
}, action.selector);
|
|
2369
|
+
if (submitDisabled) {
|
|
2370
|
+
return {
|
|
2371
|
+
success: false,
|
|
2372
|
+
selector: action.selector,
|
|
2373
|
+
error: `Submit button "${action.selector}" is disabled — required fields may be empty or invalid.`,
|
|
2374
|
+
suggestion: 'Use get-field-values to inspect form state, or check for unfilled required fields and unchecked agreement checkboxes.'
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
// Scroll submit button into view
|
|
2379
|
+
await page.evaluate((sel) => {
|
|
2380
|
+
const el = document.querySelector(sel);
|
|
2381
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2382
|
+
}, action.selector);
|
|
2383
|
+
await new Promise(r => setTimeout(r, 300));
|
|
2384
|
+
|
|
2385
|
+
// Capture pre-submit state for change detection
|
|
2386
|
+
const preSubmitBodyText = await page.evaluate(() => document.body.innerText.substring(0, 500));
|
|
2387
|
+
const preSubmitUrl = page.url();
|
|
2388
|
+
|
|
2389
|
+
// Set up network POST listener to capture form submission response
|
|
2390
|
+
const submitNetworkResults = [];
|
|
2391
|
+
const submitResponseHandler = async (response) => {
|
|
2392
|
+
const req = response.request();
|
|
2393
|
+
if (req.method() === 'POST') {
|
|
2394
|
+
let respBody = null;
|
|
2395
|
+
try { respBody = await response.text(); } catch {}
|
|
2396
|
+
submitNetworkResults.push({
|
|
2397
|
+
url: response.url(),
|
|
2398
|
+
status: response.status(),
|
|
2399
|
+
body: respBody?.substring(0, 1000)
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
page.on('response', submitResponseHandler);
|
|
2404
|
+
|
|
2405
|
+
// Determine if selector points to a form or a button
|
|
2406
|
+
let isFormElement = false;
|
|
2407
|
+
try {
|
|
2408
|
+
isFormElement = await page.evaluate((sel) => {
|
|
2409
|
+
const el = document.querySelector(sel);
|
|
2410
|
+
return el?.tagName?.toLowerCase() === 'form';
|
|
2411
|
+
}, action.selector);
|
|
2412
|
+
} catch (evalErr) {
|
|
2413
|
+
this.logger?.warn('[WebTool] Form detection failed', { error: evalErr.message });
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
let navigationWarning = null;
|
|
2417
|
+
if (isFormElement) {
|
|
2418
|
+
await Promise.all([
|
|
2419
|
+
page.waitForNavigation({ timeout: 15000, waitUntil: BROWSER_CONFIG.WAIT_UNTIL }).catch((e) => {
|
|
2420
|
+
navigationWarning = `Navigation after submit did not complete: ${e.message}`;
|
|
2421
|
+
}),
|
|
2422
|
+
page.evaluate((sel) => document.querySelector(sel).submit(), action.selector)
|
|
2423
|
+
]);
|
|
2424
|
+
} else if (humanMode && cursor) {
|
|
2425
|
+
try {
|
|
2426
|
+
await humanSubmit(page, action.selector, {
|
|
2427
|
+
cursor,
|
|
2428
|
+
waitForNavigation: action.waitForNavigation !== false
|
|
2429
|
+
});
|
|
2430
|
+
} catch (submitCursorErr) {
|
|
2431
|
+
// Fallback to standard click for submit
|
|
2432
|
+
this.logger?.warn('[WebTool] humanSubmit failed, falling back to standard click', {
|
|
2433
|
+
selector: action.selector, error: submitCursorErr.message
|
|
2434
|
+
});
|
|
2435
|
+
await page.click(action.selector);
|
|
2436
|
+
}
|
|
2437
|
+
} else {
|
|
2438
|
+
await Promise.all([
|
|
2439
|
+
action.waitForNavigation !== false
|
|
2440
|
+
? page.waitForNavigation({ timeout: 15000, waitUntil: BROWSER_CONFIG.WAIT_UNTIL }).catch((e) => {
|
|
2441
|
+
navigationWarning = `Navigation after submit did not complete: ${e.message}`;
|
|
2442
|
+
})
|
|
2443
|
+
: Promise.resolve(),
|
|
2444
|
+
page.click(action.selector)
|
|
2445
|
+
]);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// Wait for AJAX responses to arrive (most forms submit via AJAX)
|
|
2449
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
2450
|
+
|
|
2451
|
+
// Remove listener
|
|
2452
|
+
page.off('response', submitResponseHandler);
|
|
2453
|
+
|
|
2454
|
+
// Detect success indicators on the page
|
|
2455
|
+
const submitDetection = await page.evaluate(() => {
|
|
2456
|
+
const body = document.body.innerText;
|
|
2457
|
+
const successKeywords = ['thank', 'success', 'submitted', 'received', 'confirmation', 'we will contact'];
|
|
2458
|
+
const errorKeywords = ['error', 'failed', 'invalid', 'required', 'please fill', 'try again'];
|
|
2459
|
+
const foundSuccess = successKeywords.filter(kw => body.toLowerCase().includes(kw));
|
|
2460
|
+
const foundErrors = errorKeywords.filter(kw => body.toLowerCase().includes(kw));
|
|
2461
|
+
|
|
2462
|
+
// Look for success/error DOM elements
|
|
2463
|
+
const successEls = document.querySelectorAll('[class*="success"], [class*="thank"], [class*="confirmation"], [data-success]');
|
|
2464
|
+
const errorEls = document.querySelectorAll('[class*="error"], [class*="invalid"], [class*="alert-danger"], .field-error');
|
|
2465
|
+
const successMessages = Array.from(successEls).map(el => el.innerText?.trim()).filter(t => t && t.length < 200);
|
|
2466
|
+
const errorMessages = Array.from(errorEls).map(el => el.innerText?.trim()).filter(t => t && t.length < 200 && t.length > 0);
|
|
2467
|
+
|
|
2468
|
+
return { foundSuccess, foundErrors, successMessages, errorMessages };
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
const postSubmitUrl = page.url();
|
|
2472
|
+
const urlChanged = postSubmitUrl !== preSubmitUrl;
|
|
2473
|
+
|
|
2474
|
+
// Determine if submission was confirmed
|
|
2475
|
+
const submitConfirmed = submitDetection.successMessages.length > 0 ||
|
|
2476
|
+
submitDetection.foundSuccess.length > 0 ||
|
|
2477
|
+
urlChanged ||
|
|
2478
|
+
submitNetworkResults.some(r => r.status >= 200 && r.status < 300);
|
|
2479
|
+
|
|
2480
|
+
return {
|
|
2481
|
+
success: true,
|
|
2482
|
+
selector: action.selector,
|
|
2483
|
+
url: postSubmitUrl,
|
|
2484
|
+
...(urlChanged && { urlChanged: true, previousUrl: preSubmitUrl }),
|
|
2485
|
+
...(navigationWarning && { warning: navigationWarning }),
|
|
2486
|
+
// Submission detection results
|
|
2487
|
+
submitConfirmed,
|
|
2488
|
+
...(submitDetection.successMessages.length > 0 && { successMessage: submitDetection.successMessages[0] }),
|
|
2489
|
+
...(submitDetection.errorMessages.length > 0 && { formErrors: submitDetection.errorMessages }),
|
|
2490
|
+
...(submitNetworkResults.length > 0 && {
|
|
2491
|
+
networkResponse: submitNetworkResults.map(r => ({
|
|
2492
|
+
url: r.url.substring(0, 150),
|
|
2493
|
+
status: r.status,
|
|
2494
|
+
...(r.body && r.body.length < 500 && { body: r.body })
|
|
2495
|
+
}))
|
|
2496
|
+
})
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
case 'evaluate': {
|
|
2501
|
+
// Execute arbitrary JavaScript in the page context
|
|
2502
|
+
const script = action.script || action.value;
|
|
2503
|
+
if (!script) {
|
|
2504
|
+
return { success: false, error: 'script is required for evaluate action. Provide JS code to execute in the page context.' };
|
|
2505
|
+
}
|
|
2506
|
+
try {
|
|
2507
|
+
const evalResult = await page.evaluate((code) => {
|
|
2508
|
+
try {
|
|
2509
|
+
// Wrap in Function to allow return statements, or eval directly
|
|
2510
|
+
const fn = new Function(code);
|
|
2511
|
+
const result = fn();
|
|
2512
|
+
// Serialize result (handle DOM elements, circular refs)
|
|
2513
|
+
if (result instanceof HTMLElement) {
|
|
2514
|
+
return { __type: 'HTMLElement', tagName: result.tagName, id: result.id, className: result.className?.toString(), text: result.innerText?.substring(0, 500) };
|
|
2515
|
+
}
|
|
2516
|
+
return JSON.parse(JSON.stringify(result ?? null));
|
|
2517
|
+
} catch (e) {
|
|
2518
|
+
return { __error: e.message };
|
|
2519
|
+
}
|
|
2520
|
+
}, script);
|
|
2521
|
+
|
|
2522
|
+
if (evalResult && evalResult.__error) {
|
|
2523
|
+
return { success: false, error: `Script execution error: ${evalResult.__error}` };
|
|
2524
|
+
}
|
|
2525
|
+
return { success: true, result: evalResult };
|
|
2526
|
+
} catch (evalErr) {
|
|
2527
|
+
return { success: false, error: `Evaluate failed: ${evalErr.message}` };
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
case 'get-field-values': {
|
|
2532
|
+
// Read current values of form fields — essential for verifying form state
|
|
2533
|
+
const selectors = action.selectors || (action.selector ? [action.selector] : null);
|
|
2534
|
+
if (!selectors || !Array.isArray(selectors) || selectors.length === 0) {
|
|
2535
|
+
return { success: false, error: 'selectors array is required for get-field-values action.' };
|
|
2536
|
+
}
|
|
2537
|
+
const fieldValues = await page.evaluate((sels) => {
|
|
2538
|
+
const results = {};
|
|
2539
|
+
for (const sel of sels) {
|
|
2540
|
+
const el = document.querySelector(sel);
|
|
2541
|
+
if (!el) {
|
|
2542
|
+
results[sel] = { found: false };
|
|
2543
|
+
continue;
|
|
2544
|
+
}
|
|
2545
|
+
const entry = { found: true, tagName: el.tagName.toLowerCase() };
|
|
2546
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
2547
|
+
entry.checked = el.checked;
|
|
2548
|
+
entry.value = el.value;
|
|
2549
|
+
} else if (el.tagName === 'SELECT') {
|
|
2550
|
+
entry.value = el.value;
|
|
2551
|
+
entry.selectedText = el.options[el.selectedIndex]?.text;
|
|
2552
|
+
entry.options = Array.from(el.options).map(o => ({ value: o.value, text: o.text, selected: o.selected }));
|
|
2553
|
+
} else {
|
|
2554
|
+
entry.value = el.value || el.innerText?.substring(0, 500) || '';
|
|
2555
|
+
}
|
|
2556
|
+
entry.disabled = el.disabled || false;
|
|
2557
|
+
entry.required = el.required || el.hasAttribute('required') || false;
|
|
2558
|
+
results[sel] = entry;
|
|
2559
|
+
}
|
|
2560
|
+
return results;
|
|
2561
|
+
}, selectors);
|
|
2562
|
+
return { success: true, fields: fieldValues };
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
case 'select': {
|
|
2566
|
+
// Select an option in a <select> dropdown or custom dropdown
|
|
2567
|
+
if (!action.selector) {
|
|
2568
|
+
return { success: false, error: 'selector is required for select action' };
|
|
2569
|
+
}
|
|
2570
|
+
const selectValue = action.value || action.text;
|
|
2571
|
+
if (selectValue === undefined || selectValue === null) {
|
|
2572
|
+
return { success: false, error: 'value or text is required for select action' };
|
|
2573
|
+
}
|
|
2574
|
+
const selectEl = await page.$(action.selector);
|
|
2575
|
+
if (!selectEl) {
|
|
2576
|
+
return {
|
|
2577
|
+
success: false,
|
|
2578
|
+
error: `Element not found: "${action.selector}" on ${page.url()}`,
|
|
2579
|
+
selector: action.selector,
|
|
2580
|
+
suggestion: 'Use get-source or extract-text to find the correct select element selector.'
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
// Scroll into view
|
|
2584
|
+
await page.evaluate((sel) => {
|
|
2585
|
+
const el = document.querySelector(sel);
|
|
2586
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2587
|
+
}, action.selector);
|
|
2588
|
+
await new Promise(r => setTimeout(r, 300));
|
|
2589
|
+
|
|
2590
|
+
const isNativeSelect = await page.evaluate((sel) => {
|
|
2591
|
+
return document.querySelector(sel)?.tagName?.toLowerCase() === 'select';
|
|
2592
|
+
}, action.selector);
|
|
2593
|
+
|
|
2594
|
+
if (isNativeSelect) {
|
|
2595
|
+
// Native <select> — use Puppeteer's select method
|
|
2596
|
+
try {
|
|
2597
|
+
// Try matching by value first, then by visible text
|
|
2598
|
+
let selected = await page.select(action.selector, selectValue);
|
|
2599
|
+
if (!selected || selected.length === 0) {
|
|
2600
|
+
// Try matching by text content
|
|
2601
|
+
selected = await page.evaluate((sel, text) => {
|
|
2602
|
+
const selectEl = document.querySelector(sel);
|
|
2603
|
+
if (!selectEl) return [];
|
|
2604
|
+
const option = Array.from(selectEl.options).find(o =>
|
|
2605
|
+
o.text.toLowerCase().includes(text.toLowerCase()) ||
|
|
2606
|
+
o.value.toLowerCase().includes(text.toLowerCase())
|
|
2607
|
+
);
|
|
2608
|
+
if (option) {
|
|
2609
|
+
selectEl.value = option.value;
|
|
2610
|
+
selectEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2611
|
+
selectEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2612
|
+
return [option.value];
|
|
2613
|
+
}
|
|
2614
|
+
return [];
|
|
2615
|
+
}, action.selector, selectValue);
|
|
2616
|
+
}
|
|
2617
|
+
const currentValue = await page.evaluate((sel) => {
|
|
2618
|
+
const el = document.querySelector(sel);
|
|
2619
|
+
return { value: el?.value, text: el?.options?.[el?.selectedIndex]?.text };
|
|
2620
|
+
}, action.selector);
|
|
2621
|
+
return {
|
|
2622
|
+
success: true,
|
|
2623
|
+
selector: action.selector,
|
|
2624
|
+
selectedValue: currentValue.value,
|
|
2625
|
+
selectedText: currentValue.text
|
|
2626
|
+
};
|
|
2627
|
+
} catch (selectErr) {
|
|
2628
|
+
return { success: false, error: `Select failed: ${selectErr.message}`, selector: action.selector };
|
|
2629
|
+
}
|
|
2630
|
+
} else {
|
|
2631
|
+
// Custom dropdown — click to open, then click matching option
|
|
2632
|
+
if (humanMode && cursor) {
|
|
2633
|
+
try { await cursor.click(action.selector); } catch { await page.click(action.selector); }
|
|
2634
|
+
} else {
|
|
2635
|
+
await page.click(action.selector);
|
|
2636
|
+
}
|
|
2637
|
+
await new Promise(r => setTimeout(r, 500)); // Wait for dropdown to open
|
|
2638
|
+
|
|
2639
|
+
// Find and click the matching option
|
|
2640
|
+
const optionClicked = await page.evaluate((text) => {
|
|
2641
|
+
// Common dropdown option selectors
|
|
2642
|
+
const optionSelectors = [
|
|
2643
|
+
'[role="option"]', '[role="listbox"] li', '.dropdown-item', '.select-option',
|
|
2644
|
+
'[class*="option"]', '[class*="dropdown"] li', '[class*="menu"] li',
|
|
2645
|
+
'ul[class*="select"] li', 'div[class*="select"] div[class*="option"]'
|
|
2646
|
+
];
|
|
2647
|
+
for (const optSel of optionSelectors) {
|
|
2648
|
+
const options = document.querySelectorAll(optSel);
|
|
2649
|
+
for (const opt of options) {
|
|
2650
|
+
if (opt.innerText?.toLowerCase().includes(text.toLowerCase())) {
|
|
2651
|
+
opt.click();
|
|
2652
|
+
return { found: true, text: opt.innerText.trim() };
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
return { found: false };
|
|
2657
|
+
}, selectValue);
|
|
2658
|
+
|
|
2659
|
+
if (optionClicked.found) {
|
|
2660
|
+
return { success: true, selector: action.selector, selectedText: optionClicked.text };
|
|
2661
|
+
} else {
|
|
2662
|
+
return {
|
|
2663
|
+
success: false,
|
|
2664
|
+
selector: action.selector,
|
|
2665
|
+
error: `Could not find option matching "${selectValue}" in dropdown`,
|
|
2666
|
+
suggestion: 'Use extract-text on the dropdown or get-source to inspect available options.'
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
default:
|
|
2673
|
+
throw new Error(`Unknown action type: ${action.type}`);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
/**
|
|
2678
|
+
* Take screenshot of page
|
|
2679
|
+
* @param {Page} page - Puppeteer page
|
|
2680
|
+
* @param {Object} options - Screenshot options
|
|
2681
|
+
* @param {Object} context - Execution context
|
|
2682
|
+
* @returns {Promise<Object>} Screenshot result
|
|
2683
|
+
* @private
|
|
2684
|
+
*/
|
|
2685
|
+
async takeScreenshot(page, options, context) {
|
|
2686
|
+
const format = options.format || 'file';
|
|
2687
|
+
const screenshotPath = options.path;
|
|
2688
|
+
|
|
2689
|
+
if (format === 'base64') {
|
|
2690
|
+
const screenshot = await page.screenshot({ encoding: 'base64' });
|
|
2691
|
+
return {
|
|
2692
|
+
success: true,
|
|
2693
|
+
format: 'base64',
|
|
2694
|
+
screenshot
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// File format
|
|
2699
|
+
let filePath;
|
|
2700
|
+
|
|
2701
|
+
if (screenshotPath) {
|
|
2702
|
+
// Save to project directory if path is provided
|
|
2703
|
+
const projectDir = context.directoryAccess?.workingDirectory || context.projectDir || process.cwd();
|
|
2704
|
+
filePath = path.isAbsolute(screenshotPath)
|
|
2705
|
+
? screenshotPath
|
|
2706
|
+
: path.join(projectDir, screenshotPath);
|
|
2707
|
+
} else {
|
|
2708
|
+
// Save to temp directory
|
|
2709
|
+
const filename = `screenshot-${Date.now()}.png`;
|
|
2710
|
+
filePath = path.join(this.TEMP_DIR, filename);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
await page.screenshot({ path: filePath });
|
|
2714
|
+
|
|
2715
|
+
return {
|
|
2716
|
+
success: true,
|
|
2717
|
+
format: 'file',
|
|
2718
|
+
path: filePath
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
/**
|
|
2723
|
+
* Analyze screenshot using AI vision model
|
|
2724
|
+
* @param {Page} page - Puppeteer page
|
|
2725
|
+
* @param {string} question - Question for AI
|
|
2726
|
+
* @param {Object} context - Execution context
|
|
2727
|
+
* @returns {Promise<Object>} Analysis result
|
|
2728
|
+
* @private
|
|
2729
|
+
*/
|
|
2730
|
+
async analyzeScreenshot(page, question, context) {
|
|
2731
|
+
// Take screenshot as base64
|
|
2732
|
+
const screenshot = await page.screenshot({ encoding: 'base64' });
|
|
2733
|
+
|
|
2734
|
+
// Get AI service from context
|
|
2735
|
+
const aiService = context.aiService;
|
|
2736
|
+
if (!aiService) {
|
|
2737
|
+
throw new Error('AI service not available for screenshot analysis');
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
this.logger?.info('Analyzing screenshot with AI', {
|
|
2741
|
+
question: question.substring(0, 100),
|
|
2742
|
+
agentId: context.agentId
|
|
2743
|
+
});
|
|
2744
|
+
|
|
2745
|
+
try {
|
|
2746
|
+
// Use vision model (prefer o3 if available, fallback to gpt-4-vision)
|
|
2747
|
+
const model = 'o3'; // Will be mapped by AI service
|
|
2748
|
+
|
|
2749
|
+
// Create message with image
|
|
2750
|
+
const response = await aiService.sendMessage(
|
|
2751
|
+
model,
|
|
2752
|
+
question,
|
|
2753
|
+
{
|
|
2754
|
+
agentId: context.agentId,
|
|
2755
|
+
images: [`data:image/png;base64,${screenshot}`],
|
|
2756
|
+
apiKey: context.apiKey,
|
|
2757
|
+
customApiKeys: context.customApiKeys,
|
|
2758
|
+
platformProvided: context.platformProvided
|
|
2759
|
+
}
|
|
2760
|
+
);
|
|
2761
|
+
|
|
2762
|
+
return {
|
|
2763
|
+
success: true,
|
|
2764
|
+
question,
|
|
2765
|
+
analysis: response.content,
|
|
2766
|
+
model: response.model || model
|
|
2767
|
+
};
|
|
2768
|
+
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
this.logger?.error('Screenshot analysis failed', {
|
|
2771
|
+
error: error.message,
|
|
2772
|
+
agentId: context.agentId
|
|
2773
|
+
});
|
|
2774
|
+
|
|
2775
|
+
throw new Error(`Screenshot analysis failed: ${error.message}`);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
/**
|
|
2780
|
+
* Close a tab
|
|
2781
|
+
* @param {string} agentId - Agent identifier
|
|
2782
|
+
* @param {string} tabName - Tab name to close
|
|
2783
|
+
* @returns {Promise<Object>} Result
|
|
2784
|
+
*/
|
|
2785
|
+
async closeTab(agentId, tabName) {
|
|
2786
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
2787
|
+
if (!agentTabsMap || !agentTabsMap.has(tabName)) {
|
|
2788
|
+
throw new Error(`Tab '${tabName}' not found for agent ${agentId}`);
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
const tabInfo = agentTabsMap.get(tabName);
|
|
2792
|
+
|
|
2793
|
+
this.logger?.info('Closing tab', { agentId, tabName });
|
|
2794
|
+
|
|
2795
|
+
await tabInfo.page.close();
|
|
2796
|
+
agentTabsMap.delete(tabName);
|
|
2797
|
+
|
|
2798
|
+
return {
|
|
2799
|
+
success: true,
|
|
2800
|
+
tabName,
|
|
2801
|
+
message: `Tab '${tabName}' closed`
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
/**
|
|
2806
|
+
* Switch to an existing tab
|
|
2807
|
+
* @param {string} agentId - Agent identifier
|
|
2808
|
+
* @param {string} tabName - Tab name to switch to
|
|
2809
|
+
* @returns {Promise<Object>} Result
|
|
2810
|
+
*/
|
|
2811
|
+
async switchTab(agentId, tabName) {
|
|
2812
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
2813
|
+
if (!agentTabsMap || !agentTabsMap.has(tabName)) {
|
|
2814
|
+
throw new Error(`Tab '${tabName}' not found for agent ${agentId}`);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
const tabInfo = agentTabsMap.get(tabName);
|
|
2818
|
+
tabInfo.lastActivity = Date.now();
|
|
2819
|
+
|
|
2820
|
+
return {
|
|
2821
|
+
success: true,
|
|
2822
|
+
tabName,
|
|
2823
|
+
url: tabInfo.url,
|
|
2824
|
+
message: `Switched to tab '${tabName}'`
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
/**
|
|
2829
|
+
* List all active tabs for an agent
|
|
2830
|
+
* @param {string} agentId - Agent identifier
|
|
2831
|
+
* @returns {Promise<Object>} List of tabs
|
|
2832
|
+
*/
|
|
2833
|
+
async listTabs(agentId) {
|
|
2834
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
2835
|
+
|
|
2836
|
+
if (!agentTabsMap || agentTabsMap.size === 0) {
|
|
2837
|
+
return {
|
|
2838
|
+
success: true,
|
|
2839
|
+
tabCount: 0,
|
|
2840
|
+
tabs: [],
|
|
2841
|
+
message: 'No active tabs'
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
const tabs = [];
|
|
2846
|
+
for (const [name, info] of agentTabsMap.entries()) {
|
|
2847
|
+
tabs.push({
|
|
2848
|
+
name,
|
|
2849
|
+
url: info.url,
|
|
2850
|
+
idleTime: Date.now() - info.lastActivity,
|
|
2851
|
+
headless: info.headless
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
return {
|
|
2856
|
+
success: true,
|
|
2857
|
+
tabCount: tabs.length,
|
|
2858
|
+
tabs
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
/**
|
|
2863
|
+
* Start cleanup timer for idle tabs
|
|
2864
|
+
* @private
|
|
2865
|
+
*/
|
|
2866
|
+
startCleanupTimer() {
|
|
2867
|
+
if (this.cleanupTimer) {
|
|
2868
|
+
clearInterval(this.cleanupTimer);
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
this.cleanupTimer = setInterval(() => {
|
|
2872
|
+
this.cleanupIdleTabs();
|
|
2873
|
+
}, this.CLEANUP_INTERVAL);
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
/**
|
|
2877
|
+
* Cleanup idle tabs (1-hour timeout)
|
|
2878
|
+
* @private
|
|
2879
|
+
*/
|
|
2880
|
+
async cleanupIdleTabs() {
|
|
2881
|
+
const now = Date.now();
|
|
2882
|
+
const tabsToClose = [];
|
|
2883
|
+
|
|
2884
|
+
for (const [agentId, agentTabsMap] of this.agentTabs.entries()) {
|
|
2885
|
+
for (const [tabName, tabInfo] of agentTabsMap.entries()) {
|
|
2886
|
+
const idleTime = now - tabInfo.lastActivity;
|
|
2887
|
+
|
|
2888
|
+
if (idleTime > this.TAB_IDLE_TIMEOUT) {
|
|
2889
|
+
tabsToClose.push({ agentId, tabName, tabInfo });
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
if (tabsToClose.length > 0) {
|
|
2895
|
+
this.logger?.info('Cleaning up idle tabs', {
|
|
2896
|
+
count: tabsToClose.length
|
|
2897
|
+
});
|
|
2898
|
+
|
|
2899
|
+
for (const { agentId, tabName, tabInfo } of tabsToClose) {
|
|
2900
|
+
try {
|
|
2901
|
+
await tabInfo.page.close();
|
|
2902
|
+
this.agentTabs.get(agentId).delete(tabName);
|
|
2903
|
+
this.logger?.debug('Closed idle tab', { agentId, tabName });
|
|
2904
|
+
} catch (error) {
|
|
2905
|
+
this.logger?.error('Failed to close idle tab', {
|
|
2906
|
+
agentId,
|
|
2907
|
+
tabName,
|
|
2908
|
+
error: error.message
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/**
|
|
2916
|
+
* Cleanup all tabs for an agent (called when agent is deleted)
|
|
2917
|
+
* @param {string} agentId - Agent identifier
|
|
2918
|
+
* @returns {Promise<Object>} Cleanup result
|
|
2919
|
+
*/
|
|
2920
|
+
async cleanupAgent(agentId) {
|
|
2921
|
+
const agentTabsMap = this.agentTabs.get(agentId);
|
|
2922
|
+
|
|
2923
|
+
if (!agentTabsMap) {
|
|
2924
|
+
return {
|
|
2925
|
+
success: true,
|
|
2926
|
+
agentId,
|
|
2927
|
+
closedTabs: 0,
|
|
2928
|
+
message: 'No tabs to clean up'
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
this.logger?.info('Cleaning up agent tabs', {
|
|
2933
|
+
agentId,
|
|
2934
|
+
tabCount: agentTabsMap.size
|
|
2935
|
+
});
|
|
2936
|
+
|
|
2937
|
+
let closedCount = 0;
|
|
2938
|
+
|
|
2939
|
+
for (const [tabName, tabInfo] of agentTabsMap.entries()) {
|
|
2940
|
+
try {
|
|
2941
|
+
await tabInfo.page.close();
|
|
2942
|
+
closedCount++;
|
|
2943
|
+
} catch (error) {
|
|
2944
|
+
this.logger?.error('Failed to close tab during cleanup', {
|
|
2945
|
+
agentId,
|
|
2946
|
+
tabName,
|
|
2947
|
+
error: error.message
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
this.agentTabs.delete(agentId);
|
|
2953
|
+
|
|
2954
|
+
return {
|
|
2955
|
+
success: true,
|
|
2956
|
+
agentId,
|
|
2957
|
+
closedTabs: closedCount,
|
|
2958
|
+
message: `Closed ${closedCount} tabs for agent ${agentId}`
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
/**
|
|
2963
|
+
* Ensure temp directory exists
|
|
2964
|
+
* @private
|
|
2965
|
+
*/
|
|
2966
|
+
async ensureTempDir() {
|
|
2967
|
+
try {
|
|
2968
|
+
await fs.mkdir(this.TEMP_DIR, { recursive: true });
|
|
2969
|
+
} catch (error) {
|
|
2970
|
+
this.logger?.warn('Failed to create temp directory', {
|
|
2971
|
+
path: this.TEMP_DIR,
|
|
2972
|
+
error: error.message
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
/**
|
|
2978
|
+
* Fix 4: Save all session cookies from authenticated tabs before browser close
|
|
2979
|
+
* This preserves login sessions even if browser needs to restart
|
|
2980
|
+
* @private
|
|
2981
|
+
* @returns {Promise<number>} Number of sessions saved
|
|
2982
|
+
*/
|
|
2983
|
+
async saveAllSessionCookies() {
|
|
2984
|
+
let savedCount = 0;
|
|
2985
|
+
const vault = getCredentialVault();
|
|
2986
|
+
|
|
2987
|
+
for (const [agentId, agentTabsMap] of this.agentTabs.entries()) {
|
|
2988
|
+
for (const [tabName, tabInfo] of agentTabsMap.entries()) {
|
|
2989
|
+
try {
|
|
2990
|
+
// Only save cookies from authenticated tabs or tabs with a meaningful URL
|
|
2991
|
+
if (tabInfo.authenticated || tabInfo.url) {
|
|
2992
|
+
const page = tabInfo.page;
|
|
2993
|
+
if (page && !page.isClosed()) {
|
|
2994
|
+
const cookies = await page.cookies();
|
|
2995
|
+
if (cookies && cookies.length > 0) {
|
|
2996
|
+
// Derive site ID from tab name or URL
|
|
2997
|
+
const siteId = tabInfo.siteId || tabName.replace(/-session$/, '') || this.extractDomainFromUrl(tabInfo.url);
|
|
2998
|
+
if (siteId) {
|
|
2999
|
+
await vault.saveSession(siteId, cookies);
|
|
3000
|
+
savedCount++;
|
|
3001
|
+
this.logger?.info('[WebTool] Saved session cookies before browser close', {
|
|
3002
|
+
agentId,
|
|
3003
|
+
tabName,
|
|
3004
|
+
siteId,
|
|
3005
|
+
cookieCount: cookies.length
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
} catch (error) {
|
|
3012
|
+
this.logger?.warn('[WebTool] Failed to save session cookies', {
|
|
3013
|
+
agentId,
|
|
3014
|
+
tabName,
|
|
3015
|
+
error: error.message
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
return savedCount;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
/**
|
|
3025
|
+
* Extract domain from URL for session identification
|
|
3026
|
+
* @private
|
|
3027
|
+
* @param {string} url - URL to extract domain from
|
|
3028
|
+
* @returns {string|null} Domain or null
|
|
3029
|
+
*/
|
|
3030
|
+
extractDomainFromUrl(url) {
|
|
3031
|
+
try {
|
|
3032
|
+
if (!url) return null;
|
|
3033
|
+
const urlObj = new URL(url);
|
|
3034
|
+
// Return domain without www prefix
|
|
3035
|
+
return urlObj.hostname.replace(/^www\./, '');
|
|
3036
|
+
} catch {
|
|
3037
|
+
return null;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
/**
|
|
3042
|
+
* Close the browser instance without full cleanup
|
|
3043
|
+
* Useful for changing stealth levels or freeing resources
|
|
3044
|
+
* @returns {Promise<void>}
|
|
3045
|
+
*/
|
|
3046
|
+
async closeBrowser() {
|
|
3047
|
+
if (this.browser) {
|
|
3048
|
+
try {
|
|
3049
|
+
// Fix 4: Save session cookies before closing tabs
|
|
3050
|
+
const savedSessions = await this.saveAllSessionCookies();
|
|
3051
|
+
if (savedSessions > 0) {
|
|
3052
|
+
this.logger?.info('[WebTool] Saved session cookies before browser close', { savedSessions });
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
// Close all agent tabs
|
|
3056
|
+
for (const [agentId] of this.agentTabs.entries()) {
|
|
3057
|
+
await this.cleanupAgent(agentId);
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
await this.browser.close();
|
|
3061
|
+
this.browser = null;
|
|
3062
|
+
this.logger?.info('[WebTool] Browser closed');
|
|
3063
|
+
} catch (error) {
|
|
3064
|
+
this.logger?.warn('[WebTool] Error closing browser', { error: error.message });
|
|
3065
|
+
this.browser = null;
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
/**
|
|
3071
|
+
* Cleanup resources
|
|
3072
|
+
*/
|
|
3073
|
+
async cleanup() {
|
|
3074
|
+
// Stop cleanup timer
|
|
3075
|
+
if (this.cleanupTimer) {
|
|
3076
|
+
clearInterval(this.cleanupTimer);
|
|
3077
|
+
this.cleanupTimer = null;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// Fix 4: Save session cookies before cleanup
|
|
3081
|
+
if (this.browser) {
|
|
3082
|
+
try {
|
|
3083
|
+
await this.saveAllSessionCookies();
|
|
3084
|
+
} catch (error) {
|
|
3085
|
+
this.logger?.warn('[WebTool] Failed to save session cookies during cleanup', {
|
|
3086
|
+
error: error.message
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
// Close all tabs
|
|
3092
|
+
for (const [agentId] of this.agentTabs.entries()) {
|
|
3093
|
+
await this.cleanupAgent(agentId);
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
// Close browser
|
|
3097
|
+
if (this.browser) {
|
|
3098
|
+
await this.browser.close();
|
|
3099
|
+
this.browser = null;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// Clean temp directory
|
|
3103
|
+
try {
|
|
3104
|
+
await fs.rm(this.TEMP_DIR, { recursive: true, force: true });
|
|
3105
|
+
} catch (error) {
|
|
3106
|
+
this.logger?.warn('Failed to clean temp directory', {
|
|
3107
|
+
path: this.TEMP_DIR,
|
|
3108
|
+
error: error.message
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
export default WebTool;
|