Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae22bda46c | ||
|
|
d82357fea4 | ||
|
|
66a39f3c83 | ||
|
|
eedf824af7 | ||
|
|
c07eb7ee52 | ||
|
|
a493d02c66 | ||
|
|
c4180c8cb1 | ||
|
|
56260f68b0 | ||
|
|
324a9e4e0c | ||
|
|
7f42eed031 | ||
|
|
c10981413f | ||
|
|
05018b309a | ||
|
|
9d0454d229 | ||
|
|
51f5fa484c | ||
|
|
857c0b25df | ||
|
|
62241b50e9 | ||
|
|
c6a33b6338 | ||
|
|
d32a1d046a | ||
|
|
0c0ed5e72c | ||
|
|
34092ec193 | ||
|
|
726377c4c1 | ||
|
|
8e2e4abce6 | ||
|
|
870895e922 | ||
|
|
2a766cfe48 | ||
|
|
d04cf59238 | ||
|
|
0a257ad2c1 | ||
|
|
d97e2016f5 | ||
|
|
6aea316c82 | ||
|
|
c7fa10cb1b | ||
|
|
44c0361fcd | ||
|
|
907ca8c808 | ||
|
|
b304a4188c | ||
|
|
e912f541f2 | ||
|
|
7238b7da4c | ||
|
|
79d8dfe34c | ||
|
|
14ec5b5ffa | ||
|
|
74fda70b67 | ||
|
|
2a397de59f | ||
|
|
bf672ee7f9 | ||
|
|
74910cfef1 | ||
|
|
41830dff71 | ||
|
|
79b05877a8 | ||
|
|
7bd8ce937b | ||
|
|
b455a58aa2 | ||
|
|
b59dc4c82c | ||
|
|
2e38882664 | ||
|
|
bc1e917e87 | ||
|
|
e005c7251b |
@@ -1,202 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,356 +0,0 @@
|
||||
---
|
||||
name: skill-creator
|
||||
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# Skill Creator
|
||||
|
||||
This skill provides guidance for creating effective skills.
|
||||
|
||||
## About Skills
|
||||
|
||||
Skills are modular, self-contained packages that extend Claude's capabilities by providing
|
||||
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
|
||||
domains or tasks—they transform Claude from a general-purpose agent into a specialized agent
|
||||
equipped with procedural knowledge that no model can fully possess.
|
||||
|
||||
### What Skills Provide
|
||||
|
||||
1. Specialized workflows - Multi-step procedures for specific domains
|
||||
2. Tool integrations - Instructions for working with specific file formats or APIs
|
||||
3. Domain expertise - Company-specific knowledge, schemas, business logic
|
||||
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Concise is Key
|
||||
|
||||
The context window is a public good. Skills share the context window with everything else Claude needs: system prompt, conversation history, other Skills' metadata, and the actual user request.
|
||||
|
||||
**Default assumption: Claude is already very smart.** Only add context Claude doesn't already have. Challenge each piece of information: "Does Claude really need this explanation?" and "Does this paragraph justify its token cost?"
|
||||
|
||||
Prefer concise examples over verbose explanations.
|
||||
|
||||
### Set Appropriate Degrees of Freedom
|
||||
|
||||
Match the level of specificity to the task's fragility and variability:
|
||||
|
||||
**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.
|
||||
|
||||
**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.
|
||||
|
||||
**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.
|
||||
|
||||
Think of Claude as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).
|
||||
|
||||
### Anatomy of a Skill
|
||||
|
||||
Every skill consists of a required SKILL.md file and optional bundled resources:
|
||||
|
||||
```
|
||||
skill-name/
|
||||
├── SKILL.md (required)
|
||||
│ ├── YAML frontmatter metadata (required)
|
||||
│ │ ├── name: (required)
|
||||
│ │ └── description: (required)
|
||||
│ └── Markdown instructions (required)
|
||||
└── Bundled Resources (optional)
|
||||
├── scripts/ - Executable code (Python/Bash/etc.)
|
||||
├── references/ - Documentation intended to be loaded into context as needed
|
||||
└── assets/ - Files used in output (templates, icons, fonts, etc.)
|
||||
```
|
||||
|
||||
#### SKILL.md (required)
|
||||
|
||||
Every SKILL.md consists of:
|
||||
|
||||
- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Claude reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used.
|
||||
- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).
|
||||
|
||||
#### Bundled Resources (optional)
|
||||
|
||||
##### Scripts (`scripts/`)
|
||||
|
||||
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
|
||||
|
||||
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
|
||||
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
|
||||
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
|
||||
- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments
|
||||
|
||||
##### References (`references/`)
|
||||
|
||||
Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking.
|
||||
|
||||
- **When to include**: For documentation that Claude should reference while working
|
||||
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
|
||||
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
|
||||
- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed
|
||||
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
|
||||
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
|
||||
|
||||
##### Assets (`assets/`)
|
||||
|
||||
Files not intended to be loaded into context, but rather used within the output Claude produces.
|
||||
|
||||
- **When to include**: When the skill needs files that will be used in the final output
|
||||
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
|
||||
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
|
||||
- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context
|
||||
|
||||
#### What to Not Include in a Skill
|
||||
|
||||
A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:
|
||||
|
||||
- README.md
|
||||
- INSTALLATION_GUIDE.md
|
||||
- QUICK_REFERENCE.md
|
||||
- CHANGELOG.md
|
||||
- etc.
|
||||
|
||||
The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxilary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.
|
||||
|
||||
### Progressive Disclosure Design Principle
|
||||
|
||||
Skills use a three-level loading system to manage context efficiently:
|
||||
|
||||
1. **Metadata (name + description)** - Always in context (~100 words)
|
||||
2. **SKILL.md body** - When skill triggers (<5k words)
|
||||
3. **Bundled resources** - As needed by Claude (Unlimited because scripts can be executed without reading into context window)
|
||||
|
||||
#### Progressive Disclosure Patterns
|
||||
|
||||
Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.
|
||||
|
||||
**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.
|
||||
|
||||
**Pattern 1: High-level guide with references**
|
||||
|
||||
```markdown
|
||||
# PDF Processing
|
||||
|
||||
## Quick start
|
||||
|
||||
Extract text with pdfplumber:
|
||||
[code example]
|
||||
|
||||
## Advanced features
|
||||
|
||||
- **Form filling**: See [FORMS.md](FORMS.md) for complete guide
|
||||
- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods
|
||||
- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns
|
||||
```
|
||||
|
||||
Claude loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.
|
||||
|
||||
**Pattern 2: Domain-specific organization**
|
||||
|
||||
For Skills with multiple domains, organize content by domain to avoid loading irrelevant context:
|
||||
|
||||
```
|
||||
bigquery-skill/
|
||||
├── SKILL.md (overview and navigation)
|
||||
└── reference/
|
||||
├── finance.md (revenue, billing metrics)
|
||||
├── sales.md (opportunities, pipeline)
|
||||
├── product.md (API usage, features)
|
||||
└── marketing.md (campaigns, attribution)
|
||||
```
|
||||
|
||||
When a user asks about sales metrics, Claude only reads sales.md.
|
||||
|
||||
Similarly, for skills supporting multiple frameworks or variants, organize by variant:
|
||||
|
||||
```
|
||||
cloud-deploy/
|
||||
├── SKILL.md (workflow + provider selection)
|
||||
└── references/
|
||||
├── aws.md (AWS deployment patterns)
|
||||
├── gcp.md (GCP deployment patterns)
|
||||
└── azure.md (Azure deployment patterns)
|
||||
```
|
||||
|
||||
When the user chooses AWS, Claude only reads aws.md.
|
||||
|
||||
**Pattern 3: Conditional details**
|
||||
|
||||
Show basic content, link to advanced content:
|
||||
|
||||
```markdown
|
||||
# DOCX Processing
|
||||
|
||||
## Creating documents
|
||||
|
||||
Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).
|
||||
|
||||
## Editing documents
|
||||
|
||||
For simple edits, modify the XML directly.
|
||||
|
||||
**For tracked changes**: See [REDLINING.md](REDLINING.md)
|
||||
**For OOXML details**: See [OOXML.md](OOXML.md)
|
||||
```
|
||||
|
||||
Claude reads REDLINING.md or OOXML.md only when the user needs those features.
|
||||
|
||||
**Important guidelines:**
|
||||
|
||||
- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.
|
||||
- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Claude can see the full scope when previewing.
|
||||
|
||||
## Skill Creation Process
|
||||
|
||||
Skill creation involves these steps:
|
||||
|
||||
1. Understand the skill with concrete examples
|
||||
2. Plan reusable skill contents (scripts, references, assets)
|
||||
3. Initialize the skill (run init_skill.py)
|
||||
4. Edit the skill (implement resources and write SKILL.md)
|
||||
5. Package the skill (run package_skill.py)
|
||||
6. Iterate based on real usage
|
||||
|
||||
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
|
||||
|
||||
### Step 1: Understanding the Skill with Concrete Examples
|
||||
|
||||
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
|
||||
|
||||
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
|
||||
|
||||
For example, when building an image-editor skill, relevant questions include:
|
||||
|
||||
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
|
||||
- "Can you give some examples of how this skill would be used?"
|
||||
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
|
||||
- "What would a user say that should trigger this skill?"
|
||||
|
||||
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
|
||||
|
||||
Conclude this step when there is a clear sense of the functionality the skill should support.
|
||||
|
||||
### Step 2: Planning the Reusable Skill Contents
|
||||
|
||||
To turn concrete examples into an effective skill, analyze each example by:
|
||||
|
||||
1. Considering how to execute on the example from scratch
|
||||
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
|
||||
|
||||
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
|
||||
|
||||
1. Rotating a PDF requires re-writing the same code each time
|
||||
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
|
||||
|
||||
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
|
||||
|
||||
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
|
||||
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
|
||||
|
||||
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
|
||||
|
||||
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
|
||||
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
|
||||
|
||||
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
|
||||
|
||||
### Step 3: Initializing the Skill
|
||||
|
||||
At this point, it is time to actually create the skill.
|
||||
|
||||
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
|
||||
|
||||
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
scripts/init_skill.py <skill-name> --path <output-directory>
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- Creates the skill directory at the specified path
|
||||
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
|
||||
- Creates example resource directories: `scripts/`, `references/`, and `assets/`
|
||||
- Adds example files in each directory that can be customized or deleted
|
||||
|
||||
After initialization, customize or remove the generated SKILL.md and example files as needed.
|
||||
|
||||
### Step 4: Edit the Skill
|
||||
|
||||
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Include information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively.
|
||||
|
||||
#### Learn Proven Design Patterns
|
||||
|
||||
Consult these helpful guides based on your skill's needs:
|
||||
|
||||
- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic
|
||||
- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns
|
||||
|
||||
These files contain established best practices for effective skill design.
|
||||
|
||||
#### Start with Reusable Skill Contents
|
||||
|
||||
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
|
||||
|
||||
Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.
|
||||
|
||||
Any example files and directories not needed for the skill should be deleted. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them.
|
||||
|
||||
#### Update SKILL.md
|
||||
|
||||
**Writing Guidelines:** Always use imperative/infinitive form.
|
||||
|
||||
##### Frontmatter
|
||||
|
||||
Write the YAML frontmatter with `name` and `description`:
|
||||
|
||||
- `name`: The skill name
|
||||
- `description`: This is the primary triggering mechanism for your skill, and helps Claude understand when to use the skill.
|
||||
- Include both what the Skill does and specific triggers/contexts for when to use it.
|
||||
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Claude.
|
||||
- Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
|
||||
|
||||
Do not include any other fields in YAML frontmatter.
|
||||
|
||||
##### Body
|
||||
|
||||
Write instructions for using the skill and its bundled resources.
|
||||
|
||||
### Step 5: Packaging a Skill
|
||||
|
||||
Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
|
||||
|
||||
```bash
|
||||
scripts/package_skill.py <path/to/skill-folder>
|
||||
```
|
||||
|
||||
Optional output directory specification:
|
||||
|
||||
```bash
|
||||
scripts/package_skill.py <path/to/skill-folder> ./dist
|
||||
```
|
||||
|
||||
The packaging script will:
|
||||
|
||||
1. **Validate** the skill automatically, checking:
|
||||
|
||||
- YAML frontmatter format and required fields
|
||||
- Skill naming conventions and directory structure
|
||||
- Description completeness and quality
|
||||
- File organization and resource references
|
||||
|
||||
2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension.
|
||||
|
||||
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
|
||||
|
||||
### Step 6: Iterate
|
||||
|
||||
After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.
|
||||
|
||||
**Iteration workflow:**
|
||||
|
||||
1. Use the skill on real tasks
|
||||
2. Notice struggles or inefficiencies
|
||||
3. Identify how SKILL.md or bundled resources should be updated
|
||||
4. Implement changes and test again
|
||||
@@ -1,82 +0,0 @@
|
||||
# Output Patterns
|
||||
|
||||
Use these patterns when skills need to produce consistent, high-quality output.
|
||||
|
||||
## Template Pattern
|
||||
|
||||
Provide templates for output format. Match the level of strictness to your needs.
|
||||
|
||||
**For strict requirements (like API responses or data formats):**
|
||||
|
||||
```markdown
|
||||
## Report structure
|
||||
|
||||
ALWAYS use this exact template structure:
|
||||
|
||||
# [Analysis Title]
|
||||
|
||||
## Executive summary
|
||||
[One-paragraph overview of key findings]
|
||||
|
||||
## Key findings
|
||||
- Finding 1 with supporting data
|
||||
- Finding 2 with supporting data
|
||||
- Finding 3 with supporting data
|
||||
|
||||
## Recommendations
|
||||
1. Specific actionable recommendation
|
||||
2. Specific actionable recommendation
|
||||
```
|
||||
|
||||
**For flexible guidance (when adaptation is useful):**
|
||||
|
||||
```markdown
|
||||
## Report structure
|
||||
|
||||
Here is a sensible default format, but use your best judgment:
|
||||
|
||||
# [Analysis Title]
|
||||
|
||||
## Executive summary
|
||||
[Overview]
|
||||
|
||||
## Key findings
|
||||
[Adapt sections based on what you discover]
|
||||
|
||||
## Recommendations
|
||||
[Tailor to the specific context]
|
||||
|
||||
Adjust sections as needed for the specific analysis type.
|
||||
```
|
||||
|
||||
## Examples Pattern
|
||||
|
||||
For skills where output quality depends on seeing examples, provide input/output pairs:
|
||||
|
||||
```markdown
|
||||
## Commit message format
|
||||
|
||||
Generate commit messages following these examples:
|
||||
|
||||
**Example 1:**
|
||||
Input: Added user authentication with JWT tokens
|
||||
Output:
|
||||
```
|
||||
feat(auth): implement JWT-based authentication
|
||||
|
||||
Add login endpoint and token validation middleware
|
||||
```
|
||||
|
||||
**Example 2:**
|
||||
Input: Fixed bug where dates displayed incorrectly in reports
|
||||
Output:
|
||||
```
|
||||
fix(reports): correct date formatting in timezone conversion
|
||||
|
||||
Use UTC timestamps consistently across report generation
|
||||
```
|
||||
|
||||
Follow this style: type(scope): brief description, then detailed explanation.
|
||||
```
|
||||
|
||||
Examples help Claude understand the desired style and level of detail more clearly than descriptions alone.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Workflow Patterns
|
||||
|
||||
## Sequential Workflows
|
||||
|
||||
For complex tasks, break operations into clear, sequential steps. It is often helpful to give Claude an overview of the process towards the beginning of SKILL.md:
|
||||
|
||||
```markdown
|
||||
Filling a PDF form involves these steps:
|
||||
|
||||
1. Analyze the form (run analyze_form.py)
|
||||
2. Create field mapping (edit fields.json)
|
||||
3. Validate mapping (run validate_fields.py)
|
||||
4. Fill the form (run fill_form.py)
|
||||
5. Verify output (run verify_output.py)
|
||||
```
|
||||
|
||||
## Conditional Workflows
|
||||
|
||||
For tasks with branching logic, guide Claude through decision points:
|
||||
|
||||
```markdown
|
||||
1. Determine the modification type:
|
||||
**Creating new content?** → Follow "Creation workflow" below
|
||||
**Editing existing content?** → Follow "Editing workflow" below
|
||||
|
||||
2. Creation workflow: [steps]
|
||||
3. Editing workflow: [steps]
|
||||
```
|
||||
@@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Skill Initializer - Creates a new skill from template
|
||||
|
||||
Usage:
|
||||
init_skill.py <skill-name> --path <path>
|
||||
|
||||
Examples:
|
||||
init_skill.py my-new-skill --path skills/public
|
||||
init_skill.py my-api-helper --path skills/private
|
||||
init_skill.py custom-skill --path /custom/location
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SKILL_TEMPLATE = """---
|
||||
name: {skill_name}
|
||||
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
|
||||
---
|
||||
|
||||
# {skill_title}
|
||||
|
||||
## Overview
|
||||
|
||||
[TODO: 1-2 sentences explaining what this skill enables]
|
||||
|
||||
## Structuring This Skill
|
||||
|
||||
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
|
||||
|
||||
**1. Workflow-Based** (best for sequential processes)
|
||||
- Works well when there are clear step-by-step procedures
|
||||
- Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing"
|
||||
- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2...
|
||||
|
||||
**2. Task-Based** (best for tool collections)
|
||||
- Works well when the skill offers different operations/capabilities
|
||||
- Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text"
|
||||
- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2...
|
||||
|
||||
**3. Reference/Guidelines** (best for standards or specifications)
|
||||
- Works well for brand guidelines, coding standards, or requirements
|
||||
- Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features"
|
||||
- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage...
|
||||
|
||||
**4. Capabilities-Based** (best for integrated systems)
|
||||
- Works well when the skill provides multiple interrelated features
|
||||
- Example: Product Management with "Core Capabilities" → numbered capability list
|
||||
- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature...
|
||||
|
||||
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
|
||||
|
||||
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
|
||||
|
||||
## [TODO: Replace with the first main section based on chosen structure]
|
||||
|
||||
[TODO: Add content here. See examples in existing skills:
|
||||
- Code samples for technical skills
|
||||
- Decision trees for complex workflows
|
||||
- Concrete examples with realistic user requests
|
||||
- References to scripts/templates/references as needed]
|
||||
|
||||
## Resources
|
||||
|
||||
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
|
||||
|
||||
### scripts/
|
||||
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
|
||||
|
||||
**Examples from other skills:**
|
||||
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
|
||||
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
|
||||
|
||||
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
|
||||
|
||||
**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments.
|
||||
|
||||
### references/
|
||||
Documentation and reference material intended to be loaded into context to inform Claude's process and thinking.
|
||||
|
||||
**Examples from other skills:**
|
||||
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
|
||||
- BigQuery: API reference documentation and query examples
|
||||
- Finance: Schema documentation, company policies
|
||||
|
||||
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working.
|
||||
|
||||
### assets/
|
||||
Files not intended to be loaded into context, but rather used within the output Claude produces.
|
||||
|
||||
**Examples from other skills:**
|
||||
- Brand styling: PowerPoint template files (.pptx), logo files
|
||||
- Frontend builder: HTML/React boilerplate project directories
|
||||
- Typography: Font files (.ttf, .woff2)
|
||||
|
||||
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
|
||||
|
||||
---
|
||||
|
||||
**Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
|
||||
"""
|
||||
|
||||
EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
|
||||
"""
|
||||
Example helper script for {skill_name}
|
||||
|
||||
This is a placeholder script that can be executed directly.
|
||||
Replace with actual implementation or delete if not needed.
|
||||
|
||||
Example real scripts from other skills:
|
||||
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
|
||||
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
|
||||
"""
|
||||
|
||||
def main():
|
||||
print("This is an example script for {skill_name}")
|
||||
# TODO: Add actual script logic here
|
||||
# This could be data processing, file conversion, API calls, etc.
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
|
||||
|
||||
This is a placeholder for detailed reference documentation.
|
||||
Replace with actual reference content or delete if not needed.
|
||||
|
||||
Example real reference docs from other skills:
|
||||
- product-management/references/communication.md - Comprehensive guide for status updates
|
||||
- product-management/references/context_building.md - Deep-dive on gathering context
|
||||
- bigquery/references/ - API references and query examples
|
||||
|
||||
## When Reference Docs Are Useful
|
||||
|
||||
Reference docs are ideal for:
|
||||
- Comprehensive API documentation
|
||||
- Detailed workflow guides
|
||||
- Complex multi-step processes
|
||||
- Information too lengthy for main SKILL.md
|
||||
- Content that's only needed for specific use cases
|
||||
|
||||
## Structure Suggestions
|
||||
|
||||
### API Reference Example
|
||||
- Overview
|
||||
- Authentication
|
||||
- Endpoints with examples
|
||||
- Error codes
|
||||
- Rate limits
|
||||
|
||||
### Workflow Guide Example
|
||||
- Prerequisites
|
||||
- Step-by-step instructions
|
||||
- Common patterns
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
"""
|
||||
|
||||
EXAMPLE_ASSET = """# Example Asset File
|
||||
|
||||
This placeholder represents where asset files would be stored.
|
||||
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
|
||||
|
||||
Asset files are NOT intended to be loaded into context, but rather used within
|
||||
the output Claude produces.
|
||||
|
||||
Example asset files from other skills:
|
||||
- Brand guidelines: logo.png, slides_template.pptx
|
||||
- Frontend builder: hello-world/ directory with HTML/React boilerplate
|
||||
- Typography: custom-font.ttf, font-family.woff2
|
||||
- Data: sample_data.csv, test_dataset.json
|
||||
|
||||
## Common Asset Types
|
||||
|
||||
- Templates: .pptx, .docx, boilerplate directories
|
||||
- Images: .png, .jpg, .svg, .gif
|
||||
- Fonts: .ttf, .otf, .woff, .woff2
|
||||
- Boilerplate code: Project directories, starter files
|
||||
- Icons: .ico, .svg
|
||||
- Data files: .csv, .json, .xml, .yaml
|
||||
|
||||
Note: This is a text placeholder. Actual assets can be any file type.
|
||||
"""
|
||||
|
||||
|
||||
def title_case_skill_name(skill_name):
|
||||
"""Convert hyphenated skill name to Title Case for display."""
|
||||
return ' '.join(word.capitalize() for word in skill_name.split('-'))
|
||||
|
||||
|
||||
def init_skill(skill_name, path):
|
||||
"""
|
||||
Initialize a new skill directory with template SKILL.md.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill
|
||||
path: Path where the skill directory should be created
|
||||
|
||||
Returns:
|
||||
Path to created skill directory, or None if error
|
||||
"""
|
||||
# Determine skill directory path
|
||||
skill_dir = Path(path).resolve() / skill_name
|
||||
|
||||
# Check if directory already exists
|
||||
if skill_dir.exists():
|
||||
print(f"[x] Error: Skill directory already exists: {skill_dir}")
|
||||
return None
|
||||
|
||||
# Create skill directory
|
||||
try:
|
||||
skill_dir.mkdir(parents=True, exist_ok=False)
|
||||
print(f"[OK] Created skill directory: {skill_dir}")
|
||||
except Exception as e:
|
||||
print(f"[x] Error creating directory: {e}")
|
||||
return None
|
||||
|
||||
# Create SKILL.md from template
|
||||
skill_title = title_case_skill_name(skill_name)
|
||||
skill_content = SKILL_TEMPLATE.format(
|
||||
skill_name=skill_name,
|
||||
skill_title=skill_title
|
||||
)
|
||||
|
||||
skill_md_path = skill_dir / 'SKILL.md'
|
||||
try:
|
||||
skill_md_path.write_text(skill_content)
|
||||
print("[OK] Created SKILL.md")
|
||||
except Exception as e:
|
||||
print(f"[x] Error creating SKILL.md: {e}")
|
||||
return None
|
||||
|
||||
# Create resource directories with example files
|
||||
try:
|
||||
# Create scripts/ directory with example script
|
||||
scripts_dir = skill_dir / 'scripts'
|
||||
scripts_dir.mkdir(exist_ok=True)
|
||||
example_script = scripts_dir / 'example.py'
|
||||
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
|
||||
example_script.chmod(0o755)
|
||||
print("[OK] Created scripts/example.py")
|
||||
|
||||
# Create references/ directory with example reference doc
|
||||
references_dir = skill_dir / 'references'
|
||||
references_dir.mkdir(exist_ok=True)
|
||||
example_reference = references_dir / 'api_reference.md'
|
||||
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
|
||||
print("[OK] Created references/api_reference.md")
|
||||
|
||||
# Create assets/ directory with example asset placeholder
|
||||
assets_dir = skill_dir / 'assets'
|
||||
assets_dir.mkdir(exist_ok=True)
|
||||
example_asset = assets_dir / 'example_asset.txt'
|
||||
example_asset.write_text(EXAMPLE_ASSET)
|
||||
print("[OK] Created assets/example_asset.txt")
|
||||
except Exception as e:
|
||||
print(f"[x] Error creating resource directories: {e}")
|
||||
return None
|
||||
|
||||
# Print next steps
|
||||
print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}")
|
||||
print("\nNext steps:")
|
||||
print("1. Edit SKILL.md to complete the TODO items and update the description")
|
||||
print("2. Customize or delete the example files in scripts/, references/, and assets/")
|
||||
print("3. Run the validator when ready to check the skill structure")
|
||||
|
||||
return skill_dir
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4 or sys.argv[2] != '--path':
|
||||
print("Usage: init_skill.py <skill-name> --path <path>")
|
||||
print("\nSkill name requirements:")
|
||||
print(" - Hyphen-case identifier (e.g., 'data-analyzer')")
|
||||
print(" - Lowercase letters, digits, and hyphens only")
|
||||
print(" - Max 40 characters")
|
||||
print(" - Must match directory name exactly")
|
||||
print("\nExamples:")
|
||||
print(" init_skill.py my-new-skill --path skills/public")
|
||||
print(" init_skill.py my-api-helper --path skills/private")
|
||||
print(" init_skill.py custom-skill --path /custom/location")
|
||||
sys.exit(1)
|
||||
|
||||
skill_name = sys.argv[1]
|
||||
path = sys.argv[3]
|
||||
|
||||
print(f"Initializing skill: {skill_name}")
|
||||
print(f" Location: {path}")
|
||||
print()
|
||||
|
||||
result = init_skill(skill_name, path)
|
||||
|
||||
if result:
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Skill Packager - Creates a distributable .skill file of a skill folder
|
||||
|
||||
Usage:
|
||||
python utils/package_skill.py <path/to/skill-folder> [output-directory]
|
||||
|
||||
Example:
|
||||
python utils/package_skill.py skills/public/my-skill
|
||||
python utils/package_skill.py skills/public/my-skill ./dist
|
||||
"""
|
||||
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
# Add script directory to path for sibling imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from quick_validate import validate_skill
|
||||
|
||||
|
||||
def package_skill(skill_path, output_dir=None):
|
||||
"""
|
||||
Package a skill folder into a .skill file.
|
||||
|
||||
Args:
|
||||
skill_path: Path to the skill folder
|
||||
output_dir: Optional output directory for the .skill file (defaults to current directory)
|
||||
|
||||
Returns:
|
||||
Path to the created .skill file, or None if error
|
||||
"""
|
||||
skill_path = Path(skill_path).resolve()
|
||||
|
||||
# Validate skill folder exists
|
||||
if not skill_path.exists():
|
||||
print(f"[x] Error: Skill folder not found: {skill_path}")
|
||||
return None
|
||||
|
||||
if not skill_path.is_dir():
|
||||
print(f"[x] Error: Path is not a directory: {skill_path}")
|
||||
return None
|
||||
|
||||
# Validate SKILL.md exists
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
print(f"[x] Error: SKILL.md not found in {skill_path}")
|
||||
return None
|
||||
|
||||
# Run validation before packaging
|
||||
print("Validating skill...")
|
||||
valid, message = validate_skill(skill_path)
|
||||
if not valid:
|
||||
print(f"[x] Validation failed: {message}")
|
||||
print(" Please fix the validation errors before packaging.")
|
||||
return None
|
||||
print(f"[OK] {message}\n")
|
||||
|
||||
# Determine output location
|
||||
skill_name = skill_path.name
|
||||
if output_dir:
|
||||
output_path = Path(output_dir).resolve()
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
output_path = Path.cwd()
|
||||
|
||||
skill_filename = output_path / f"{skill_name}.skill"
|
||||
|
||||
# Create the .skill file (zip format)
|
||||
try:
|
||||
with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Walk through the skill directory
|
||||
for file_path in skill_path.rglob('*'):
|
||||
if file_path.is_file():
|
||||
# Calculate the relative path within the zip
|
||||
arcname = file_path.relative_to(skill_path.parent)
|
||||
zipf.write(file_path, arcname)
|
||||
print(f" Added: {arcname}")
|
||||
|
||||
print(f"\n[OK] Successfully packaged skill to: {skill_filename}")
|
||||
return skill_filename
|
||||
|
||||
except Exception as e:
|
||||
print(f"[x] Error creating .skill file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]")
|
||||
print("\nExample:")
|
||||
print(" python utils/package_skill.py skills/public/my-skill")
|
||||
print(" python utils/package_skill.py skills/public/my-skill ./dist")
|
||||
sys.exit(1)
|
||||
|
||||
skill_path = sys.argv[1]
|
||||
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
print(f"Packaging skill: {skill_path}")
|
||||
if output_dir:
|
||||
print(f" Output directory: {output_dir}")
|
||||
print()
|
||||
|
||||
result = package_skill(skill_path, output_dir)
|
||||
|
||||
if result:
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick validation script for skills - minimal version
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
def validate_skill(skill_path):
|
||||
"""Basic validation of a skill"""
|
||||
skill_path = Path(skill_path)
|
||||
|
||||
# Check SKILL.md exists
|
||||
skill_md = skill_path / 'SKILL.md'
|
||||
if not skill_md.exists():
|
||||
return False, "SKILL.md not found"
|
||||
|
||||
# Read and validate frontmatter
|
||||
content = skill_md.read_text()
|
||||
if not content.startswith('---'):
|
||||
return False, "No YAML frontmatter found"
|
||||
|
||||
# Extract frontmatter
|
||||
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not match:
|
||||
return False, "Invalid frontmatter format"
|
||||
|
||||
frontmatter_text = match.group(1)
|
||||
|
||||
# Parse YAML frontmatter
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_text)
|
||||
if not isinstance(frontmatter, dict):
|
||||
return False, "Frontmatter must be a YAML dictionary"
|
||||
except yaml.YAMLError as e:
|
||||
return False, f"Invalid YAML in frontmatter: {e}"
|
||||
|
||||
# Define allowed properties
|
||||
ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata'}
|
||||
|
||||
# Check for unexpected properties (excluding nested keys under metadata)
|
||||
unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES
|
||||
if unexpected_keys:
|
||||
return False, (
|
||||
f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. "
|
||||
f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}"
|
||||
)
|
||||
|
||||
# Check required fields
|
||||
if 'name' not in frontmatter:
|
||||
return False, "Missing 'name' in frontmatter"
|
||||
if 'description' not in frontmatter:
|
||||
return False, "Missing 'description' in frontmatter"
|
||||
|
||||
# Extract name for validation
|
||||
name = frontmatter.get('name', '')
|
||||
if not isinstance(name, str):
|
||||
return False, f"Name must be a string, got {type(name).__name__}"
|
||||
name = name.strip()
|
||||
if name:
|
||||
# Check naming convention (hyphen-case: lowercase with hyphens)
|
||||
if not re.match(r'^[a-z0-9-]+$', name):
|
||||
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)"
|
||||
if name.startswith('-') or name.endswith('-') or '--' in name:
|
||||
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
|
||||
# Check name length (max 64 characters per spec)
|
||||
if len(name) > 64:
|
||||
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters."
|
||||
|
||||
# Extract and validate description
|
||||
description = frontmatter.get('description', '')
|
||||
if not isinstance(description, str):
|
||||
return False, f"Description must be a string, got {type(description).__name__}"
|
||||
description = description.strip()
|
||||
if description:
|
||||
# Check for angle brackets
|
||||
if '<' in description or '>' in description:
|
||||
return False, "Description cannot contain angle brackets (< or >)"
|
||||
# Check description length (max 1024 characters per spec)
|
||||
if len(description) > 1024:
|
||||
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters."
|
||||
|
||||
return True, "Skill is valid!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python quick_validate.py <skill_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
valid, message = validate_skill(sys.argv[1])
|
||||
print(message)
|
||||
sys.exit(0 if valid else 1)
|
||||
+54
-44
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
version-sync:
|
||||
@@ -55,18 +56,35 @@ jobs:
|
||||
run: pnpm test
|
||||
|
||||
rust:
|
||||
name: Rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
|
||||
rust-cross:
|
||||
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- os: windows-latest
|
||||
- os: windows-latest-8-cores
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
steps:
|
||||
@@ -78,29 +96,19 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
workspaces: cli
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
windows-integration:
|
||||
name: Windows Integration Test
|
||||
runs-on: windows-latest
|
||||
needs: rust
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest-8-cores
|
||||
needs: rust-cross
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -122,18 +130,10 @@ jobs:
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: windows-cargo-x86_64-pc-windows-msvc-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
windows-cargo-x86_64-pc-windows-msvc-
|
||||
workspaces: cli
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
|
||||
@@ -173,6 +173,23 @@ jobs:
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Test daemon lifecycle (open, snapshot, close)
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
Write-Host "--- Opening page ---"
|
||||
bin/agent-browser-win32-x64.exe open https://example.com
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
|
||||
Write-Host "--- Taking snapshot ---"
|
||||
$snapshot = bin/agent-browser-win32-x64.exe snapshot
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "snapshot failed"; exit 1 }
|
||||
Write-Host $snapshot
|
||||
Write-Host "--- Closing browser ---"
|
||||
bin/agent-browser-win32-x64.exe close
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "close failed"; exit 1 }
|
||||
Write-Host "--- Windows daemon lifecycle test passed ---"
|
||||
shell: pwsh
|
||||
timeout-minutes: 5
|
||||
|
||||
serverless-chromium:
|
||||
name: Serverless Chromium (@sparticuz/chromium)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -206,8 +223,9 @@ jobs:
|
||||
|
||||
global-install:
|
||||
name: Global Install (${{ matrix.os }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: rust
|
||||
needs: rust-cross
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -217,7 +235,7 @@ jobs:
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
- os: windows-latest
|
||||
- os: windows-latest-8-cores
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: agent-browser-win32-x64.exe
|
||||
|
||||
@@ -241,18 +259,10 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
workspaces: cli
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
@@ -98,18 +98,10 @@ jobs:
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
workspaces: cli
|
||||
|
||||
- name: Build with zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
@@ -209,6 +201,10 @@ jobs:
|
||||
fi
|
||||
echo "All 5 platform binaries present and valid"
|
||||
|
||||
- name: Verify bundled binary versions
|
||||
run: |
|
||||
pnpm run verify:bundled-binaries
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
|
||||
@@ -26,6 +26,57 @@ This applies to changes that either human users or AI agents would need to know
|
||||
|
||||
In the `docs/src/app/` MDX files, always use HTML `<table>` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site.
|
||||
|
||||
## Dual Architecture (Node.js + Native)
|
||||
|
||||
The codebase has two daemon implementations:
|
||||
|
||||
- **Node.js/Playwright** (default) -- `src/daemon.ts`, `src/actions.ts`, `src/browser.ts`, and the rest of `src/`
|
||||
- **Rust/Native** (experimental, `--native` or `AGENT_BROWSER_NATIVE=1`) -- `cli/src/native/daemon.rs`, `cli/src/native/actions.rs`, `cli/src/native/browser.rs`, and the rest of `cli/src/native/`
|
||||
|
||||
When modifying browser automation logic (commands, actions, protocol handling), changes **must** be made in **both** paths:
|
||||
|
||||
| Node.js Path | Native Path |
|
||||
|---|---|
|
||||
| `src/actions.ts` | `cli/src/native/actions.rs` |
|
||||
| `src/browser.ts` | `cli/src/native/browser.rs` |
|
||||
| `src/daemon.ts` | `cli/src/native/daemon.rs` |
|
||||
| `src/protocol.ts` | `cli/src/native/cdp/client.rs` |
|
||||
| `src/snapshot.ts` | `cli/src/native/snapshot.rs` |
|
||||
| `src/state-utils.ts` | `cli/src/native/state.rs` |
|
||||
|
||||
New commands must be implemented in both paths, or explicitly stubbed in the native path with a clear `"Not yet implemented: {action}"` error. The goal is eventual full migration to native, but until then both paths must stay in sync.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
cd cli && cargo test
|
||||
```
|
||||
|
||||
Runs all unit tests (~320 tests). These are fast and don't require Chrome.
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
```bash
|
||||
cd cli && cargo test e2e -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
Runs 18 e2e tests that launch real headless Chrome instances and exercise the full native daemon command pipeline. Requirements:
|
||||
|
||||
- Chrome must be installed
|
||||
- Must run serially (`--test-threads=1`) to avoid Chrome instance contention
|
||||
- Tests are `#[ignore]`'d so they don't run during normal `cargo test`
|
||||
|
||||
The e2e tests live in `cli/src/native/e2e_tests.rs` and cover: launch/close, navigation, snapshots, screenshots, form interaction, cookies, storage, tabs, element queries, viewport/emulation, domain filtering, diff, state management, error handling, and Phase 8 commands.
|
||||
|
||||
### Linting and Formatting
|
||||
|
||||
```bash
|
||||
cd cli && cargo fmt -- --check # Check formatting
|
||||
cd cli && cargo clippy # Lint
|
||||
```
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
## Source Code Reference
|
||||
|
||||
+20
-1
@@ -1,6 +1,25 @@
|
||||
# agent-browser
|
||||
|
||||
## 0.14.0-fork.3
|
||||
## 0.15.2-fork.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Merge upstream `v0.15.2` updates, including fixes for cookies clear/tab close output, daemon EPERM liveness checks, unnamed element reference matching, and docs/skills refresh.
|
||||
|
||||
## 0.15.1-fork.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Auto-attach existing browser more reliably by trying CDP localhost:9333 first, then falling back to auto-discovery before failing.
|
||||
Align daemon behavior and user-facing docs/skill guidance with the same attachment policy.
|
||||
|
||||
## 0.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7bd8ce9: Added support for chrome:// and chrome-extension:// URLs in navigation and recording commands. These special browser URLs are now preserved as-is instead of having https:// incorrectly prepended.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
|
||||
@@ -1,163 +1,272 @@
|
||||
# agent-browser-stealth
|
||||
|
||||
Stealth-focused fork of `agent-browser` for anti-bot evasion in production automation.
|
||||
Stealth-first fork of `agent-browser` for production browser automation under anti-bot pressure.
|
||||
|
||||
This fork keeps core browser automation capabilities in sync with upstream `agent-browser`, and focuses its own changes on stealth and anti-detection behavior.
|
||||
This README focuses on stealth architecture and principles. For full command coverage inherited from upstream, use:
|
||||
|
||||
## Positioning
|
||||
- upstream docs: <https://github.com/vercel-labs/agent-browser>
|
||||
- local help: `agent-browser --help` (short alias: `abs --help`)
|
||||
|
||||
- Core commands and workflows: aligned with upstream `agent-browser`
|
||||
- Fork value: stronger anti-bot defaults and operational policies
|
||||
- Default mindset: no extra stealth toggle, stealth is always on
|
||||
## What This Fork Optimizes
|
||||
|
||||
## Installation
|
||||
- Stealth is always on (legacy `launch.stealth` is accepted but ignored).
|
||||
- Fingerprint surfaces are patched at multiple layers (launch args, CDP overrides, init scripts).
|
||||
- Behavioral signals are humanized (typing cadence, cursor path, pacing, retry backoff).
|
||||
- Region signals are auto-aligned (locale/timezone/Accept-Language) to reduce mismatch risk.
|
||||
- Verification/captcha handling is policy-driven (`--risk-mode off|warn|block`).
|
||||
|
||||
### Global (recommended)
|
||||
## FAQ: `agent-browser` vs `agent-browser-stealth`
|
||||
|
||||
People often ask this: "What's the anti-detection approach compared to `agent-browser-stealth` on npm?"
|
||||
|
||||
- `agent-browser-stealth` on npm is the package name for this fork.
|
||||
- The CLI keeps upstream-compatible command names (`agent-browser` is still the main executable, with `agent-browser-stealth` and `abs` as aliases).
|
||||
- The practical difference vs upstream `agent-browser` is not one single "stealth switch"; it is a defense-in-depth stack designed for anti-bot pressure.
|
||||
|
||||
The core idea is layered hardening across the full automation lifecycle:
|
||||
|
||||
1. Connection-aware policy: choose the best available stealth capability by mode (local launch/CDP/cloud provider).
|
||||
2. Fingerprint hardening: patch launch args, CDP metadata, and init-script surfaces before page code runs.
|
||||
3. Behavioral humanization: non-uniform typing/mouse/wait patterns instead of perfectly mechanical actions.
|
||||
4. Region coherence: auto-align locale/timezone/language signals to target geography.
|
||||
5. Risk-aware control loop: detect verification/captcha signals and handle them with explicit `risk-mode` policy.
|
||||
|
||||
Goal: reduce detection probability and improve stability in production automation. Non-goal: "guaranteed bypass" on every target.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser-stealth
|
||||
agent-browser install
|
||||
# same CLI, short alias
|
||||
abs install
|
||||
```
|
||||
|
||||
### Quick try with npx
|
||||
|
||||
```bash
|
||||
npx agent-browser-stealth install
|
||||
npx agent-browser-stealth open example.com
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/leeguooooo/agent-browser
|
||||
cd agent-browser
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build:native
|
||||
pnpm link --global
|
||||
agent-browser install
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
### Minimal Usage
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser click @e2
|
||||
agent-browser fill @e3 "test@example.com"
|
||||
agent-browser screenshot page.png
|
||||
```
|
||||
|
||||
## Anti-Bot Measures
|
||||
|
||||
Stealth is always enabled. Legacy `launch.stealth` is accepted only for compatibility and ignored.
|
||||
|
||||
### 1) Fingerprint hardening
|
||||
|
||||
- Hides automation indicators such as `navigator.webdriver`
|
||||
- Adds Chromium launch args to reduce automation fingerprints
|
||||
- Rewrites headless UA markers (`HeadlessChrome`)
|
||||
- Patches high-signal surfaces such as:
|
||||
- `navigator.plugins` / `navigator.mimeTypes`
|
||||
- `window.chrome.runtime`
|
||||
- WebGL vendor/renderer exposure
|
||||
- permissions/language/media/device related probes
|
||||
- Applies both context init scripts and CDP-level UA overrides
|
||||
- Preserves explicit custom UA from `--user-agent` or `launch({ userAgent })`
|
||||
|
||||
### 2) Behavioral humanization
|
||||
|
||||
- Randomized typing cadence when `--delay` is used
|
||||
- Random wait ranges (`wait 2000-5000`)
|
||||
- Bezier-curve mouse movement before click actions
|
||||
- Randomized navigation pacing
|
||||
|
||||
### 3) Region signal alignment
|
||||
|
||||
- Auto-aligns locale/timezone/Accept-Language by target TLD
|
||||
- Reduces locale-timezone mismatch risk on region-sensitive sites
|
||||
|
||||
### 4) Verification-aware retry
|
||||
|
||||
- Detects common captcha/verification interstitial patterns
|
||||
- Retries navigation with randomized backoff when triggered
|
||||
|
||||
## Typing `--delay` Correctly
|
||||
|
||||
Use `--delay` as an option:
|
||||
### Default: Auto Group Agent Tabs (CDP + Plugin)
|
||||
|
||||
```bash
|
||||
agent-browser type @e2 "iphone" --delay 120
|
||||
agent-browser keyboard type "iphone" --delay 120
|
||||
agent-browser open https://example.com
|
||||
# In CDP mode, tabs are grouped when the tab-group extension is installed
|
||||
|
||||
# Override group title
|
||||
agent-browser --tab-group "My Agent Group" open https://example.com
|
||||
```
|
||||
|
||||
If literal text includes `--delay`, stop option parsing with `--`:
|
||||
- CDP (`--cdp` / `--auto-connect`) keeps working unchanged.
|
||||
- If the extension is installed and handshake succeeds, agent tabs are grouped by session:
|
||||
- session=`default`: `Agent Browser Stealth`
|
||||
- other sessions: `Agent Browser Stealth • <session>`
|
||||
- If the extension is missing/unavailable, commands continue normally with silent no-op (no warning/error unless `AGENT_BROWSER_DEBUG=1`).
|
||||
- Env overrides:
|
||||
- `AGENT_BROWSER_TAB_GROUP` for base title
|
||||
- `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` for expected extension ID
|
||||
|
||||
```bash
|
||||
agent-browser type @e2 -- "--delay 120"
|
||||
agent-browser keyboard type -- "--delay 120"
|
||||
Install once in Chrome: load unpacked extension from `extensions/tab-group-cdp/` (extension name: `agent-browser-stealth`).
|
||||
|
||||
### Extension Capabilities (`agent-browser-stealth`)
|
||||
|
||||
- Session window isolation: tabs are kept in their session window when possible.
|
||||
- Configurable isolation controls: side panel can toggle `strictWindowIsolation` and cross-window activation guard.
|
||||
- Session-aware grouping: deterministic group color, default session expanded, non-default sessions collapsed.
|
||||
- Download archive routing: downloads from managed tabs are routed to `agent-browser-stealth/<session>/...`.
|
||||
- Domain allowlist fallback: when allowlist is configured for a session, extension can force-block out-of-policy tabs to `about:blank`.
|
||||
- Risk hints (debug only): suspicious host/TLD hints are returned via handshake and printed only when `AGENT_BROWSER_DEBUG=1`.
|
||||
- Side panel console: view session/tab/group mapping, focus a session, keep only one session, clean empty groups, edit session allowlist, and toggle auto-clean.
|
||||
|
||||
## Stealth Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Command Input"] --> B["Stealth Policy Resolver"]
|
||||
B --> C["Connection Mode Detection"]
|
||||
C --> D["Launch Layer: Chromium Args"]
|
||||
C --> E["CDP Layer: UA + Metadata Override"]
|
||||
C --> F["Context Layer: Init Script Patches"]
|
||||
D --> G["Behavior Layer: Humanized Interaction"]
|
||||
E --> G
|
||||
F --> G
|
||||
G --> H["Risk Layer: Verification Detection and Handling"]
|
||||
H --> I["Response with warnings and riskSignals"]
|
||||
```
|
||||
|
||||
## Validation Snapshot
|
||||
### Policy by Connection Mode
|
||||
|
||||
Manual checks were run against common public detection pages in headed mode, including:
|
||||
| Mode | Stealth Capabilities | Notes |
|
||||
| --------------------------------------- | ------------------------------------------------------------- | -------------------------------------------- |
|
||||
| Local Chromium launch | Chromium launch args + CDP UA override + context init scripts | Most complete stack |
|
||||
| Existing browser via CDP | CDP UA override + context init scripts | No local Chromium arg injection |
|
||||
| Cloud provider (browserbase/browseruse) | Context init scripts | Remote browser runtime controls launch layer |
|
||||
| Kernel provider | Context init scripts + provider-managed stealth | Provider-side stealth may also apply |
|
||||
|
||||
- [bot.sannysoft.com](https://bot.sannysoft.com/)
|
||||
- [CreepJS](https://abrahamjuliot.github.io/creepjs/)
|
||||
- [areyouheadless](https://arh.antoinevastel.com/bots/areyouheadless)
|
||||
- [detect-headless](https://infosimples.github.io/detect-headless)
|
||||
## Principle 1: Always-On Stealth with Explicit Boundaries
|
||||
|
||||
Reproduce CreepJS check:
|
||||
- Stealth defaults to enabled and does not depend on a runtime toggle.
|
||||
- Project policy forbids:
|
||||
- `--profile` / `AGENT_BROWSER_PROFILE`
|
||||
- `--channel` / `AGENT_BROWSER_CHANNEL`
|
||||
- Default CLI policy auto-attaches an existing browser: try CDP `localhost:9333` first, then auto-discovery unless explicit connection options are provided.
|
||||
|
||||
## Principle 2: Multi-Layer Fingerprint Hardening
|
||||
|
||||
### 2.1 Launch Layer (Local Chromium)
|
||||
|
||||
Injected Chromium args:
|
||||
|
||||
- `--disable-blink-features=AutomationControlled`
|
||||
- `--use-gl=angle`
|
||||
- `--use-angle=default`
|
||||
|
||||
If no custom UA is set, the runtime UA is normalized to remove `HeadlessChrome` tokens.
|
||||
|
||||
### 2.2 CDP Layer (Browser/Page Targets)
|
||||
|
||||
- Uses `Emulation.setUserAgentOverride` to align:
|
||||
- `userAgent`
|
||||
- `acceptLanguage`
|
||||
- `userAgentMetadata` brands and versions
|
||||
- Applies overrides for existing/new targets, including worker-relevant contexts.
|
||||
- Forces opaque white background (`Emulation.setDefaultBackgroundColorOverride`) to avoid headless transparency fingerprints.
|
||||
|
||||
### 2.3 Context Init-Script Layer (Patch Inventory)
|
||||
|
||||
The init script patch set is injected before page scripts and currently includes:
|
||||
|
||||
1. `navigator.webdriver` removal (including prototype-level cleanup).
|
||||
2. CSS webdriver heuristic neutralization (`CSS.supports('border-end-end-radius: initial')` probe).
|
||||
3. `window.chrome.runtime` bootstrap for missing runtime surfaces.
|
||||
4. Locale/language normalization (`navigator.language`, `navigator.languages`).
|
||||
5. Realistic `navigator.plugins` and `navigator.mimeTypes`.
|
||||
6. `navigator.permissions.query` normalization for notifications.
|
||||
7. WebGL vendor/renderer masking when SwiftShader indicators are present.
|
||||
8. `cdc_` property cleanup on document/documentElement.
|
||||
9. Window/screen dimension normalization (`outerWidth/outerHeight/screenX/screenY`).
|
||||
10. Screen availability patching (`availWidth/availHeight`).
|
||||
11. Hardware concurrency stabilization.
|
||||
12. Notification permission consistency.
|
||||
13. Active text color heuristic patching.
|
||||
14. `navigator.connection` normalization.
|
||||
15. Worker network signal normalization (`downlinkMax`).
|
||||
16. `prefers-color-scheme` light-mode heuristic neutralization.
|
||||
17. `navigator.share` exposure.
|
||||
18. `navigator.contacts` exposure.
|
||||
19. `contentIndex` exposure.
|
||||
20. `navigator.pdfViewerEnabled` normalization.
|
||||
21. Media devices surface normalization.
|
||||
22. `navigator.userAgent` cleanup (strip `HeadlessChrome`).
|
||||
23. `navigator.userAgentData` brand cleanup.
|
||||
24. `performance.memory` stabilization.
|
||||
25. Default background color patching at script level.
|
||||
|
||||
## Principle 3: Behavioral Humanization
|
||||
|
||||
- Navigation pacing jitter before `goto` (short randomized delay).
|
||||
- Typing jitter for `type --delay` and `keyboard type --delay`:
|
||||
- per-character randomized delay around the requested base delay (about ±40%).
|
||||
- Click path humanization:
|
||||
- cursor moves on a Bezier-like curve before click.
|
||||
- Wait supports random ranges (`wait min-max`) for non-uniform timing.
|
||||
|
||||
## Principle 4: Region Signal Alignment
|
||||
|
||||
Before navigation, the runtime derives region hints from target URL TLD and aligns:
|
||||
|
||||
- locale
|
||||
- timezone
|
||||
- `Accept-Language`
|
||||
|
||||
Examples of built-in mappings include `tw`, `jp`, `kr`, `sg`, `de`, `fr`, `uk`, `in`, `au`.
|
||||
|
||||
Manual overrides are supported:
|
||||
|
||||
- `AGENT_BROWSER_LOCALE`
|
||||
- `AGENT_BROWSER_TIMEZONE` (or `TZ`)
|
||||
|
||||
## Principle 5: Verification-Aware Risk Control
|
||||
|
||||
When a navigation lands on verification/captcha pages, structured risk signals are generated from URL/title/page-text evidence.
|
||||
|
||||
`riskSignals` include:
|
||||
|
||||
- `code`
|
||||
- `source` (`url` or `title`)
|
||||
- `evidence`
|
||||
- `confidence`
|
||||
|
||||
### Risk Mode
|
||||
|
||||
- `warn` (default): wait for auto-clear, then retry with randomized backoff and return warnings + `riskSignals`.
|
||||
- `block`: fail fast once verification/captcha interstitial is detected.
|
||||
- `off`: skip detection/retry path.
|
||||
|
||||
```bash
|
||||
agent-browser --risk-mode warn open https://example.com
|
||||
agent-browser --risk-mode block open https://example.com
|
||||
AGENT_BROWSER_RISK_MODE=off agent-browser open https://example.com
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Navigate"] --> B["Collect URL/Title/Text Signals"]
|
||||
B --> C{"risk-mode"}
|
||||
C -->|off| D["Return Success"]
|
||||
C -->|block| E["Return Error with First Signal"]
|
||||
C -->|warn| F["Wait for auto-clear, then retry up to 2 times"]
|
||||
F --> G{"Signals Cleared"}
|
||||
G -->|yes| H["Return Success + recovery warning + riskSignals"]
|
||||
G -->|no| I["Return Success + warning + riskSignals"]
|
||||
```
|
||||
|
||||
## Operational Recommendations
|
||||
|
||||
- Prefer `--headed` for high-friction targets.
|
||||
- Reuse session state with one stable `--session-name` for continuity (when omitted, it defaults to `--session`).
|
||||
- Keep locale/timezone consistent with target market.
|
||||
- For challenge-heavy pages, prefer `--wait-until domcontentloaded` on `open`/`navigate` to avoid `load` stalls.
|
||||
- Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages.
|
||||
- For `cookies set`, use either `--url <url>`, or `--domain <domain> --path <path>` together.
|
||||
- If `--url`, `--domain`, and `--path` are all omitted, the cookie is scoped from the current page URL.
|
||||
|
||||
## Validation Scripts
|
||||
|
||||
Run public detector checks after stealth changes:
|
||||
|
||||
```bash
|
||||
node scripts/check-sannysoft-webdriver.js --binary ./cli/target/release/agent-browser
|
||||
node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser
|
||||
node scripts/check-stealth-regression.js --binary ./cli/target/release/agent-browser
|
||||
pnpm run check:turnstile-testkey
|
||||
```
|
||||
|
||||
## Command Coverage And Docs
|
||||
## Doctor Diagnostics
|
||||
|
||||
Core command set is intentionally kept compatible with upstream `agent-browser`.
|
||||
|
||||
- Full command reference: [upstream agent-browser docs](https://github.com/vercel-labs/agent-browser)
|
||||
- Local help: `agent-browser --help`
|
||||
|
||||
## Fork Policies
|
||||
|
||||
This fork enforces a few operational policies:
|
||||
|
||||
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
|
||||
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
|
||||
- Default mode expects an existing browser via CDP on `localhost:9333`
|
||||
|
||||
## Maintainer Notes (Fork Release)
|
||||
|
||||
- Keep `upstream-main` for clean upstream sync
|
||||
- Merge upstream into short-lived sync branches, then PR into `main`
|
||||
- Recommended release format: `<upstream>-fork.<fork>` (example: `0.14.0-fork.3`)
|
||||
- Use npm Trusted Publishing (OIDC)
|
||||
|
||||
## OpenClaw Skill Sync
|
||||
|
||||
This repo includes a dedicated OpenClaw skill at:
|
||||
|
||||
- `skills/agent-browser-stealth/SKILL.md`
|
||||
|
||||
Local git `pre-push` hook auto-syncs skills before every push:
|
||||
|
||||
- `.husky/pre-push` -> `pnpm run clawhub:sync`
|
||||
|
||||
Manual sync command (same logic as hook):
|
||||
Use `doctor` to quickly diagnose local CDP, sourceURL sanitization, and tab-group plugin readiness:
|
||||
|
||||
```bash
|
||||
pnpm run clawhub:sync
|
||||
agent-browser doctor
|
||||
agent-browser --json doctor
|
||||
```
|
||||
|
||||
This uses your existing local ClawHub login session (no GitHub secret required).
|
||||
`doctor` checks:
|
||||
|
||||
Temporarily skip auto-sync for one push:
|
||||
- CDP probe status (preferred `:9333` plus common ports)
|
||||
- DevToolsActivePort discovery from local Chrome profiles
|
||||
- CDP Runtime.evaluate sourceURL sanitization probe
|
||||
- Plugin handshake page context check (internal page vs normal `http(s)` page)
|
||||
- Tab-group extension handshake (when currently attached in CDP mode)
|
||||
|
||||
```bash
|
||||
SKIP_CLAWHUB_SYNC=1 git push
|
||||
```
|
||||
## Upstream Compatibility
|
||||
|
||||
This fork intentionally keeps command workflows close to upstream while concentrating custom behavior in stealth, policy, and anti-detection handling.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Generated
+2506
-37
File diff suppressed because it is too large
Load Diff
+22
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.14.0-fork.4"
|
||||
version = "0.16.1-fork.3"
|
||||
edition = "2021"
|
||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||
license = "Apache-2.0"
|
||||
@@ -18,6 +18,18 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dirs = "5.0"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
image = "0.25"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
||||
sha2 = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
similar = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -25,8 +37,17 @@ libc = "0.2"
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_System_Threading", "Win32_Foundation"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let protocol_dir = Path::new("cdp-protocol");
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let out_path = Path::new(&out_dir).join("cdp_generated.rs");
|
||||
|
||||
let browser_path = protocol_dir.join("browser_protocol.json");
|
||||
let js_path = protocol_dir.join("js_protocol.json");
|
||||
|
||||
if !browser_path.exists() && !js_path.exists() {
|
||||
fs::write(
|
||||
&out_path,
|
||||
"// No protocol JSON files found in cdp-protocol/\n",
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut all_domains: Vec<Domain> = Vec::new();
|
||||
|
||||
for path in [&browser_path, &js_path] {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let protocol: ProtocolSpec = match serde_json::from_str(&content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Failed to parse {}: {}", path.display(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
all_domains.extend(protocol.domains);
|
||||
}
|
||||
|
||||
// Collect all known type IDs per domain for cross-domain resolution
|
||||
let mut domain_types: std::collections::HashMap<String, HashSet<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for domain in &all_domains {
|
||||
let mut types = HashSet::new();
|
||||
for td in &domain.types {
|
||||
types.insert(td.id.clone());
|
||||
}
|
||||
domain_types.insert(domain.domain.clone(), types);
|
||||
}
|
||||
|
||||
// Known recursive struct fields that need Box wrapping
|
||||
let recursive_fields: HashSet<(&str, &str, &str)> = [
|
||||
("DOM", "Node", "contentDocument"),
|
||||
("DOM", "Node", "templateContent"),
|
||||
("DOM", "Node", "importedDocument"),
|
||||
("Accessibility", "AXNode", "sources"),
|
||||
("Runtime", "StackTrace", "parent"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
output.push_str("use serde::{Deserialize, Serialize};\n\n");
|
||||
|
||||
for domain in &all_domains {
|
||||
generate_domain(domain, &domain_types, &recursive_fields, &mut output);
|
||||
}
|
||||
|
||||
fs::write(&out_path, &output).unwrap();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ProtocolSpec {
|
||||
domains: Vec<Domain>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Domain {
|
||||
domain: String,
|
||||
#[serde(default)]
|
||||
types: Vec<TypeDef>,
|
||||
#[serde(default)]
|
||||
commands: Vec<Command>,
|
||||
#[serde(default)]
|
||||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct TypeDef {
|
||||
id: String,
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: String,
|
||||
#[serde(default)]
|
||||
properties: Vec<Property>,
|
||||
#[serde(rename = "enum", default)]
|
||||
enum_values: Vec<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Command {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
parameters: Vec<Property>,
|
||||
#[serde(default)]
|
||||
returns: Vec<Property>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Event {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
parameters: Vec<Property>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Property {
|
||||
name: String,
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: Option<String>,
|
||||
#[serde(rename = "$ref", default)]
|
||||
ref_type: Option<String>,
|
||||
#[serde(default)]
|
||||
optional: bool,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
items: Option<Box<ItemType>>,
|
||||
#[serde(rename = "enum", default)]
|
||||
enum_values: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct ItemType {
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: Option<String>,
|
||||
#[serde(rename = "$ref", default)]
|
||||
ref_type: Option<String>,
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut capitalize = true;
|
||||
for c in s.chars() {
|
||||
if c == '_' || c == '-' || c == '.' {
|
||||
capitalize = true;
|
||||
} else if capitalize {
|
||||
result.push(c.to_ascii_uppercase());
|
||||
capitalize = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn to_snake_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
// Only insert underscore at transitions from lowercase to uppercase,
|
||||
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
|
||||
let prev_upper = chars[i - 1].is_uppercase();
|
||||
let next_lower = chars.get(i + 1).map_or(false, |n| n.is_lowercase());
|
||||
if !prev_upper || next_lower {
|
||||
result.push('_');
|
||||
}
|
||||
}
|
||||
result.push(c.to_ascii_lowercase());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve a $ref type reference. Cross-domain refs like "Page.FrameId" become
|
||||
/// `super::cdp_page::FrameId`. Same-domain refs are used directly.
|
||||
fn resolve_ref(
|
||||
r: &str,
|
||||
current_domain: &str,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
) -> String {
|
||||
let parts: Vec<&str> = r.split('.').collect();
|
||||
if parts.len() == 2 {
|
||||
let ref_domain = parts[0];
|
||||
let ref_type = parts[1];
|
||||
if ref_domain == current_domain {
|
||||
to_pascal_case(ref_type)
|
||||
} else {
|
||||
// Check if this type actually exists in the referenced domain
|
||||
if domain_types
|
||||
.get(ref_domain)
|
||||
.map_or(false, |t| t.contains(ref_type))
|
||||
{
|
||||
format!(
|
||||
"super::cdp_{}::{}",
|
||||
to_snake_case(ref_domain),
|
||||
to_pascal_case(ref_type)
|
||||
)
|
||||
} else {
|
||||
// Fall back to serde_json::Value for unknown cross-domain refs
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
to_pascal_case(r)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_type_in_domain(
|
||||
prop: &Property,
|
||||
current_domain: &str,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
) -> String {
|
||||
if let Some(ref r) = prop.ref_type {
|
||||
let type_name = resolve_ref(r, current_domain, domain_types);
|
||||
if prop.optional {
|
||||
format!("Option<{}>", type_name)
|
||||
} else {
|
||||
type_name
|
||||
}
|
||||
} else if let Some(ref t) = prop.type_kind {
|
||||
let base = match t.as_str() {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
"object" => "serde_json::Value".to_string(),
|
||||
"any" => "serde_json::Value".to_string(),
|
||||
"array" => {
|
||||
if let Some(ref items) = prop.items {
|
||||
let inner = if let Some(ref r) = items.ref_type {
|
||||
resolve_ref(r, current_domain, domain_types)
|
||||
} else {
|
||||
match items.type_kind.as_deref().unwrap_or("any") {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
_ => "serde_json::Value".to_string(),
|
||||
}
|
||||
};
|
||||
format!("Vec<{}>", inner)
|
||||
} else {
|
||||
"Vec<serde_json::Value>".to_string()
|
||||
}
|
||||
}
|
||||
_ => "serde_json::Value".to_string(),
|
||||
};
|
||||
if prop.optional {
|
||||
format!("Option<{}>", base)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else if prop.optional {
|
||||
"Option<serde_json::Value>".to_string()
|
||||
} else {
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_rust_keyword(s: &str) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
"type"
|
||||
| "self"
|
||||
| "Self"
|
||||
| "super"
|
||||
| "move"
|
||||
| "ref"
|
||||
| "fn"
|
||||
| "mod"
|
||||
| "use"
|
||||
| "pub"
|
||||
| "let"
|
||||
| "mut"
|
||||
| "const"
|
||||
| "static"
|
||||
| "if"
|
||||
| "else"
|
||||
| "for"
|
||||
| "while"
|
||||
| "loop"
|
||||
| "match"
|
||||
| "return"
|
||||
| "break"
|
||||
| "continue"
|
||||
| "as"
|
||||
| "in"
|
||||
| "impl"
|
||||
| "trait"
|
||||
| "struct"
|
||||
| "enum"
|
||||
| "where"
|
||||
| "async"
|
||||
| "await"
|
||||
| "dyn"
|
||||
| "box"
|
||||
| "yield"
|
||||
| "override"
|
||||
| "crate"
|
||||
| "extern"
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_domain(
|
||||
domain: &Domain,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
recursive_fields: &HashSet<(&str, &str, &str)>,
|
||||
output: &mut String,
|
||||
) {
|
||||
let mod_name = to_snake_case(&domain.domain);
|
||||
output.push_str(&format!(
|
||||
"#[allow(dead_code, non_snake_case, non_camel_case_types, clippy::enum_variant_names)]\npub mod cdp_{} {{\n",
|
||||
mod_name
|
||||
));
|
||||
output.push_str(" use super::*;\n\n");
|
||||
|
||||
for type_def in &domain.types {
|
||||
if !type_def.enum_values.is_empty() {
|
||||
// Deduplicate enum variants (some CDP enums have duplicated PascalCase forms)
|
||||
let mut seen_variants = HashSet::new();
|
||||
output.push_str(" #[derive(Debug, Clone, Serialize, Deserialize)]\n");
|
||||
output.push_str(&format!(" pub enum {} {{\n", type_def.id));
|
||||
for val in &type_def.enum_values {
|
||||
let mut variant = to_pascal_case(val);
|
||||
if variant == "Self" {
|
||||
variant = "SelfValue".to_string();
|
||||
}
|
||||
if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) {
|
||||
variant = format!("V{}", variant);
|
||||
}
|
||||
if seen_variants.insert(variant.clone()) {
|
||||
output.push_str(&format!(
|
||||
" #[serde(rename = \"{}\")]\n {},\n",
|
||||
val, variant
|
||||
));
|
||||
}
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
} else if type_def.type_kind == "object" && !type_def.properties.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {} {{\n", type_def.id));
|
||||
for prop in &type_def.properties {
|
||||
let field_name = to_snake_case(&prop.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let mut rust_type = map_type_in_domain(prop, &domain.domain, domain_types);
|
||||
|
||||
// Wrap recursive fields in Box
|
||||
if recursive_fields.contains(&(
|
||||
domain.domain.as_str(),
|
||||
type_def.id.as_str(),
|
||||
prop.name.as_str(),
|
||||
)) {
|
||||
if rust_type.starts_with("Option<") {
|
||||
let inner = &rust_type[7..rust_type.len() - 1];
|
||||
rust_type = format!("Option<Box<{}>>", inner);
|
||||
} else {
|
||||
rust_type = format!("Box<{}>", rust_type);
|
||||
}
|
||||
}
|
||||
|
||||
if prop.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
} else if type_def.type_kind == "object" && type_def.properties.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" pub type {} = serde_json::Value;\n\n",
|
||||
type_def.id
|
||||
));
|
||||
} else if type_def.type_kind == "array" {
|
||||
output.push_str(&format!(
|
||||
" pub type {} = Vec<serde_json::Value>;\n\n",
|
||||
type_def.id
|
||||
));
|
||||
} else if type_def.type_kind == "string" && type_def.enum_values.is_empty() {
|
||||
output.push_str(&format!(" pub type {} = String;\n\n", type_def.id));
|
||||
} else if type_def.type_kind == "integer" {
|
||||
output.push_str(&format!(" pub type {} = i64;\n\n", type_def.id));
|
||||
} else if type_def.type_kind == "number" {
|
||||
output.push_str(&format!(" pub type {} = f64;\n\n", type_def.id));
|
||||
}
|
||||
}
|
||||
|
||||
for cmd in &domain.commands {
|
||||
let pascal_name = to_pascal_case(&cmd.name);
|
||||
|
||||
if !cmd.parameters.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Params {{\n", pascal_name));
|
||||
for param in &cmd.parameters {
|
||||
let field_name = to_snake_case(¶m.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
|
||||
if param.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
|
||||
if !cmd.returns.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Result {{\n", pascal_name));
|
||||
for ret in &cmd.returns {
|
||||
let field_name = to_snake_case(&ret.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(ret, &domain.domain, domain_types);
|
||||
if ret.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
for event in &domain.events {
|
||||
if !event.parameters.is_empty() {
|
||||
let pascal_name = to_pascal_case(&event.name);
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Event {{\n", pascal_name));
|
||||
for param in &event.parameters {
|
||||
let field_name = to_snake_case(¶m.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
|
||||
if param.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str("}\n\n");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+148
-33
@@ -159,6 +159,8 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|| url_lower.starts_with("about:")
|
||||
|| url_lower.starts_with("data:")
|
||||
|| url_lower.starts_with("file:")
|
||||
|| url_lower.starts_with("chrome-extension://")
|
||||
|| url_lower.starts_with("chrome://")
|
||||
{
|
||||
url.to_string()
|
||||
} else {
|
||||
@@ -182,6 +184,35 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
nav_cmd["iosDevice"] = json!(device);
|
||||
}
|
||||
}
|
||||
if let Some(ref risk_mode) = flags.risk_mode {
|
||||
if matches!(risk_mode.as_str(), "off" | "warn" | "block") {
|
||||
nav_cmd["riskMode"] = json!(risk_mode);
|
||||
} else {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!(
|
||||
"Invalid --risk-mode value: {} (expected off, warn, or block)",
|
||||
risk_mode
|
||||
),
|
||||
usage: "open <url>",
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(ref wait_until) = flags.wait_until {
|
||||
if matches!(
|
||||
wait_until.as_str(),
|
||||
"load" | "domcontentloaded" | "networkidle"
|
||||
) {
|
||||
nav_cmd["waitUntil"] = json!(wait_until);
|
||||
} else {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!(
|
||||
"Invalid --wait-until value: {} (expected load, domcontentloaded, or networkidle)",
|
||||
wait_until
|
||||
),
|
||||
usage: "open <url>",
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(nav_cmd)
|
||||
}
|
||||
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||
@@ -640,6 +671,17 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
// === Close ===
|
||||
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
||||
|
||||
// === Doctor ===
|
||||
"doctor" => {
|
||||
if !rest.is_empty() {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("doctor does not accept arguments: {}", rest.join(" ")),
|
||||
usage: "doctor",
|
||||
});
|
||||
}
|
||||
Ok(json!({ "id": id, "action": "doctor" }))
|
||||
}
|
||||
|
||||
// === Connect (CDP) ===
|
||||
"connect" => {
|
||||
let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -815,6 +857,17 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
}
|
||||
|
||||
// Playwright requires either `url` or a complete `domain`+`path` pair.
|
||||
let has_url = cookie.get("url").is_some();
|
||||
let has_domain = cookie.get("domain").is_some();
|
||||
let has_path = cookie.get("path").is_some();
|
||||
if !has_url && (has_domain != has_path) {
|
||||
return Err(ParseError::MissingArguments {
|
||||
context: "cookies set".to_string(),
|
||||
usage: "When not using --url, you must provide both --domain <domain> and --path <path>",
|
||||
});
|
||||
}
|
||||
|
||||
Ok(json!({ "id": id, "action": "cookies_set", "cookies": [cookie] }))
|
||||
}
|
||||
"clear" => Ok(json!({ "id": id, "action": "cookies_clear" })),
|
||||
@@ -970,8 +1023,8 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
let url = rest.get(2);
|
||||
let mut cmd = json!({ "id": id, "action": "recording_start", "path": path });
|
||||
if let Some(u) = url {
|
||||
// Add https:// prefix if needed
|
||||
let url_str = if u.starts_with("http") {
|
||||
// Add https:// prefix if needed (preserve special schemes)
|
||||
let url_str = if u.starts_with("http") || u.contains("://") {
|
||||
u.to_string()
|
||||
} else {
|
||||
format!("https://{}", u)
|
||||
@@ -990,8 +1043,8 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
let url = rest.get(2);
|
||||
let mut cmd = json!({ "id": id, "action": "recording_restart", "path": path });
|
||||
if let Some(u) = url {
|
||||
// Add https:// prefix if needed
|
||||
let url_str = if u.starts_with("http") {
|
||||
// Add https:// prefix if needed (preserve special schemes)
|
||||
let url_str = if u.starts_with("http") || u.contains("://") {
|
||||
u.to_string()
|
||||
} else {
|
||||
format!("https://{}", u)
|
||||
@@ -2028,6 +2081,12 @@ mod tests {
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
tab_group: None,
|
||||
tab_group_plugin_id: None,
|
||||
risk_mode: None,
|
||||
wait_until: None,
|
||||
cli_tab_group: false,
|
||||
cli_tab_group_plugin_id: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2083,28 +2142,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookies_set_with_domain() {
|
||||
let cmd = parse_command(
|
||||
fn test_cookies_set_with_domain_requires_path() {
|
||||
let result = parse_command(
|
||||
&args("cookies set mycookie myvalue --domain example.com"),
|
||||
&default_flags(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookies_set_with_path_requires_domain() {
|
||||
let result = parse_command(
|
||||
&args("cookies set mycookie myvalue --path /api"),
|
||||
&default_flags(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookies_set_with_domain_and_path() {
|
||||
let cmd = parse_command(
|
||||
&args("cookies set mycookie myvalue --domain example.com --path /api"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "cookies_set");
|
||||
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
|
||||
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
|
||||
assert_eq!(cmd["cookies"][0]["domain"], "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookies_set_with_path() {
|
||||
let cmd = parse_command(
|
||||
&args("cookies set mycookie myvalue --path /api"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "cookies_set");
|
||||
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
|
||||
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
|
||||
assert_eq!(cmd["cookies"][0]["path"], "/api");
|
||||
}
|
||||
|
||||
@@ -2293,6 +2358,22 @@ mod tests {
|
||||
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_risk_mode() {
|
||||
let mut flags = default_flags();
|
||||
flags.risk_mode = Some("block".to_string());
|
||||
let cmd = parse_command(&args("open https://example.com"), &flags).unwrap();
|
||||
assert_eq!(cmd["riskMode"], "block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_wait_until() {
|
||||
let mut flags = default_flags();
|
||||
flags.wait_until = Some("domcontentloaded".to_string());
|
||||
let cmd = parse_command(&args("open https://example.com"), &flags).unwrap();
|
||||
assert_eq!(cmd["waitUntil"], "domcontentloaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_multiple_headers() {
|
||||
let mut flags = default_flags();
|
||||
@@ -2323,6 +2404,24 @@ mod tests {
|
||||
assert!(msg.contains("Invalid JSON for --headers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_chrome_extension_url() {
|
||||
let cmd = parse_command(
|
||||
&args("open chrome-extension://abcdefghijklmnop/popup.html"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "navigate");
|
||||
assert_eq!(cmd["url"], "chrome-extension://abcdefghijklmnop/popup.html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_chrome_url() {
|
||||
let cmd = parse_command(&args("open chrome://extensions"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "navigate");
|
||||
assert_eq!(cmd["url"], "chrome://extensions");
|
||||
}
|
||||
|
||||
// === Set Headers Tests ===
|
||||
|
||||
#[test]
|
||||
@@ -2701,6 +2800,18 @@ mod tests {
|
||||
assert_eq!(cmd["url"], "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_start_with_chrome_extension_url() {
|
||||
let cmd = parse_command(
|
||||
&args("record start demo.webm chrome-extension://abcdef/popup.html"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "recording_start");
|
||||
assert_eq!(cmd["path"], "demo.webm");
|
||||
assert_eq!(cmd["url"], "chrome-extension://abcdef/popup.html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_start_missing_path() {
|
||||
let result = parse_command(&args("record start"), &default_flags());
|
||||
@@ -2884,6 +2995,21 @@ mod tests {
|
||||
assert!(err.format().contains("Invalid base64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doctor() {
|
||||
let cmd = parse_command(&args("doctor"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "doctor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doctor_rejects_arguments() {
|
||||
let result = parse_command(&args("doctor extra"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, ParseError::InvalidValue { .. }));
|
||||
assert!(err.format().contains("doctor does not accept arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_command() {
|
||||
let result = parse_command(&args("unknowncommand"), &default_flags());
|
||||
@@ -3638,11 +3764,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scroll_with_selector_short_flag() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll left 100 -s .sidebar"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd = parse_command(&args("scroll left 100 -s .sidebar"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "left");
|
||||
assert_eq!(cmd["amount"], 100);
|
||||
@@ -3651,11 +3773,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_before_positional() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll --selector .panel down 400"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd =
|
||||
parse_command(&args("scroll --selector .panel down 400"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 400);
|
||||
@@ -3664,11 +3783,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_only() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll --selector .content"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd = parse_command(&args("scroll --selector .content"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 300);
|
||||
|
||||
+23
-32
@@ -118,6 +118,12 @@ fn get_pid_path(session: &str) -> PathBuf {
|
||||
|
||||
/// Clean up stale socket and PID files for a session
|
||||
fn cleanup_stale_files(session: &str) {
|
||||
// Never delete files for a live daemon. A missing PID file can happen in
|
||||
// race scenarios, but the socket is authoritative for liveness.
|
||||
if daemon_ready(session) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pid_path = get_pid_path(session);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
|
||||
@@ -150,36 +156,6 @@ fn get_port_for_session(session: &str) -> u16 {
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<i32>() {
|
||||
unsafe {
|
||||
return libc::kill(pid, 0) == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn daemon_ready(session: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -221,9 +197,12 @@ pub fn ensure_daemon(
|
||||
session_name: Option<&str>,
|
||||
debug: bool,
|
||||
download_path: Option<&str>,
|
||||
tab_group: Option<&str>,
|
||||
tab_group_plugin_id: Option<&str>,
|
||||
) -> Result<DaemonResult, String> {
|
||||
// Check if daemon is running AND responsive
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
// Socket readiness is the source of truth for a usable daemon.
|
||||
// PID files can be missing/stale under concurrent start/stop races.
|
||||
if daemon_ready(session) {
|
||||
// Double-check it's actually responsive by waiting and checking again
|
||||
// This handles the race condition where daemon is shutting down
|
||||
// (daemon has a 100ms shutdown delay, so we wait longer)
|
||||
@@ -368,6 +347,12 @@ pub fn ensure_daemon(
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
if let Some(tg) = tab_group {
|
||||
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
|
||||
}
|
||||
if let Some(plugin_id) = tab_group_plugin_id {
|
||||
cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id);
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
@@ -455,6 +440,12 @@ pub fn ensure_daemon(
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
if let Some(tg) = tab_group {
|
||||
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
|
||||
}
|
||||
if let Some(plugin_id) = tab_group_plugin_id {
|
||||
cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id);
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
|
||||
+260
-4
@@ -1,4 +1,4 @@
|
||||
use crate::color;
|
||||
use crate::{color, validation};
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@@ -7,6 +7,8 @@ use std::path::{Path, PathBuf};
|
||||
const CONFIG_DIR: &str = ".agent-browser";
|
||||
const CONFIG_FILENAME: &str = "config.json";
|
||||
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
|
||||
const DEFAULT_TAB_GROUP: &str = "Agent Browser Stealth";
|
||||
const DEFAULT_TAB_GROUP_PLUGIN_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -34,6 +36,10 @@ pub struct Config {
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub tab_group: Option<String>,
|
||||
pub tab_group_plugin_id: Option<String>,
|
||||
pub risk_mode: Option<String>,
|
||||
pub wait_until: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -68,6 +74,10 @@ impl Config {
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
download_path: other.download_path.or(self.download_path),
|
||||
tab_group: other.tab_group.or(self.tab_group),
|
||||
tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id),
|
||||
risk_mode: other.risk_mode.or(self.risk_mode),
|
||||
wait_until: other.wait_until.or(self.wait_until),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +144,10 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--color-scheme",
|
||||
"--channel",
|
||||
"--download-path",
|
||||
"--tab-group",
|
||||
"--tab-group-plugin-id",
|
||||
"--risk-mode",
|
||||
"--wait-until",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -200,10 +214,18 @@ pub struct Flags {
|
||||
pub allow_file_access: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub session_name: Option<String>,
|
||||
pub session_name: Option<String>, // Defaults to --session when unset
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub tab_group: Option<String>,
|
||||
pub tab_group_plugin_id: Option<String>,
|
||||
/// How verification/captcha detections are handled on navigation:
|
||||
/// `off` (disable), `warn` (retry and warn), `block` (fail fast).
|
||||
pub risk_mode: Option<String>,
|
||||
/// Navigation wait strategy passed to navigate/open commands:
|
||||
/// `load`, `domcontentloaded`, or `networkidle`.
|
||||
pub wait_until: Option<String>,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
@@ -217,6 +239,8 @@ pub struct Flags {
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_tab_group: bool,
|
||||
pub cli_tab_group_plugin_id: bool,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -283,8 +307,22 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
|
||||
.ok()
|
||||
.or(config.color_scheme),
|
||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
|
||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH")
|
||||
.ok()
|
||||
.or(config.download_path),
|
||||
tab_group: env::var("AGENT_BROWSER_TAB_GROUP")
|
||||
.ok()
|
||||
.or(config.tab_group)
|
||||
.or_else(|| Some(DEFAULT_TAB_GROUP.to_string())),
|
||||
tab_group_plugin_id: env::var("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID")
|
||||
.ok()
|
||||
.or(config.tab_group_plugin_id)
|
||||
.or_else(|| Some(DEFAULT_TAB_GROUP_PLUGIN_ID.to_string())),
|
||||
risk_mode: env::var("AGENT_BROWSER_RISK_MODE")
|
||||
.ok()
|
||||
.or(config.risk_mode)
|
||||
.map(|s| s.to_ascii_lowercase()),
|
||||
wait_until: config.wait_until.map(|s| s.to_ascii_lowercase()),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_state: false,
|
||||
@@ -295,6 +333,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_tab_group: false,
|
||||
cli_tab_group_plugin_id: false,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -456,6 +496,32 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--tab-group" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.tab_group = Some(s.clone());
|
||||
flags.cli_tab_group = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--tab-group-plugin-id" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.tab_group_plugin_id = Some(s.clone());
|
||||
flags.cli_tab_group_plugin_id = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--risk-mode" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.risk_mode = Some(s.to_ascii_lowercase());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--wait-until" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.wait_until = Some(s.to_ascii_lowercase());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--config" => {
|
||||
// Already handled by load_config(); skip the value
|
||||
i += 1;
|
||||
@@ -464,6 +530,18 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Keep auth/state continuity stable by default: if no explicit --session-name
|
||||
// is provided, derive it from --session (or fall back to "default" when invalid).
|
||||
if flags.session_name.is_none() {
|
||||
let derived = if validation::is_valid_session_name(&flags.session) {
|
||||
flags.session.clone()
|
||||
} else {
|
||||
"default".to_string()
|
||||
};
|
||||
flags.session_name = Some(derived);
|
||||
}
|
||||
|
||||
flags
|
||||
}
|
||||
|
||||
@@ -500,6 +578,10 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--download-path",
|
||||
"--tab-group",
|
||||
"--tab-group-plugin-id",
|
||||
"--risk-mode",
|
||||
"--wait-until",
|
||||
"--config",
|
||||
];
|
||||
|
||||
@@ -534,6 +616,36 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => env::set_var(name, v),
|
||||
None => env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn args(s: &str) -> Vec<String> {
|
||||
s.split_whitespace().map(String::from).collect()
|
||||
@@ -647,6 +759,19 @@ mod tests {
|
||||
));
|
||||
assert_eq!(flags.session, "test");
|
||||
assert_eq!(flags.executable_path, Some("/custom/chrome".to_string()));
|
||||
assert_eq!(flags.session_name.as_deref(), Some("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_name_defaults_to_session_when_not_provided() {
|
||||
let flags = parse_flags(&args("--session my-session snapshot"));
|
||||
assert_eq!(flags.session_name.as_deref(), Some("my-session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_session_falls_back_to_default_session_name() {
|
||||
let flags = parse_flags(&args("--session bad/session snapshot"));
|
||||
assert_eq!(flags.session_name.as_deref(), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -697,6 +822,128 @@ mod tests {
|
||||
assert!(!flags.cli_download_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_tab_group_is_enabled() {
|
||||
let flags = parse_flags(&args("snapshot"));
|
||||
assert_eq!(flags.tab_group.as_deref(), Some(DEFAULT_TAB_GROUP));
|
||||
assert!(!flags.cli_tab_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_tab_group_plugin_id_is_enabled() {
|
||||
let flags = parse_flags(&args("snapshot"));
|
||||
assert_eq!(
|
||||
flags.tab_group_plugin_id.as_deref(),
|
||||
Some(DEFAULT_TAB_GROUP_PLUGIN_ID)
|
||||
);
|
||||
assert!(!flags.cli_tab_group_plugin_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_group_flag() {
|
||||
let input = vec![
|
||||
"--tab-group".to_string(),
|
||||
"Agent Browser Stealth".to_string(),
|
||||
"snapshot".to_string(),
|
||||
];
|
||||
let flags = parse_flags(&input);
|
||||
assert_eq!(flags.tab_group.as_deref(), Some("Agent Browser Stealth"));
|
||||
assert!(flags.cli_tab_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_tab_group() {
|
||||
let cleaned = clean_args(&args("--tab-group AgentGroup open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_group_plugin_id_flag() {
|
||||
let input = vec![
|
||||
"--tab-group-plugin-id".to_string(),
|
||||
"cli-plugin-id".to_string(),
|
||||
"snapshot".to_string(),
|
||||
];
|
||||
let flags = parse_flags(&input);
|
||||
assert_eq!(flags.tab_group_plugin_id.as_deref(), Some("cli-plugin-id"));
|
||||
assert!(flags.cli_tab_group_plugin_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_tab_group_plugin_id() {
|
||||
let cleaned = clean_args(&args(
|
||||
"--tab-group-plugin-id cli-plugin-id open example.com",
|
||||
));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_group_plugin_id_precedence_config_env_cli() {
|
||||
use std::io::Write;
|
||||
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_TAB_GROUP_PLUGIN_ID"]);
|
||||
|
||||
let dir = std::env::temp_dir().join("ab-test-plugin-id-precedence");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let config_path = dir.join("config.json");
|
||||
let mut f = fs::File::create(&config_path).unwrap();
|
||||
writeln!(f, r#"{{"tabGroupPluginId":"config-plugin-id"}}"#).unwrap();
|
||||
|
||||
env::set_var("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", "env-plugin-id");
|
||||
|
||||
let env_args = vec![
|
||||
"--config".to_string(),
|
||||
config_path.to_string_lossy().to_string(),
|
||||
"snapshot".to_string(),
|
||||
];
|
||||
let flags_from_env = parse_flags(&env_args);
|
||||
assert_eq!(
|
||||
flags_from_env.tab_group_plugin_id.as_deref(),
|
||||
Some("env-plugin-id")
|
||||
);
|
||||
|
||||
let cli_args = vec![
|
||||
"--config".to_string(),
|
||||
config_path.to_string_lossy().to_string(),
|
||||
"--tab-group-plugin-id".to_string(),
|
||||
"cli-plugin-id".to_string(),
|
||||
"snapshot".to_string(),
|
||||
];
|
||||
let flags_from_cli = parse_flags(&cli_args);
|
||||
assert_eq!(
|
||||
flags_from_cli.tab_group_plugin_id.as_deref(),
|
||||
Some("cli-plugin-id")
|
||||
);
|
||||
assert!(flags_from_cli.cli_tab_group_plugin_id);
|
||||
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_risk_mode_flag() {
|
||||
let flags = parse_flags(&args("--risk-mode block open example.com"));
|
||||
assert_eq!(flags.risk_mode.as_deref(), Some("block"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_risk_mode() {
|
||||
let cleaned = clean_args(&args("--risk-mode warn open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wait_until_flag() {
|
||||
let flags = parse_flags(&args("--wait-until domcontentloaded open example.com"));
|
||||
assert_eq!(flags.wait_until.as_deref(), Some("domcontentloaded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_wait_until() {
|
||||
let cleaned = clean_args(&args("--wait-until networkidle open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_multiple_flags_tracking() {
|
||||
let flags = parse_flags(&args(
|
||||
@@ -732,7 +979,10 @@ mod tests {
|
||||
"allowFileAccess": true,
|
||||
"cdp": "9222",
|
||||
"autoConnect": true,
|
||||
"headers": "{\"Auth\":\"token\"}"
|
||||
"headers": "{\"Auth\":\"token\"}",
|
||||
"tabGroup": "Agent Browser Stealth",
|
||||
"tabGroupPluginId": "tab-group-plugin-id",
|
||||
"riskMode": "block"
|
||||
}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
@@ -758,6 +1008,12 @@ mod tests {
|
||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||
assert_eq!(config.auto_connect, Some(true));
|
||||
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
|
||||
assert_eq!(config.tab_group.as_deref(), Some("Agent Browser Stealth"));
|
||||
assert_eq!(
|
||||
config.tab_group_plugin_id.as_deref(),
|
||||
Some("tab-group-plugin-id")
|
||||
);
|
||||
assert_eq!(config.risk_mode.as_deref(), Some("block"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+90
-10
@@ -72,7 +72,11 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||
#[cfg(unix)]
|
||||
let running = unsafe { libc::kill(pid as i32, 0) == 0 };
|
||||
let running = unsafe {
|
||||
libc::kill(pid as i32, 0) == 0
|
||||
|| std::io::Error::last_os_error().raw_os_error()
|
||||
!= Some(libc::ESRCH)
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let running = unsafe {
|
||||
let handle =
|
||||
@@ -153,6 +157,21 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref risk_mode) = flags.risk_mode {
|
||||
if !matches!(risk_mode.as_str(), "off" | "warn" | "block") {
|
||||
let msg = format!(
|
||||
"Invalid --risk-mode value: {} (expected off, warn, or block)",
|
||||
risk_mode
|
||||
);
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if args.iter().any(|a| a == "--profile") {
|
||||
let msg =
|
||||
"Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence.";
|
||||
@@ -268,6 +287,8 @@ fn main() {
|
||||
flags.session_name.as_deref(),
|
||||
flags.debug,
|
||||
flags.download_path.as_deref(),
|
||||
flags.tab_group.as_deref(),
|
||||
flags.tab_group_plugin_id.as_deref(),
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
@@ -319,6 +340,10 @@ fn main() {
|
||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||
flags.cli_download_path.then_some("--download-path"),
|
||||
flags.cli_tab_group.then_some("--tab-group"),
|
||||
flags
|
||||
.cli_tab_group_plugin_id
|
||||
.then_some("--tab-group-plugin-id"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -384,6 +409,8 @@ fn main() {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let mut attached_to_existing_browser = false;
|
||||
|
||||
// Auto-connect to existing browser
|
||||
if flags.auto_connect {
|
||||
let mut launch_cmd = json!({
|
||||
@@ -403,6 +430,12 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
@@ -421,6 +454,8 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
attached_to_existing_browser = true;
|
||||
}
|
||||
|
||||
// Connect via CDP if --cdp flag is set
|
||||
@@ -493,6 +528,12 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
@@ -511,6 +552,8 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
attached_to_existing_browser = true;
|
||||
}
|
||||
|
||||
// Launch with cloud provider if -p flag is set
|
||||
@@ -524,6 +567,12 @@ fn main() {
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) => {
|
||||
@@ -538,7 +587,6 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
}
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
@@ -552,8 +600,8 @@ fn main() {
|
||||
}
|
||||
|
||||
// Project policy: when no explicit connection mode is provided,
|
||||
// commands must attach to an existing browser on CDP :9333.
|
||||
// If unavailable, fail fast instead of launching a managed browser.
|
||||
// commands should attach to an existing browser.
|
||||
// Try CDP :9333 first, then fall back to auto-connect discovery.
|
||||
let can_try_default_cdp = flags.cdp.is_none()
|
||||
&& !flags.auto_connect
|
||||
&& flags.provider.is_none()
|
||||
@@ -566,7 +614,6 @@ fn main() {
|
||||
&& !flags.allow_file_access
|
||||
&& flags.extensions.is_empty();
|
||||
|
||||
let mut launched_via_default_cdp = false;
|
||||
if can_try_default_cdp {
|
||||
let mut launch_cmd = json!({
|
||||
"id": gen_id(),
|
||||
@@ -577,13 +624,41 @@ fn main() {
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
if let Ok(resp) = send_command(launch_cmd, &flags.session) {
|
||||
launched_via_default_cdp = resp.success;
|
||||
attached_to_existing_browser = resp.success;
|
||||
}
|
||||
|
||||
if !attached_to_existing_browser {
|
||||
let mut auto_connect_cmd = json!({
|
||||
"id": gen_id(),
|
||||
"action": "launch",
|
||||
"autoConnect": true
|
||||
});
|
||||
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
auto_connect_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
auto_connect_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
auto_connect_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
if let Ok(resp) = send_command(auto_connect_cmd, &flags.session) {
|
||||
attached_to_existing_browser = resp.success;
|
||||
}
|
||||
}
|
||||
}
|
||||
if can_try_default_cdp && !launched_via_default_cdp {
|
||||
let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333. Start your browser with remote debugging on port 9333, or pass --cdp <port|url>.";
|
||||
if can_try_default_cdp && !attached_to_existing_browser {
|
||||
let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed. Start Chrome with remote debugging (for example, --remote-debugging-port=9333), or pass --cdp <port|url>.";
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
@@ -606,7 +681,7 @@ fn main() {
|
||||
|| flags.download_path.is_some())
|
||||
&& flags.cdp.is_none()
|
||||
&& flags.provider.is_none()
|
||||
&& !launched_via_default_cdp
|
||||
&& !attached_to_existing_browser
|
||||
{
|
||||
let mut launch_cmd = json!({
|
||||
"id": gen_id(),
|
||||
@@ -668,6 +743,12 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) => {
|
||||
@@ -683,7 +764,6 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
}
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthProfile {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub submit_selector: Option<String>,
|
||||
}
|
||||
|
||||
// Keep legacy Credential alias for backward compatibility
|
||||
pub type Credential = AuthProfile;
|
||||
|
||||
fn validate_profile_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid profile name '{}'. Must match /^[a-zA-Z0-9_-]+$/",
|
||||
name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_auth_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("auth")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("auth")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile_path(name: &str) -> PathBuf {
|
||||
get_auth_dir().join(format!("{}.json", name))
|
||||
}
|
||||
|
||||
fn derive_encryption_key() -> Vec<u8> {
|
||||
let hostname = std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("COMPUTERNAME"))
|
||||
.unwrap_or_else(|_| {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut buf = [0u8; 256];
|
||||
let len = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||
if len == 0 {
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..end]).to_string()
|
||||
} else {
|
||||
"unknown-host".to_string()
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
"unknown-host".to_string()
|
||||
}
|
||||
});
|
||||
let username = std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "unknown-user".to_string());
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("agent-browser:{}:{}", hostname, username).as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<Vec<u8>, String> {
|
||||
let key = derive_encryption_key();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
|
||||
|
||||
let plaintext = serde_json::to_string(profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&nonce), plaintext.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
if data.len() < 13 {
|
||||
return Err("Encrypted data too short".to_string());
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
|
||||
let key = derive_encryption_key();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e))
|
||||
}
|
||||
|
||||
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
|
||||
let dir = get_auth_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
|
||||
let encrypted = encrypt_profile(profile)?;
|
||||
let path = get_profile_path(&profile.name);
|
||||
fs::write(&path, &encrypted).map_err(|e| format!("Failed to write profile: {}", e))
|
||||
}
|
||||
|
||||
fn load_profile(name: &str) -> Result<AuthProfile, String> {
|
||||
let path = get_profile_path(name);
|
||||
if !path.exists() {
|
||||
return Err(format!("Auth profile '{}' not found", name));
|
||||
}
|
||||
let data = fs::read(&path).map_err(|e| format!("Failed to read profile: {}", e))?;
|
||||
decrypt_profile(&data)
|
||||
}
|
||||
|
||||
pub fn credentials_set(
|
||||
name: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
url: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = AuthProfile {
|
||||
name: name.to_string(),
|
||||
url: url.unwrap_or("").to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
}
|
||||
|
||||
pub fn auth_save(
|
||||
name: &str,
|
||||
url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
username_selector: Option<&str>,
|
||||
password_selector: Option<&str>,
|
||||
submit_selector: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = AuthProfile {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
username_selector: username_selector.map(String::from),
|
||||
password_selector: password_selector.map(String::from),
|
||||
submit_selector: submit_selector.map(String::from),
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
}
|
||||
|
||||
pub fn credentials_get(name: &str) -> Result<Value, String> {
|
||||
let profile = load_profile(name)?;
|
||||
Ok(json!({
|
||||
"name": profile.name,
|
||||
"username": profile.username,
|
||||
"url": profile.url,
|
||||
"hasPassword": true,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn credentials_get_full(name: &str) -> Result<AuthProfile, String> {
|
||||
load_profile(name)
|
||||
}
|
||||
|
||||
pub fn credentials_delete(name: &str) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let path = get_profile_path(name);
|
||||
if !path.exists() {
|
||||
return Err(format!("Auth profile '{}' not found", name));
|
||||
}
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to delete profile: {}", e))?;
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
pub fn credentials_list() -> Result<Value, String> {
|
||||
let dir = get_auth_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "profiles": [] }));
|
||||
}
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
match load_profile(&name) {
|
||||
Ok(profile) => {
|
||||
profiles.push(json!({
|
||||
"name": profile.name,
|
||||
"username": profile.username,
|
||||
"url": profile.url,
|
||||
}));
|
||||
}
|
||||
Err(_) => {
|
||||
profiles.push(json!({
|
||||
"name": name,
|
||||
"error": "Failed to decrypt",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "profiles": profiles }))
|
||||
}
|
||||
|
||||
pub fn auth_show(name: &str) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = load_profile(name)?;
|
||||
Ok(json!({
|
||||
"profile": {
|
||||
"name": profile.name,
|
||||
"url": profile.url,
|
||||
"username": profile.username,
|
||||
"usernameSelector": profile.username_selector,
|
||||
"passwordSelector": profile.password_selector,
|
||||
"submitSelector": profile.submit_selector,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_profile_name() {
|
||||
assert!(validate_profile_name("github").is_ok());
|
||||
assert!(validate_profile_name("my-app").is_ok());
|
||||
assert!(validate_profile_name("test_123").is_ok());
|
||||
assert!(validate_profile_name("").is_err());
|
||||
assert!(validate_profile_name("has space").is_err());
|
||||
assert!(validate_profile_name("../evil").is_err());
|
||||
assert!(validate_profile_name("foo/bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_profile_serialization() {
|
||||
let profile = AuthProfile {
|
||||
name: "test".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: Some("button[type=submit]".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&profile).unwrap();
|
||||
let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, "test");
|
||||
assert_eq!(
|
||||
parsed.submit_selector,
|
||||
Some("button[type=submit]".to_string())
|
||||
);
|
||||
assert!(parsed.username_selector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let profile = AuthProfile {
|
||||
name: "roundtrip".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "s3cret!".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(&encrypted).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_encryption_key_is_stable() {
|
||||
let k1 = derive_encryption_key();
|
||||
let k2 = derive_encryption_key();
|
||||
assert_eq!(k1, k2);
|
||||
assert_eq!(k1.len(), 32);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,635 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::types::BrowserVersionInfo;
|
||||
|
||||
pub struct ChromeProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
}
|
||||
|
||||
impl ChromeProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChromeProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LaunchOptions {
|
||||
pub headless: bool,
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub proxy_bypass: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub args: Vec<String>,
|
||||
pub allow_file_access: bool,
|
||||
pub extensions: Option<Vec<String>>,
|
||||
pub storage_state: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for LaunchOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
headless: true,
|
||||
executable_path: None,
|
||||
proxy: None,
|
||||
proxy_bypass: None,
|
||||
profile: None,
|
||||
args: Vec::new(),
|
||||
allow_file_access: false,
|
||||
extensions: None,
|
||||
storage_state: None,
|
||||
user_agent: None,
|
||||
ignore_https_errors: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
let chrome_path = match &options.executable_path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => {
|
||||
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
|
||||
}
|
||||
};
|
||||
|
||||
let mut args = vec![
|
||||
"--remote-debugging-port=0".to_string(),
|
||||
"--no-first-run".to_string(),
|
||||
"--no-default-browser-check".to_string(),
|
||||
"--disable-background-networking".to_string(),
|
||||
"--disable-backgrounding-occluded-windows".to_string(),
|
||||
"--disable-component-update".to_string(),
|
||||
"--disable-default-apps".to_string(),
|
||||
"--disable-hang-monitor".to_string(),
|
||||
"--disable-popup-blocking".to_string(),
|
||||
"--disable-prompt-on-repost".to_string(),
|
||||
"--disable-sync".to_string(),
|
||||
"--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
|
||||
"--metrics-recording-only".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
];
|
||||
|
||||
if options.headless {
|
||||
args.push("--headless=new".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref proxy) = options.proxy {
|
||||
args.push(format!("--proxy-server={}", proxy));
|
||||
}
|
||||
|
||||
if let Some(ref bypass) = options.proxy_bypass {
|
||||
args.push(format!("--proxy-bypass-list={}", bypass));
|
||||
}
|
||||
|
||||
if let Some(ref profile) = options.profile {
|
||||
let expanded = expand_tilde(profile);
|
||||
args.push(format!("--user-data-dir={}", expanded));
|
||||
}
|
||||
|
||||
if options.allow_file_access {
|
||||
args.push("--allow-file-access-from-files".to_string());
|
||||
args.push("--allow-file-access".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref exts) = options.extensions {
|
||||
if !exts.is_empty() {
|
||||
let ext_list = exts.join(",");
|
||||
args.push(format!("--load-extension={}", ext_list));
|
||||
args.push(format!("--disable-extensions-except={}", ext_list));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user args set window size (skip viewport override)
|
||||
let has_window_size = options
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
|
||||
|
||||
if !has_window_size && options.headless {
|
||||
args.push("--window-size=1280,720".to_string());
|
||||
}
|
||||
|
||||
args.extend(options.args.iter().cloned());
|
||||
|
||||
if should_disable_sandbox(&args) {
|
||||
args.push("--no-sandbox".to_string());
|
||||
}
|
||||
|
||||
let mut child = Command::new(&chrome_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Chrome at {:?}: {}", chrome_path, e))?;
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or("Failed to capture Chrome stderr")?;
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
let ws_url = wait_for_ws_url(reader)?;
|
||||
|
||||
Ok(ChromeProcess { child, ws_url })
|
||||
}
|
||||
|
||||
fn wait_for_ws_url(reader: BufReader<std::process::ChildStderr>) -> Result<String, String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
let prefix = "DevTools listening on ";
|
||||
let mut stderr_lines: Vec<String> = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err(chrome_launch_error(
|
||||
"Timeout waiting for Chrome DevTools URL",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
let line = line.map_err(|e| format!("Failed to read Chrome stderr: {}", e))?;
|
||||
if let Some(url) = line.strip_prefix(prefix) {
|
||||
return Ok(url.trim().to_string());
|
||||
}
|
||||
stderr_lines.push(line);
|
||||
}
|
||||
|
||||
Err(chrome_launch_error(
|
||||
"Chrome exited before providing DevTools URL",
|
||||
&stderr_lines,
|
||||
))
|
||||
}
|
||||
|
||||
fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
|
||||
let relevant: Vec<&String> = stderr_lines
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
let lower = l.to_lowercase();
|
||||
lower.contains("error")
|
||||
|| lower.contains("fatal")
|
||||
|| lower.contains("sandbox")
|
||||
|| lower.contains("namespace")
|
||||
|| lower.contains("permission")
|
||||
|| lower.contains("cannot")
|
||||
|| lower.contains("failed")
|
||||
|| lower.contains("abort")
|
||||
})
|
||||
.collect();
|
||||
|
||||
if relevant.is_empty() {
|
||||
if stderr_lines.is_empty() {
|
||||
return format!("{} (no stderr output from Chrome)", message);
|
||||
}
|
||||
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
|
||||
return format!(
|
||||
"{}\nChrome stderr (last {} lines):\n {}",
|
||||
message,
|
||||
last_lines.len(),
|
||||
last_lines
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
let hint = if relevant.iter().any(|l| {
|
||||
let lower = l.to_lowercase();
|
||||
lower.contains("sandbox") || lower.contains("namespace")
|
||||
}) {
|
||||
"\nHint: try --args \"--no-sandbox\" (required in containers, VMs, and some Linux setups)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}\nChrome stderr:\n {}{}",
|
||||
message,
|
||||
relevant
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n "),
|
||||
hint
|
||||
)
|
||||
}
|
||||
|
||||
pub fn find_chrome() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let candidates = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
];
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = find_playwright_chromium() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let candidates = [
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium-browser",
|
||||
"chromium",
|
||||
];
|
||||
for name in &candidates {
|
||||
if let Ok(output) = Command::new("which").arg(name).output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = find_playwright_chromium() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let candidates = [
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
];
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
let p = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
|
||||
let url = format!("http://127.0.0.1:{}/json/version", port);
|
||||
|
||||
let body = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
reqwest_get_string(&url).await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to CDP on port {}", port))?
|
||||
.map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?;
|
||||
|
||||
let info: BrowserVersionInfo = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Invalid /json/version response: {}", e))?;
|
||||
|
||||
info.web_socket_debugger_url
|
||||
.ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port))
|
||||
}
|
||||
|
||||
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||
let client = tokio::net::TcpStream::connect(
|
||||
url.strip_prefix("http://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1:9222"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let path = url
|
||||
.find('/')
|
||||
.and_then(|i| url[i..].find('/').map(|j| &url[i + j..]))
|
||||
.unwrap_or("/json/version");
|
||||
|
||||
let host = url
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1");
|
||||
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
||||
path, host
|
||||
);
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut client = client;
|
||||
client
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
client
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
let body = response_str
|
||||
.split("\r\n\r\n")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
|
||||
let path = user_data_dir.join("DevToolsActivePort");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
let mut lines = content.lines();
|
||||
let port: u16 = lines.next()?.trim().parse().ok()?;
|
||||
let ws_path = lines
|
||||
.next()
|
||||
.unwrap_or("/devtools/browser")
|
||||
.trim()
|
||||
.to_string();
|
||||
Some((port, ws_path))
|
||||
}
|
||||
|
||||
pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||
let user_data_dirs = get_chrome_user_data_dirs();
|
||||
|
||||
for dir in &user_data_dirs {
|
||||
if let Some((port, ws_path)) = read_devtools_active_port(dir) {
|
||||
// Try HTTP endpoint first (pre-M144)
|
||||
if let Ok(ws_url) = discover_cdp_url(port).await {
|
||||
return Ok(ws_url);
|
||||
}
|
||||
// M144+: direct WebSocket
|
||||
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
|
||||
return Ok(ws_url);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: probe common ports
|
||||
for port in [9222u16, 9229] {
|
||||
if let Ok(ws_url) = discover_cdp_url(port).await {
|
||||
return Ok(ws_url);
|
||||
}
|
||||
}
|
||||
|
||||
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
|
||||
}
|
||||
|
||||
fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let base = home.join("Library/Application Support");
|
||||
for name in ["Google/Chrome", "Google/Chrome Canary", "Chromium"] {
|
||||
dirs.push(base.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let config = home.join(".config");
|
||||
for name in ["google-chrome", "google-chrome-unstable", "chromium"] {
|
||||
dirs.push(config.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
let base = PathBuf::from(local);
|
||||
for name in [
|
||||
r"Google\Chrome\User Data",
|
||||
r"Google\Chrome SxS\User Data",
|
||||
r"Chromium\User Data",
|
||||
] {
|
||||
dirs.push(base.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Returns true if Chrome's sandbox should be disabled because the environment
|
||||
/// doesn't support it (containers, VMs, running as root).
|
||||
fn should_disable_sandbox(existing_args: &[String]) -> bool {
|
||||
if existing_args.iter().any(|a| a == "--no-sandbox") {
|
||||
return false; // already set by user
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Root user -- standard container default, Chrome sandbox requires non-root
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Docker container
|
||||
if Path::new("/.dockerenv").exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Podman container
|
||||
if Path::new("/run/.containerenv").exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generic container detection: cgroup contains docker/kubepods/lxc
|
||||
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
|
||||
if cgroup.contains("docker")
|
||||
|| cgroup.contains("kubepods")
|
||||
|| cgroup.contains("lxc")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Search Playwright's browser cache for a Chromium binary.
|
||||
/// This is where `agent-browser install` (via `npx playwright install chromium`) puts it.
|
||||
fn find_playwright_chromium() -> Option<PathBuf> {
|
||||
let mut search_dirs = Vec::new();
|
||||
|
||||
if let Ok(custom) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
|
||||
search_dirs.push(PathBuf::from(custom));
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
search_dirs.push(home.join(".cache/ms-playwright"));
|
||||
}
|
||||
|
||||
for dir in &search_dirs {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
let mut matches: Vec<PathBuf> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map(|n| n.starts_with("chromium-"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let candidate = build_playwright_binary_path(&e.path());
|
||||
if candidate.exists() {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Sort descending so the newest version wins
|
||||
matches.sort();
|
||||
matches.reverse();
|
||||
if let Some(p) = matches.into_iter().next() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
|
||||
chromium_dir.join("chrome-linux64/chrome")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
|
||||
chromium_dir.join("chrome-mac/Chromium.app/Contents/MacOS/Chromium")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
|
||||
chromium_dir.join("chrome-win/chrome.exe")
|
||||
}
|
||||
|
||||
fn expand_tilde(path: &str) -> String {
|
||||
if let Some(rest) = path.strip_prefix('~') {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home
|
||||
.join(rest.strip_prefix('/').unwrap_or(rest))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_chrome_returns_some_on_host() {
|
||||
// This test only makes sense on systems with Chrome installed
|
||||
if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
|
||||
let result = find_chrome();
|
||||
// Don't assert Some -- CI may not have Chrome
|
||||
if let Some(path) = result {
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tilde() {
|
||||
let expanded = expand_tilde("~/test/path");
|
||||
assert!(!expanded.starts_with('~'));
|
||||
assert!(expanded.ends_with("test/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tilde_no_tilde() {
|
||||
assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_devtools_active_port_missing() {
|
||||
let result = read_devtools_active_port(Path::new("/nonexistent"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_disable_sandbox_skips_if_already_set() {
|
||||
let args = vec!["--headless=new".to_string(), "--no-sandbox".to_string()];
|
||||
assert!(!should_disable_sandbox(&args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_no_stderr() {
|
||||
let msg = chrome_launch_error("Chrome exited", &[]);
|
||||
assert!(msg.contains("no stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_with_sandbox_hint() {
|
||||
let lines = vec![
|
||||
"some log line".to_string(),
|
||||
"Failed to move to new namespace: sandbox error".to_string(),
|
||||
];
|
||||
let msg = chrome_launch_error("Chrome exited", &lines);
|
||||
assert!(msg.contains("sandbox error"));
|
||||
assert!(msg.contains("Hint:"));
|
||||
assert!(msg.contains("--no-sandbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_generic() {
|
||||
let lines = vec![
|
||||
"info line".to_string(),
|
||||
"another info line".to_string(),
|
||||
];
|
||||
let msg = chrome_launch_error("Chrome exited", &lines);
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_playwright_chromium_nonexistent() {
|
||||
// With no Playwright cache, should return None
|
||||
std::env::set_var("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
|
||||
let result = find_playwright_chromium();
|
||||
std::env::remove_var("PLAYWRIGHT_BROWSERS_PATH");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, oneshot, Mutex};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::{CdpCommand, CdpEvent, CdpMessage};
|
||||
|
||||
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
|
||||
|
||||
pub struct CdpClient {
|
||||
ws_tx: Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
next_id: AtomicU64,
|
||||
pending: PendingMap,
|
||||
event_tx: broadcast::Sender<CdpEvent>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CdpClient {
|
||||
pub async fn connect(url: &str) -> Result<Self, String> {
|
||||
let (ws_stream, _) = connect_async(url)
|
||||
.await
|
||||
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
|
||||
let pending_clone = pending.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
let msg = match msg {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let parsed: CdpMessage = match serde_json::from_str(&msg) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = parsed.id {
|
||||
// Response to a command
|
||||
let mut pending = pending_clone.lock().await;
|
||||
if let Some(tx) = pending.remove(&id) {
|
||||
let _ = tx.send(parsed);
|
||||
}
|
||||
} else if let Some(ref method) = parsed.method {
|
||||
// Event
|
||||
let event = CdpEvent {
|
||||
method: method.clone(),
|
||||
params: parsed.params.clone().unwrap_or(Value::Null),
|
||||
session_id: parsed.session_id.clone(),
|
||||
};
|
||||
let _ = event_tx_clone.send(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
ws_tx,
|
||||
next_id: AtomicU64::new(1),
|
||||
pending,
|
||||
event_tx,
|
||||
_reader_handle: reader_handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_command(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Option<Value>,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let cmd = CdpCommand {
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: session_id.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&cmd)
|
||||
.map_err(|e| format!("Failed to serialize CDP command: {}", e))?;
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(id, tx);
|
||||
}
|
||||
|
||||
{
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send CDP command: {}", e))?;
|
||||
}
|
||||
|
||||
let response = match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await {
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(_)) => return Err("CDP response channel closed".to_string()),
|
||||
Err(_) => {
|
||||
self.pending.lock().await.remove(&id);
|
||||
return Err(format!("CDP command timed out: {}", method));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(error) = response.error {
|
||||
return Err(format!("CDP error ({}): {}", method, error));
|
||||
}
|
||||
|
||||
Ok(response.result.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: &P,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<R, String> {
|
||||
let params_value = serde_json::to_value(params)
|
||||
.map_err(|e| format!("Failed to serialize params: {}", e))?;
|
||||
let result = self
|
||||
.send_command(method, Some(params_value), session_id)
|
||||
.await?;
|
||||
serde_json::from_value(result)
|
||||
.map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
|
||||
}
|
||||
|
||||
pub async fn send_command_no_params(
|
||||
&self,
|
||||
method: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
self.send_command(method, None, session_id).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,537 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP message envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CdpCommand {
|
||||
pub id: u64,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CdpMessage {
|
||||
pub id: Option<u64>,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<CdpError>,
|
||||
pub method: Option<String>,
|
||||
pub params: Option<Value>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CdpError {
|
||||
pub code: Option<i64>,
|
||||
pub message: String,
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CdpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP events (broadcast to subscribers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdpEvent {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInfo {
|
||||
pub target_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub attached: Option<bool>,
|
||||
pub browser_context_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetTargetsResult {
|
||||
pub target_infos: Vec<TargetInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachToTargetParams {
|
||||
pub target_id: String,
|
||||
pub flatten: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachToTargetResult {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDiscoverTargetsParams {
|
||||
pub discover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetParams {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetResult {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloseTargetParams {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
// Target events
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetCreatedEvent {
|
||||
pub target_info: TargetInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetDestroyedEvent {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInfoChangedEvent {
|
||||
pub target_info: TargetInfo,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNavigateParams {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub referrer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNavigateResult {
|
||||
pub frame_id: String,
|
||||
pub loader_id: Option<String>,
|
||||
pub error_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameNavigatedEvent {
|
||||
pub frame: FrameInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameInfo {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
// Page.javascriptDialogOpening
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JavascriptDialogOpeningEvent {
|
||||
pub url: String,
|
||||
pub message: String,
|
||||
#[serde(rename = "type")]
|
||||
pub dialog_type: String,
|
||||
pub default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HandleJavaScriptDialogParams {
|
||||
pub accept: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_text: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluateParams {
|
||||
pub expression: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluateResult {
|
||||
pub result: RemoteObject,
|
||||
pub exception_details: Option<ExceptionDetails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteObject {
|
||||
#[serde(rename = "type")]
|
||||
pub object_type: String,
|
||||
pub subtype: Option<String>,
|
||||
pub value: Option<Value>,
|
||||
pub description: Option<String>,
|
||||
pub object_id: Option<String>,
|
||||
pub class_name: Option<String>,
|
||||
pub unserializable_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExceptionDetails {
|
||||
pub text: String,
|
||||
pub exception: Option<RemoteObject>,
|
||||
pub line_number: Option<i64>,
|
||||
pub column_number: Option<i64>,
|
||||
}
|
||||
|
||||
// Runtime.consoleAPICalled
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsoleApiCalledEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub args: Vec<RemoteObject>,
|
||||
pub timestamp: Option<f64>,
|
||||
}
|
||||
|
||||
// Runtime.exceptionThrown
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExceptionThrownEvent {
|
||||
pub timestamp: f64,
|
||||
pub exception_details: ExceptionDetails,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFullAXTreeResult {
|
||||
pub nodes: Vec<AXNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXNode {
|
||||
pub node_id: String,
|
||||
pub role: Option<AXValue>,
|
||||
pub name: Option<AXValue>,
|
||||
pub value: Option<AXValue>,
|
||||
pub description: Option<AXValue>,
|
||||
pub properties: Option<Vec<AXProperty>>,
|
||||
pub child_ids: Option<Vec<String>>,
|
||||
pub backend_d_o_m_node_id: Option<i64>,
|
||||
pub ignored: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXValue {
|
||||
#[serde(rename = "type")]
|
||||
pub value_type: String,
|
||||
pub value: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXProperty {
|
||||
pub name: String,
|
||||
pub value: AXValue,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network domain (minimal for Phase 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestWillBeSentEvent {
|
||||
pub request_id: String,
|
||||
pub request: NetworkRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkRequest {
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadingFinishedEvent {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadingFailedEvent {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomResolveNodeParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomResolveNodeResult {
|
||||
pub object: RemoteObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetBoxModelParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetBoxModelResult {
|
||||
pub model: BoxModel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoxModel {
|
||||
pub content: Vec<f64>,
|
||||
pub padding: Vec<f64>,
|
||||
pub border: Vec<f64>,
|
||||
pub margin: Vec<f64>,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomQuerySelectorParams {
|
||||
pub node_id: i64,
|
||||
pub selector: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomQuerySelectorResult {
|
||||
pub node_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetDocumentParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub depth: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetDocumentResult {
|
||||
pub root: DomNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomNode {
|
||||
pub node_id: i64,
|
||||
pub backend_node_id: Option<i64>,
|
||||
pub node_type: Option<i64>,
|
||||
pub node_name: Option<String>,
|
||||
pub children: Option<Vec<DomNode>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchMouseEventParams {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub button: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub buttons: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub click_count: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta_x: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta_y: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchKeyEventParams {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unmodified_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub windows_virtual_key_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub native_virtual_key_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InsertTextParams {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page.captureScreenshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureScreenshotParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Viewport>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from_surface: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capture_beyond_viewport: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureScreenshotResult {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime.callFunctionOn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallFunctionOnParams {
|
||||
pub function_declaration: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Vec<CallArgument>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallArgument {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version info (from /json/version)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrowserVersionInfo {
|
||||
#[serde(rename = "webSocketDebuggerUrl")]
|
||||
pub web_socket_debugger_url: Option<String>,
|
||||
#[serde(rename = "Browser")]
|
||||
pub browser: Option<String>,
|
||||
}
|
||||
|
||||
/// Auto-generated CDP types from protocol JSON files in `cdp-protocol/`.
|
||||
///
|
||||
/// To populate: download `browser_protocol.json` and `js_protocol.json` from
|
||||
/// <https://github.com/nicolo-ribaudo/nicolo-ribaudo.github.io/> (or any
|
||||
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
|
||||
///
|
||||
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cookie {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub domain: String,
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub expires: f64,
|
||||
#[serde(default)]
|
||||
pub size: i64,
|
||||
#[serde(default)]
|
||||
pub http_only: bool,
|
||||
#[serde(default)]
|
||||
pub secure: bool,
|
||||
#[serde(default)]
|
||||
pub session: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
urls: Option<Vec<String>>,
|
||||
) -> Result<Vec<Cookie>, String> {
|
||||
let params = match urls {
|
||||
Some(ref u) if !u.is_empty() => json!({ "urls": u }),
|
||||
_ => json!({}),
|
||||
};
|
||||
|
||||
let result = client
|
||||
.send_command("Network.getCookies", Some(params), Some(session_id))
|
||||
.await?;
|
||||
|
||||
let cookies: Vec<Cookie> = result
|
||||
.get("cookies")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
pub async fn set_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
cookies: Vec<Value>,
|
||||
current_url: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let cookies: Vec<Value> = cookies
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
// Auto-fill url if no domain/path/url provided
|
||||
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
|
||||
c.as_object_mut().map(|m| {
|
||||
m.insert(
|
||||
"url".to_string(),
|
||||
Value::String(current_url.unwrap().to_string()),
|
||||
)
|
||||
});
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Network.setCookies",
|
||||
Some(json!({ "cookies": cookies })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_cookies(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
client
|
||||
.send_command_no_params("Network.clearBrowserCookies", Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::signal;
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
use super::state;
|
||||
|
||||
pub async fn run_daemon(session: &str) {
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
}
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
|
||||
if socket_path.exists() {
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
||||
if let Ok(days) = days_str.parse::<u64>() {
|
||||
if days > 0 {
|
||||
let _ = state::state_clean(days);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = run_socket_server(&socket_path, session).await;
|
||||
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Daemon error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> {
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
let listener =
|
||||
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let port = get_port_for_session(session);
|
||||
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind TCP: {}", e))?;
|
||||
|
||||
let socket_dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
let port_path = socket_dir.join(format!("{}.port", session));
|
||||
let _ = fs::write(&port_path, port.to_string());
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
let _ = fs::remove_file(&port_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match buf_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if looks_like_http(trimmed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmd: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("Invalid JSON: {}", e),
|
||||
});
|
||||
let mut resp = serde_json::to_string(&err).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
let _ = writer.write_all(resp.as_bytes()).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
|
||||
|
||||
let response = {
|
||||
let mut s = state.lock().await;
|
||||
execute_command(&cmd, &mut s).await
|
||||
};
|
||||
|
||||
let mut resp = serde_json::to_string(&response).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
if writer.write_all(resp.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
if is_close {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_http(line: &str) -> bool {
|
||||
let prefixes = [
|
||||
"GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "CONNECT ", "TRACE ",
|
||||
];
|
||||
prefixes.iter().any(|p| line.starts_with(p))
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGINT handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sigterm = match signal::unix::signal(signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGTERM handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sighup = match signal::unix::signal(signal::unix::SignalKind::hangup()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGHUP handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {}
|
||||
_ = sigterm.recv() => {}
|
||||
_ = sighup.recv() => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
eprintln!("Failed to install Ctrl+C handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_daemon_socket_dir() -> PathBuf {
|
||||
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("agent-browser");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".agent-browser");
|
||||
}
|
||||
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i64 = 0;
|
||||
for b in session.bytes() {
|
||||
hash = hash.wrapping_mul(31).wrapping_add(b as i64);
|
||||
}
|
||||
49152 + (hash.unsigned_abs() % 16383) as u16
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use serde_json::{json, Value};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
pub struct ScreenshotDiffResult {
|
||||
pub total_pixels: u64,
|
||||
pub different_pixels: u64,
|
||||
pub mismatch_percentage: f64,
|
||||
pub matched: bool,
|
||||
pub diff_image: Option<Vec<u8>>,
|
||||
pub dimension_mismatch: Option<Value>,
|
||||
}
|
||||
|
||||
pub struct SnapshotDiffResult {
|
||||
pub diff: String,
|
||||
pub additions: usize,
|
||||
pub removals: usize,
|
||||
pub unchanged: usize,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
pub fn diff_screenshot(
|
||||
baseline: &[u8],
|
||||
current: &[u8],
|
||||
threshold: f64,
|
||||
) -> Result<ScreenshotDiffResult, String> {
|
||||
let img_a = image::load_from_memory(baseline)
|
||||
.map_err(|e| format!("Failed to decode baseline image: {}", e))?;
|
||||
let img_b = image::load_from_memory(current)
|
||||
.map_err(|e| format!("Failed to decode current image: {}", e))?;
|
||||
|
||||
let (wa, ha) = (img_a.width(), img_a.height());
|
||||
let (wb, hb) = (img_b.width(), img_b.height());
|
||||
|
||||
if wa != wb || ha != hb {
|
||||
return Ok(ScreenshotDiffResult {
|
||||
total_pixels: (wa as u64) * (ha as u64),
|
||||
different_pixels: (wa as u64) * (ha as u64),
|
||||
mismatch_percentage: 100.0,
|
||||
matched: false,
|
||||
diff_image: None,
|
||||
dimension_mismatch: Some(json!({
|
||||
"expected": { "width": wa, "height": ha },
|
||||
"actual": { "width": wb, "height": hb },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
let rgba_a = img_a.to_rgba8();
|
||||
let rgba_b = img_b.to_rgba8();
|
||||
let total = (wa as u64) * (ha as u64);
|
||||
let max_color_distance = threshold * 255.0 * (3.0_f64).sqrt();
|
||||
let mut different = 0u64;
|
||||
|
||||
let mut diff_img = image::RgbaImage::new(wa, ha);
|
||||
|
||||
for y in 0..ha {
|
||||
for x in 0..wa {
|
||||
let pa = rgba_a.get_pixel(x, y);
|
||||
let pb = rgba_b.get_pixel(x, y);
|
||||
let dr = (pa[0] as f64) - (pb[0] as f64);
|
||||
let dg = (pa[1] as f64) - (pb[1] as f64);
|
||||
let db = (pa[2] as f64) - (pb[2] as f64);
|
||||
let dist = (dr * dr + dg * dg + db * db).sqrt();
|
||||
|
||||
if dist > max_color_distance {
|
||||
different += 1;
|
||||
diff_img.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
|
||||
} else {
|
||||
let gray = ((pa[0] as u16 + pa[1] as u16 + pa[2] as u16) / 3) as u8;
|
||||
let dimmed = (gray as f64 * 0.3) as u8;
|
||||
diff_img.put_pixel(x, y, image::Rgba([dimmed, dimmed, dimmed, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mismatch = if total > 0 {
|
||||
(different as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let diff_bytes = if different > 0 {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
diff_img
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode diff image: {}", e))?;
|
||||
Some(buf.into_inner())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ScreenshotDiffResult {
|
||||
total_pixels: total,
|
||||
different_pixels: different,
|
||||
mismatch_percentage: mismatch,
|
||||
matched: different == 0,
|
||||
diff_image: diff_bytes,
|
||||
dimension_mismatch: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute a snapshot diff using the Myers algorithm via the `similar` crate.
|
||||
pub fn diff_snapshots(before: &str, after: &str) -> SnapshotDiffResult {
|
||||
let text_diff = TextDiff::from_lines(before, after);
|
||||
|
||||
let mut additions = 0usize;
|
||||
let mut removals = 0usize;
|
||||
let mut unchanged = 0usize;
|
||||
|
||||
for change in text_diff.iter_all_changes() {
|
||||
match change.tag() {
|
||||
ChangeTag::Insert => additions += 1,
|
||||
ChangeTag::Delete => removals += 1,
|
||||
ChangeTag::Equal => unchanged += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let changed = additions > 0 || removals > 0;
|
||||
|
||||
let diff = text_diff
|
||||
.unified_diff()
|
||||
.context_radius(3)
|
||||
.header("before", "after")
|
||||
.to_string();
|
||||
|
||||
SnapshotDiffResult {
|
||||
diff,
|
||||
additions,
|
||||
removals,
|
||||
unchanged,
|
||||
changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy JSON diff output for backwards compatibility.
|
||||
pub fn diff_text(a: &str, b: &str) -> Value {
|
||||
let result = diff_snapshots(a, b);
|
||||
json!({
|
||||
"identical": !result.changed,
|
||||
"additions": result.additions,
|
||||
"removals": result.removals,
|
||||
"deletions": result.removals,
|
||||
"unchanged": result.unchanged,
|
||||
"changed": result.changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn diff_unified(a: &str, b: &str) -> String {
|
||||
diff_snapshots(a, b).diff
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_diff_identical() {
|
||||
let result = diff_text("hello\nworld", "hello\nworld");
|
||||
assert_eq!(result.get("identical").unwrap(), true);
|
||||
assert_eq!(result.get("changed").unwrap(), false);
|
||||
assert_eq!(result.get("unchanged").unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_additions() {
|
||||
let result = diff_text("hello\n", "hello\nworld\n");
|
||||
assert_eq!(result.get("identical").unwrap(), false);
|
||||
assert_eq!(result.get("changed").unwrap(), true);
|
||||
assert!(result.get("additions").unwrap().as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_deletions() {
|
||||
let result = diff_text("hello\nworld\n", "hello\n");
|
||||
assert_eq!(result.get("identical").unwrap(), false);
|
||||
assert!(result.get("removals").unwrap().as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_unified_output() {
|
||||
let output = diff_unified("a\nb\nc\n", "a\nx\nc\n");
|
||||
assert!(output.contains("---"));
|
||||
assert!(output.contains("+++"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_diff_struct() {
|
||||
let result = diff_snapshots("line1\nline2\n", "line1\nline3\n");
|
||||
assert!(result.changed);
|
||||
assert_eq!(result.additions, 1);
|
||||
assert_eq!(result.removals, 1);
|
||||
assert_eq!(result.unchanged, 1);
|
||||
assert!(!result.diff.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,718 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefEntry {
|
||||
pub backend_node_id: Option<i64>,
|
||||
pub role: String,
|
||||
pub name: String,
|
||||
pub nth: Option<usize>,
|
||||
pub selector: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RefMap {
|
||||
map: HashMap<String, RefEntry>,
|
||||
next_ref: usize,
|
||||
}
|
||||
|
||||
impl RefMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
next_ref: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
&mut self,
|
||||
ref_id: String,
|
||||
backend_node_id: Option<i64>,
|
||||
role: &str,
|
||||
name: &str,
|
||||
nth: Option<usize>,
|
||||
) {
|
||||
self.map.insert(
|
||||
ref_id,
|
||||
RefEntry {
|
||||
backend_node_id,
|
||||
role: role.to_string(),
|
||||
name: name.to_string(),
|
||||
nth,
|
||||
selector: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn get(&self, ref_id: &str) -> Option<&RefEntry> {
|
||||
self.map.get(ref_id)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.next_ref = 1;
|
||||
}
|
||||
|
||||
pub fn next_ref_num(&self) -> usize {
|
||||
self.next_ref
|
||||
}
|
||||
|
||||
pub fn set_next_ref_num(&mut self, n: usize) {
|
||||
self.next_ref = n;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_ref(input: &str) -> Option<String> {
|
||||
let trimmed = input.trim();
|
||||
|
||||
if let Some(stripped) = trimmed.strip_prefix('@') {
|
||||
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(stripped.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(stripped) = trimmed.strip_prefix("ref=") {
|
||||
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(stripped.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed.starts_with('e')
|
||||
&& trimmed.len() > 1
|
||||
&& trimmed[1..].chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn resolve_element_center(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(f64, f64), String> {
|
||||
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||
let entry = ref_map
|
||||
.get(&ref_id)
|
||||
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
||||
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
let result: DomGetBoxModelResult = client
|
||||
.send_command_typed(
|
||||
"DOM.getBoxModel",
|
||||
&DomGetBoxModelParams {
|
||||
backend_node_id: Some(backend_node_id),
|
||||
node_id: None,
|
||||
object_id: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(box_model_center(&result.model));
|
||||
}
|
||||
|
||||
// Fallback: use role/name to find via JS
|
||||
return resolve_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth).await;
|
||||
}
|
||||
|
||||
// CSS selector
|
||||
resolve_by_selector(client, session_id, selector_or_ref).await
|
||||
}
|
||||
|
||||
pub async fn resolve_element_object_id(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||
let entry = ref_map
|
||||
.get(&ref_id)
|
||||
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
||||
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
let result: DomResolveNodeResult = client
|
||||
.send_command_typed(
|
||||
"DOM.resolveNode",
|
||||
&DomResolveNodeParams {
|
||||
backend_node_id: Some(backend_node_id),
|
||||
node_id: None,
|
||||
object_group: Some("agent-browser".to_string()),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
return result
|
||||
.object
|
||||
.object_id
|
||||
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
|
||||
}
|
||||
}
|
||||
|
||||
// CSS selector fallback
|
||||
let js = format!(
|
||||
"document.querySelector({})",
|
||||
serde_json::to_string(selector_or_ref).unwrap_or_default()
|
||||
);
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(false),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
result
|
||||
.result
|
||||
.object_id
|
||||
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))
|
||||
}
|
||||
|
||||
async fn resolve_by_role_name(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
role: &str,
|
||||
name: &str,
|
||||
nth: Option<usize>,
|
||||
) -> Result<(f64, f64), String> {
|
||||
let nth_index = nth.unwrap_or(0);
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
const matches = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) {{
|
||||
const r = node.getAttribute('role') || node.tagName.toLowerCase();
|
||||
const n = node.getAttribute('aria-label') || node.textContent.trim().slice(0, 100);
|
||||
if (r === {role} && n === {name}) matches.push(node);
|
||||
}}
|
||||
const el = matches[{nth}];
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
||||
}})()"#,
|
||||
role = serde_json::to_string(role).unwrap_or_default(),
|
||||
name = serde_json::to_string(name).unwrap_or_default(),
|
||||
nth = nth_index,
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let val = result.result.value.unwrap_or(Value::Null);
|
||||
let x = val.get("x").and_then(|v| v.as_f64());
|
||||
let y = val.get("y").and_then(|v| v.as_f64());
|
||||
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) => Ok((x, y)),
|
||||
_ => Err(format!(
|
||||
"Could not locate element with role={} name={}",
|
||||
role, name
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_by_selector(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
selector: &str,
|
||||
) -> Result<(f64, f64), String> {
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const el = document.querySelector({sel});
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let val = result.result.value.unwrap_or(Value::Null);
|
||||
let x = val.get("x").and_then(|v| v.as_f64());
|
||||
let y = val.get("y").and_then(|v| v.as_f64());
|
||||
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) => Ok((x, y)),
|
||||
_ => Err(format!("Element not found: {}", selector)),
|
||||
}
|
||||
}
|
||||
|
||||
fn box_model_center(model: &BoxModel) -> (f64, f64) {
|
||||
// content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
|
||||
if model.content.len() >= 8 {
|
||||
let x = (model.content[0] + model.content[2] + model.content[4] + model.content[6]) / 4.0;
|
||||
let y = (model.content[1] + model.content[3] + model.content[5] + model.content[7]) / 4.0;
|
||||
(x, y)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_element_text(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { return this.innerText || this.textContent || ''; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_element_attribute(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
attribute: &str,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: format!(
|
||||
"function() {{ return this.getAttribute({}); }}",
|
||||
serde_json::to_string(attribute).unwrap_or_default()
|
||||
),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn is_element_visible(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(this);
|
||||
return rect.width > 0 && rect.height > 0 &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.display !== 'none' &&
|
||||
parseFloat(style.opacity) > 0;
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub async fn is_element_enabled(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return !this.disabled; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true))
|
||||
}
|
||||
|
||||
pub async fn is_element_checked(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return !!this.checked; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub async fn get_element_inner_text(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return this.innerText || ''; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_element_inner_html(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return this.innerHTML || ''; }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_element_input_value(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { return typeof this.value === 'string' ? this.value : ''; }"
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn set_element_value(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let js = format!(
|
||||
"function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}",
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, EvaluateResult>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_element_bounding_box(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const r = this.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
result
|
||||
.result
|
||||
.value
|
||||
.ok_or_else(|| format!("Could not get bounding box for: {}", selector_or_ref))
|
||||
}
|
||||
|
||||
pub async fn get_element_count(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
selector: &str,
|
||||
) -> Result<i64, String> {
|
||||
let js = format!(
|
||||
"document.querySelectorAll({}).length",
|
||||
serde_json::to_string(selector).unwrap_or_default()
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.and_then(|v| v.as_i64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub async fn get_element_styles(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
properties: Option<Vec<String>>,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let js = match properties {
|
||||
Some(props) => {
|
||||
let props_json = serde_json::to_string(&props).unwrap_or("[]".to_string());
|
||||
format!(
|
||||
r#"function() {{
|
||||
const s = window.getComputedStyle(this);
|
||||
const props = {};
|
||||
const result = {{}};
|
||||
for (const p of props) result[p] = s.getPropertyValue(p);
|
||||
return result;
|
||||
}}"#,
|
||||
props_json
|
||||
)
|
||||
}
|
||||
None => r#"function() {
|
||||
const s = window.getComputedStyle(this);
|
||||
const result = {};
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const p = s[i];
|
||||
result[p] = s.getPropertyValue(p);
|
||||
}
|
||||
return result;
|
||||
}"#
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_at_prefix() {
|
||||
assert_eq!(parse_ref("@e1"), Some("e1".to_string()));
|
||||
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_equals_prefix() {
|
||||
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_bare() {
|
||||
assert_eq!(parse_ref("e1"), Some("e1".to_string()));
|
||||
assert_eq!(parse_ref("e42"), Some("e42".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_invalid() {
|
||||
assert_eq!(parse_ref("button"), None);
|
||||
assert_eq!(parse_ref("e"), None);
|
||||
assert_eq!(parse_ref("1"), None);
|
||||
assert_eq!(parse_ref(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ref_map_basic() {
|
||||
let mut map = RefMap::new();
|
||||
map.add("e1".to_string(), Some(42), "button", "Submit", None);
|
||||
assert!(map.get("e1").is_some());
|
||||
assert_eq!(map.get("e1").unwrap().role, "button");
|
||||
assert!(map.get("e2").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_box_model_center() {
|
||||
let model = BoxModel {
|
||||
content: vec![10.0, 20.0, 110.0, 20.0, 110.0, 60.0, 10.0, 60.0],
|
||||
padding: vec![],
|
||||
border: vec![],
|
||||
margin: vec![],
|
||||
width: 100,
|
||||
height: 40,
|
||||
};
|
||||
let (x, y) = box_model_center(&model);
|
||||
assert!((x - 60.0).abs() < 0.01);
|
||||
assert!((y - 40.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
|
||||
|
||||
pub async fn click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
button: &str,
|
||||
click_count: i32,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
dispatch_click(client, session_id, x, y, button, click_count).await
|
||||
}
|
||||
|
||||
pub async fn dblclick(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 2).await
|
||||
}
|
||||
|
||||
pub async fn hover(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseMoved".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: None,
|
||||
buttons: None,
|
||||
click_count: None,
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fill(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
// Focus the element
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id.clone()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Select all + delete to clear
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.select && this.select();
|
||||
this.value = '';
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert text
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.insertText",
|
||||
&InsertTextParams {
|
||||
text: value.to_string(),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn type_text(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
text: &str,
|
||||
clear: bool,
|
||||
delay_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
// Focus
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id.clone()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if clear {
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.select && this.select();
|
||||
this.value = '';
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let delay = delay_ms.unwrap_or(0);
|
||||
|
||||
for ch in text.chars() {
|
||||
let text_str = ch.to_string();
|
||||
let (key, code, key_code) = char_to_key_info(ch);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: Some(text_str.clone()),
|
||||
unmodified_text: Some(text_str.clone()),
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyUp".to_string(),
|
||||
key: Some(key),
|
||||
code: Some(code),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Result<(), String> {
|
||||
let (key_name, code, key_code) = named_key_info(key);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key_name.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyUp".to_string(),
|
||||
key: Some(key_name),
|
||||
code: Some(code),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn scroll(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: Option<&str>,
|
||||
delta_x: f64,
|
||||
delta_y: f64,
|
||||
) -> Result<(), String> {
|
||||
if let Some(sel) = selector_or_ref {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, sel).await?;
|
||||
let js = "function(dx, dy) { this.scrollBy(dx, dy); }".to_string();
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: Some(vec![
|
||||
CallArgument {
|
||||
value: Some(serde_json::json!(delta_x)),
|
||||
object_id: None,
|
||||
},
|
||||
CallArgument {
|
||||
value: Some(serde_json::json!(delta_y)),
|
||||
object_id: None,
|
||||
},
|
||||
]),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let js = format!("window.scrollBy({}, {})", delta_x, delta_y);
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn select_option(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
values: &[String],
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let js = r#"function(vals) {
|
||||
const options = Array.from(this.options);
|
||||
for (const opt of options) {
|
||||
opt.selected = vals.includes(opt.value) || vals.includes(opt.textContent.trim());
|
||||
}
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}"#
|
||||
.to_string();
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: Some(vec![CallArgument {
|
||||
value: Some(serde_json::json!(values)),
|
||||
object_id: None,
|
||||
}]),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let is_checked =
|
||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||
if !is_checked {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uncheck(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let is_checked =
|
||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||
if is_checked {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn focus(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.focus();
|
||||
this.value = '';
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn select_all(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.focus();
|
||||
if (typeof this.select === 'function') {
|
||||
this.select();
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(this);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn scroll_into_view(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { this.scrollIntoView({ block: 'center', inline: 'center' }); }"
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dispatch_event(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
event_type: &str,
|
||||
event_init: Option<&Value>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let init_json = event_init
|
||||
.map(|v| serde_json::to_string(v).unwrap_or("{}".to_string()))
|
||||
.unwrap_or_else(|| "{ bubbles: true }".to_string());
|
||||
|
||||
let js = format!(
|
||||
"function() {{ this.dispatchEvent(new Event({}, {})); }}",
|
||||
serde_json::to_string(event_type).unwrap_or_default(),
|
||||
init_json
|
||||
);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn highlight(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.style.outline = '2px solid red';
|
||||
this.style.outlineOffset = '2px';
|
||||
const el = this;
|
||||
setTimeout(() => {
|
||||
el.style.outline = '';
|
||||
el.style.outlineOffset = '';
|
||||
}, 3000);
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn tap_touch(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Input.dispatchTouchEvent",
|
||||
Some(serde_json::json!({
|
||||
"type": "touchStart",
|
||||
"touchPoints": [{ "x": x, "y": y }],
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Input.dispatchTouchEvent",
|
||||
Some(serde_json::json!({
|
||||
"type": "touchEnd",
|
||||
"touchPoints": [],
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch_click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
button: &str,
|
||||
click_count: i32,
|
||||
) -> Result<(), String> {
|
||||
// Move
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseMoved".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: None,
|
||||
buttons: None,
|
||||
click_count: None,
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let button_value = match button {
|
||||
"right" => 2,
|
||||
"middle" => 4,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
// Press
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mousePressed".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: Some(button.to_string()),
|
||||
buttons: Some(button_value),
|
||||
click_count: Some(click_count),
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Release
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseReleased".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: Some(button.to_string()),
|
||||
buttons: Some(0),
|
||||
click_count: Some(click_count),
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn char_to_key_info(ch: char) -> (String, String, i32) {
|
||||
match ch {
|
||||
'\n' | '\r' => ("Enter".to_string(), "Enter".to_string(), 13),
|
||||
'\t' => ("Tab".to_string(), "Tab".to_string(), 9),
|
||||
' ' => (" ".to_string(), "Space".to_string(), 32),
|
||||
_ => {
|
||||
let key = ch.to_string();
|
||||
let code = if ch.is_ascii_alphabetic() {
|
||||
format!("Key{}", ch.to_uppercase())
|
||||
} else if ch.is_ascii_digit() {
|
||||
format!("Digit{}", ch)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let key_code = ch as i32;
|
||||
(key, code, key_code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn named_key_info(key: &str) -> (String, String, i32) {
|
||||
match key.to_lowercase().as_str() {
|
||||
"enter" | "return" => ("Enter".to_string(), "Enter".to_string(), 13),
|
||||
"tab" => ("Tab".to_string(), "Tab".to_string(), 9),
|
||||
"escape" | "esc" => ("Escape".to_string(), "Escape".to_string(), 27),
|
||||
"backspace" => ("Backspace".to_string(), "Backspace".to_string(), 8),
|
||||
"delete" => ("Delete".to_string(), "Delete".to_string(), 46),
|
||||
"arrowup" | "up" => ("ArrowUp".to_string(), "ArrowUp".to_string(), 38),
|
||||
"arrowdown" | "down" => ("ArrowDown".to_string(), "ArrowDown".to_string(), 40),
|
||||
"arrowleft" | "left" => ("ArrowLeft".to_string(), "ArrowLeft".to_string(), 37),
|
||||
"arrowright" | "right" => ("ArrowRight".to_string(), "ArrowRight".to_string(), 39),
|
||||
"home" => ("Home".to_string(), "Home".to_string(), 36),
|
||||
"end" => ("End".to_string(), "End".to_string(), 35),
|
||||
"pageup" => ("PageUp".to_string(), "PageUp".to_string(), 33),
|
||||
"pagedown" => ("PageDown".to_string(), "PageDown".to_string(), 34),
|
||||
"space" | " " => (" ".to_string(), "Space".to_string(), 32),
|
||||
_ => {
|
||||
if key.len() == 1 {
|
||||
let ch = key.chars().next().unwrap();
|
||||
char_to_key_info(ch)
|
||||
} else {
|
||||
(key.to_string(), key.to_string(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod actions;
|
||||
#[allow(dead_code)]
|
||||
pub mod auth;
|
||||
#[allow(dead_code)]
|
||||
pub mod browser;
|
||||
#[allow(dead_code)]
|
||||
pub mod cdp;
|
||||
#[allow(dead_code)]
|
||||
pub mod cookies;
|
||||
#[allow(dead_code)]
|
||||
pub mod daemon;
|
||||
#[allow(dead_code)]
|
||||
pub mod diff;
|
||||
#[allow(dead_code)]
|
||||
pub mod element;
|
||||
#[allow(dead_code)]
|
||||
pub mod interaction;
|
||||
#[allow(dead_code)]
|
||||
pub mod network;
|
||||
#[allow(dead_code)]
|
||||
pub mod policy;
|
||||
#[allow(dead_code)]
|
||||
pub mod providers;
|
||||
#[allow(dead_code)]
|
||||
pub mod recording;
|
||||
#[allow(dead_code)]
|
||||
pub mod screenshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod snapshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod state;
|
||||
#[allow(dead_code)]
|
||||
pub mod storage;
|
||||
#[allow(dead_code)]
|
||||
pub mod stream;
|
||||
#[allow(dead_code)]
|
||||
pub mod tracing;
|
||||
#[allow(dead_code)]
|
||||
pub mod webdriver;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e_tests;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
@@ -0,0 +1,399 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
pub async fn set_extra_headers(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let headers_value: Value = headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect::<serde_json::Map<String, Value>>()
|
||||
.into();
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Network.setExtraHTTPHeaders",
|
||||
Some(json!({ "headers": headers_value })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_offline(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
offline: bool,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Network.emulateNetworkConditions",
|
||||
Some(json!({
|
||||
"offline": offline,
|
||||
"latency": 0,
|
||||
"downloadThroughput": -1,
|
||||
"uploadThroughput": -1,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_content(client: &CdpClient, session_id: &str, html: &str) -> Result<(), String> {
|
||||
// Get current frame ID
|
||||
let tree_result = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let frame_id = tree_result
|
||||
.get("frameTree")
|
||||
.and_then(|t| t.get("frame"))
|
||||
.and_then(|f| f.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.ok_or("Could not determine frame ID")?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Page.setDocumentContent",
|
||||
Some(json!({
|
||||
"frameId": frame_id,
|
||||
"html": html,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DomainFilter {
|
||||
pub allowed_domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl DomainFilter {
|
||||
pub fn new(domains: &str) -> Self {
|
||||
let allowed = parse_domain_list(domains);
|
||||
Self {
|
||||
allowed_domains: allowed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_allowed(&self, hostname: &str) -> bool {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let hostname = hostname.to_lowercase();
|
||||
for pattern in &self.allowed_domains {
|
||||
if let Some(suffix) = pattern.strip_prefix("*.") {
|
||||
if hostname == suffix || hostname.ends_with(&format!(".{}", suffix)) {
|
||||
return true;
|
||||
}
|
||||
} else if hostname == *pattern {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn check_url(&self, url: &str) -> Result<(), String> {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parsed = url::Url::parse(url).map_err(|_| format!("Invalid URL: {}", url))?;
|
||||
let hostname = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| format!("No hostname in URL: {}", url))?;
|
||||
if self.is_allowed(hostname) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Domain '{}' is not in the allowed domains list",
|
||||
hostname
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_domain_list(input: &str) -> Vec<String> {
|
||||
input
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn sanitize_existing_pages(
|
||||
client: &CdpClient,
|
||||
pages: &[super::browser::PageInfo],
|
||||
filter: &DomainFilter,
|
||||
) {
|
||||
for page in pages {
|
||||
if page.url.is_empty() || page.url == "about:blank" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(parsed) = url::Url::parse(&page.url) {
|
||||
if let Some(hostname) = parsed.host_str() {
|
||||
if !filter.is_allowed(hostname) {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": "about:blank" })),
|
||||
Some(&page.session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_domain_filter_script(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
) -> Result<(), String> {
|
||||
if allowed_domains.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let domains_json = serde_json::to_string(allowed_domains).unwrap_or("[]".to_string());
|
||||
let script = format!(
|
||||
r#"(() => {{
|
||||
const _allowed = {};
|
||||
function _isDomainAllowed(hostname) {{
|
||||
hostname = hostname.toLowerCase();
|
||||
for (const p of _allowed) {{
|
||||
if (p.startsWith('*.')) {{
|
||||
const suffix = p.slice(2);
|
||||
if (hostname === suffix || hostname.endsWith('.' + suffix)) return true;
|
||||
}} else if (hostname === p) return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
const OrigWS = window.WebSocket;
|
||||
window.WebSocket = function(url, protocols) {{
|
||||
try {{
|
||||
const u = new URL(url);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('WebSocket blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigWS(url, protocols);
|
||||
}};
|
||||
window.WebSocket.prototype = OrigWS.prototype;
|
||||
const OrigES = window.EventSource;
|
||||
if (OrigES) {{
|
||||
window.EventSource = function(url, opts) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('EventSource blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigES(url, opts);
|
||||
}};
|
||||
window.EventSource.prototype = OrigES.prototype;
|
||||
}}
|
||||
const origBeacon = navigator.sendBeacon;
|
||||
if (origBeacon) {{
|
||||
navigator.sendBeacon = function(url, data) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) return false;
|
||||
}} catch(e) {{ return false; }}
|
||||
return origBeacon.call(navigator, url, data);
|
||||
}};
|
||||
}}
|
||||
}})()"#,
|
||||
domains_json,
|
||||
);
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable Fetch-based network interception for domain filtering.
|
||||
/// This intercepts all requests and checks them against the allowed domains list.
|
||||
/// The actual handling of `Fetch.requestPaused` events happens in
|
||||
/// `resolve_fetch_paused` in the actions module.
|
||||
pub async fn install_domain_filter_fetch(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Fetch.enable",
|
||||
Some(json!({
|
||||
"patterns": [{ "urlPattern": "*" }]
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install both layers of domain filtering on a session:
|
||||
/// 1. JS patching (WebSocket, EventSource, sendBeacon)
|
||||
/// 2. Fetch-based network interception
|
||||
pub async fn install_domain_filter(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
) -> Result<(), String> {
|
||||
install_domain_filter_script(client, session_id, allowed_domains).await?;
|
||||
install_domain_filter_fetch(client, session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console and error tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConsoleEntry {
|
||||
pub level: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErrorEntry {
|
||||
pub text: String,
|
||||
pub url: Option<String>,
|
||||
pub line: Option<i64>,
|
||||
pub column: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct EventTracker {
|
||||
pub console_entries: Vec<ConsoleEntry>,
|
||||
pub error_entries: Vec<ErrorEntry>,
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
impl EventTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
console_entries: Vec::new(),
|
||||
error_entries: Vec::new(),
|
||||
max_entries: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_console(&mut self, level: &str, text: &str) {
|
||||
if self.console_entries.len() >= self.max_entries {
|
||||
self.console_entries.remove(0);
|
||||
}
|
||||
self.console_entries.push(ConsoleEntry {
|
||||
level: level.to_string(),
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_error(
|
||||
&mut self,
|
||||
text: &str,
|
||||
url: Option<&str>,
|
||||
line: Option<i64>,
|
||||
col: Option<i64>,
|
||||
) {
|
||||
if self.error_entries.len() >= self.max_entries {
|
||||
self.error_entries.remove(0);
|
||||
}
|
||||
self.error_entries.push(ErrorEntry {
|
||||
text: text.to_string(),
|
||||
url: url.map(String::from),
|
||||
line,
|
||||
column: col,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get_console_json(&self) -> Value {
|
||||
let entries: Vec<Value> = self
|
||||
.console_entries
|
||||
.iter()
|
||||
.map(|e| json!({ "level": e.level, "text": e.text }))
|
||||
.collect();
|
||||
json!({ "entries": entries })
|
||||
}
|
||||
|
||||
pub fn get_errors_json(&self) -> Value {
|
||||
let entries: Vec<Value> = self
|
||||
.error_entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"text": e.text,
|
||||
"url": e.url,
|
||||
"line": e.line,
|
||||
"column": e.column,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "errors": entries })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_exact() {
|
||||
let filter = DomainFilter::new("example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_wildcard() {
|
||||
let filter = DomainFilter::new("*.example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(filter.is_allowed("api.example.com"));
|
||||
assert!(filter.is_allowed("sub.api.example.com"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_empty() {
|
||||
let filter = DomainFilter::new("");
|
||||
assert!(filter.is_allowed("anything.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_multiple() {
|
||||
let filter = DomainFilter::new("example.com, *.api.io");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(filter.is_allowed("api.io"));
|
||||
assert!(filter.is_allowed("v1.api.io"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_domain_list() {
|
||||
let domains = parse_domain_list("A.com, B.com , *.C.com");
|
||||
assert_eq!(domains, vec!["a.com", "b.com", "*.c.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_tracker() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "hello");
|
||||
tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
|
||||
|
||||
assert_eq!(tracker.console_entries.len(), 1);
|
||||
assert_eq!(tracker.error_entries.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
//! Parity tests for the native daemon's command interface.
|
||||
//!
|
||||
//! These unit tests verify:
|
||||
//! - All documented actions are handled (not returning "Not yet implemented")
|
||||
//! - Response format consistency (success/error structure)
|
||||
//! - Credential and state actions work without a browser
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
|
||||
/// All documented action names that should be implemented.
|
||||
const DOCUMENTED_ACTIONS: &[&str] = &[
|
||||
"launch",
|
||||
"navigate",
|
||||
"url",
|
||||
"title",
|
||||
"content",
|
||||
"evaluate",
|
||||
"close",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"click",
|
||||
"dblclick",
|
||||
"fill",
|
||||
"type",
|
||||
"press",
|
||||
"hover",
|
||||
"scroll",
|
||||
"select",
|
||||
"check",
|
||||
"uncheck",
|
||||
"wait",
|
||||
"gettext",
|
||||
"getattribute",
|
||||
"isvisible",
|
||||
"isenabled",
|
||||
"ischecked",
|
||||
"back",
|
||||
"forward",
|
||||
"reload",
|
||||
"cookies_get",
|
||||
"cookies_set",
|
||||
"cookies_clear",
|
||||
"storage_get",
|
||||
"storage_set",
|
||||
"storage_clear",
|
||||
"setcontent",
|
||||
"headers",
|
||||
"offline",
|
||||
"console",
|
||||
"errors",
|
||||
"state_save",
|
||||
"state_load",
|
||||
"state_list",
|
||||
"state_show",
|
||||
"state_clear",
|
||||
"state_clean",
|
||||
"state_rename",
|
||||
"trace_start",
|
||||
"trace_stop",
|
||||
"profiler_start",
|
||||
"profiler_stop",
|
||||
"recording_start",
|
||||
"recording_stop",
|
||||
"recording_restart",
|
||||
"pdf",
|
||||
"tab_list",
|
||||
"tab_new",
|
||||
"tab_switch",
|
||||
"tab_close",
|
||||
"viewport",
|
||||
"user_agent",
|
||||
"set_media",
|
||||
"download",
|
||||
"diff_snapshot",
|
||||
"diff_url",
|
||||
"credentials_set",
|
||||
"credentials_get",
|
||||
"credentials_delete",
|
||||
"credentials_list",
|
||||
"mouse",
|
||||
"keyboard",
|
||||
"focus",
|
||||
"clear",
|
||||
"selectall",
|
||||
"scrollintoview",
|
||||
"dispatch",
|
||||
"highlight",
|
||||
"tap",
|
||||
"boundingbox",
|
||||
"innertext",
|
||||
"innerhtml",
|
||||
"inputvalue",
|
||||
"setvalue",
|
||||
"count",
|
||||
"styles",
|
||||
"bringtofront",
|
||||
"timezone",
|
||||
"locale",
|
||||
"geolocation",
|
||||
"permissions",
|
||||
"dialog",
|
||||
"upload",
|
||||
"addscript",
|
||||
"addinitscript",
|
||||
"addstyle",
|
||||
"clipboard",
|
||||
"wheel",
|
||||
"device",
|
||||
"screencast_start",
|
||||
"screencast_stop",
|
||||
"waitforurl",
|
||||
"waitforloadstate",
|
||||
"waitforfunction",
|
||||
"frame",
|
||||
"mainframe",
|
||||
"getbyrole",
|
||||
"getbytext",
|
||||
"getbylabel",
|
||||
"getbyplaceholder",
|
||||
"getbyalttext",
|
||||
"getbytitle",
|
||||
"getbytestid",
|
||||
"nth",
|
||||
"find",
|
||||
"evalhandle",
|
||||
"drag",
|
||||
"expose",
|
||||
"pause",
|
||||
"multiselect",
|
||||
"responsebody",
|
||||
"waitfordownload",
|
||||
"window_new",
|
||||
"diff_screenshot",
|
||||
"video_start",
|
||||
"video_stop",
|
||||
"har_start",
|
||||
"har_stop",
|
||||
"route",
|
||||
"unroute",
|
||||
"requests",
|
||||
"credentials",
|
||||
"auth_save",
|
||||
"auth_login",
|
||||
"auth_list",
|
||||
"auth_delete",
|
||||
"auth_show",
|
||||
"confirm",
|
||||
"deny",
|
||||
"swipe",
|
||||
"device_list",
|
||||
"input_mouse",
|
||||
"input_keyboard",
|
||||
"input_touch",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"inserttext",
|
||||
"mousemove",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
];
|
||||
|
||||
fn minimal_command(action: &str, id: &str) -> Value {
|
||||
let mut cmd = json!({ "action": action, "id": id });
|
||||
let obj = cmd.as_object_mut().unwrap();
|
||||
|
||||
match action {
|
||||
"navigate" | "diff_url" | "waitforurl" => {
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"evaluate" | "expose" => {
|
||||
obj.insert("script".to_string(), json!("1"));
|
||||
}
|
||||
"click" | "dblclick" | "fill" | "type" | "press" | "hover" | "scroll" | "select"
|
||||
| "check" | "uncheck" | "gettext" | "getattribute" | "isvisible" | "isenabled"
|
||||
| "ischecked" | "focus" | "clear" | "selectall" | "scrollintoview" | "dispatch"
|
||||
| "highlight" | "tap" | "boundingbox" | "innertext" | "innerhtml" | "inputvalue"
|
||||
| "setvalue" | "count" | "find" | "nth" | "getbytext" | "getbylabel"
|
||||
| "getbyplaceholder" | "getbyalttext" | "getbytitle" | "getbytestid" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"getbyrole" => {
|
||||
obj.insert("role".to_string(), json!("button"));
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"setcontent" => {
|
||||
obj.insert("html".to_string(), json!("<html></html>"));
|
||||
}
|
||||
"cookies_set" => {
|
||||
obj.insert("name".to_string(), json!("test"));
|
||||
obj.insert("value".to_string(), json!("val"));
|
||||
}
|
||||
"storage_get" | "storage_set" | "storage_clear" => {
|
||||
obj.insert("origin".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"state_save" | "state_load" | "state_show" | "state_clear" => {
|
||||
obj.insert("path".to_string(), json!("test-parity-state.json"));
|
||||
}
|
||||
"state_rename" => {
|
||||
obj.insert("path".to_string(), json!("test-parity-state.json"));
|
||||
obj.insert("name".to_string(), json!("renamed"));
|
||||
}
|
||||
"state_clean" => {
|
||||
obj.insert("days".to_string(), json!(7));
|
||||
}
|
||||
"credentials_set" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"auth_save" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"credentials_get" | "credentials_delete" | "auth_show" | "auth_delete" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
}
|
||||
"tab_switch" | "tab_close" => {
|
||||
obj.insert("index".to_string(), json!(0));
|
||||
}
|
||||
"viewport" | "user_agent" | "set_media" | "timezone" | "locale" | "geolocation"
|
||||
| "permissions" | "device" => {
|
||||
obj.insert("value".to_string(), json!(null));
|
||||
}
|
||||
"headers" => {
|
||||
obj.insert("headers".to_string(), json!({}));
|
||||
}
|
||||
"offline" => {
|
||||
obj.insert("offline".to_string(), json!(false));
|
||||
}
|
||||
"wait" => {
|
||||
obj.insert("timeout".to_string(), json!(100));
|
||||
}
|
||||
"waitforloadstate" => {
|
||||
obj.insert("state".to_string(), json!("load"));
|
||||
}
|
||||
"waitforfunction" => {
|
||||
obj.insert("script".to_string(), json!("() => true"));
|
||||
}
|
||||
"frame" => {
|
||||
obj.insert("selector".to_string(), json!("iframe"));
|
||||
}
|
||||
"addscript" => {
|
||||
obj.insert("content".to_string(), json!("console.log('test')"));
|
||||
}
|
||||
"addinitscript" => {
|
||||
obj.insert("script".to_string(), json!("console.log('init')"));
|
||||
}
|
||||
"addstyle" => {
|
||||
obj.insert("content".to_string(), json!("body { color: red }"));
|
||||
}
|
||||
"wheel" => {
|
||||
obj.insert("deltaX".to_string(), json!(0));
|
||||
obj.insert("deltaY".to_string(), json!(0));
|
||||
}
|
||||
"upload" => {
|
||||
obj.insert("selector".to_string(), json!("input[type=file]"));
|
||||
obj.insert("files".to_string(), json!([]));
|
||||
}
|
||||
"dialog" => {
|
||||
obj.insert("accept".to_string(), json!(true));
|
||||
}
|
||||
"credentials" => {
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"auth_login" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
}
|
||||
"route" => {
|
||||
obj.insert("url".to_string(), json!("*"));
|
||||
obj.insert("handler".to_string(), json!("continue"));
|
||||
}
|
||||
"diff_snapshot" | "diff_screenshot" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"recording_start" | "recording_restart" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-recording.webm"));
|
||||
}
|
||||
"video_start" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-video.webm"));
|
||||
}
|
||||
"profiler_start" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-profile"));
|
||||
}
|
||||
"trace_stop" | "har_stop" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-trace"));
|
||||
}
|
||||
"download" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-download"));
|
||||
}
|
||||
"multiselect" => {
|
||||
obj.insert("selector".to_string(), json!("select"));
|
||||
obj.insert("values".to_string(), json!([]));
|
||||
}
|
||||
"responsebody" => {
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"waitfordownload" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-download"));
|
||||
}
|
||||
"styles" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("names".to_string(), json!([]));
|
||||
}
|
||||
"evalhandle" => {
|
||||
obj.insert("handle".to_string(), json!(""));
|
||||
obj.insert("script".to_string(), json!("h => h"));
|
||||
}
|
||||
"drag" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("target".to_string(), json!("body"));
|
||||
}
|
||||
"swipe" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("direction".to_string(), json!("left"));
|
||||
}
|
||||
"input_mouse" | "mousemove" | "mousedown" | "mouseup" => {
|
||||
obj.insert("x".to_string(), json!(100));
|
||||
obj.insert("y".to_string(), json!(100));
|
||||
}
|
||||
"input_keyboard" | "keydown" | "keyup" => {
|
||||
obj.insert("key".to_string(), json!("a"));
|
||||
}
|
||||
"input_touch" => {
|
||||
obj.insert("type".to_string(), json!("touchStart"));
|
||||
obj.insert("touchPoints".to_string(), json!([]));
|
||||
}
|
||||
"inserttext" => {
|
||||
obj.insert("text".to_string(), json!("test"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Action dispatch coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_documented_actions_are_handled() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
|
||||
let id = format!("parity-{}", i);
|
||||
let cmd = minimal_command(action, &id);
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert!(
|
||||
result.get("id").is_some(),
|
||||
"Action '{}': response missing 'id'",
|
||||
action
|
||||
);
|
||||
|
||||
let error = result.get("error").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
assert!(
|
||||
!error.contains("Not yet implemented"),
|
||||
"Action '{}' returned 'Not yet implemented')",
|
||||
action
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response format consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_success_response_format() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_list", "id": "fmt-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result.get("id").is_some());
|
||||
assert!(result.get("data").is_some());
|
||||
assert!(result.get("error").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_response_format() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "nonexistent_action_xyz", "id": "fmt-2" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], false);
|
||||
assert!(result.get("id").is_some());
|
||||
assert!(result.get("error").is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Credential/state actions work without a browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_list_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_list", "id": "nb-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result["data"]["files"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_list_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "credentials_list", "id": "nb-2" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result["data"]["credentials"].is_array() || result["data"]["profiles"].is_array());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. New feature parity tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_profile_name_validation() {
|
||||
use super::auth;
|
||||
let valid = auth::credentials_set("valid-name_123", "u", "p", None);
|
||||
assert!(valid.is_ok());
|
||||
let invalid = auth::credentials_set("invalid/name", "u", "p", None);
|
||||
assert!(invalid.is_err());
|
||||
let invalid2 = auth::credentials_set("", "u", "p", None);
|
||||
assert!(invalid2.is_err());
|
||||
let invalid3 = auth::credentials_set("has space", "u", "p", None);
|
||||
assert!(invalid3.is_err());
|
||||
// Cleanup
|
||||
let _ = auth::credentials_delete("valid-name_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_save_and_show() {
|
||||
use super::auth;
|
||||
let result = auth::auth_save(
|
||||
"parity-roundtrip",
|
||||
"https://example.com",
|
||||
"user",
|
||||
"pass",
|
||||
Some("input#user"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let show = auth::auth_show("parity-roundtrip");
|
||||
assert!(show.is_ok());
|
||||
let data = show.unwrap();
|
||||
assert_eq!(data["profile"]["username"], "user");
|
||||
assert_eq!(data["profile"]["usernameSelector"], "input#user");
|
||||
|
||||
let full = auth::credentials_get_full("parity-roundtrip");
|
||||
assert!(full.is_ok());
|
||||
assert_eq!(full.unwrap().password, "pass");
|
||||
|
||||
// Cleanup
|
||||
let _ = auth::credentials_delete("parity-roundtrip");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_har_start_stop_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
// har_start requires a browser. Because execute_command auto-launches when
|
||||
// no browser is present, the result depends on Chrome availability: success
|
||||
// if Chrome is found (CI), failure if not. Both outcomes are valid.
|
||||
let cmd = json!({ "action": "har_start", "id": "har-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
let success = result["success"].as_bool().unwrap_or(false);
|
||||
if success {
|
||||
assert!(state.har_recording);
|
||||
} else {
|
||||
assert!(result["error"].as_str().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_clean_action() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_clean", "id": "clean-1", "days": 30 });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
assert_eq!(result["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_daemon_state_new_defaults() {
|
||||
let state = DaemonState::new();
|
||||
assert!(state.browser.is_none());
|
||||
assert!(!state.har_recording);
|
||||
assert!(state.har_entries.is_empty());
|
||||
assert!(state.pending_confirmation.is_none());
|
||||
assert!(!state.request_tracking);
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
assert!(state.active_frame_id.is_none());
|
||||
assert!(state.webdriver_backend.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tracked_request_struct() {
|
||||
use super::actions::TrackedRequest;
|
||||
let tr = TrackedRequest {
|
||||
url: "https://example.com/api".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: json!({"Accept": "text/html"}),
|
||||
timestamp: 12345,
|
||||
resource_type: "Document".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_value(&tr).unwrap();
|
||||
assert_eq!(serialized["url"], "https://example.com/api");
|
||||
assert_eq!(serialized["method"], "GET");
|
||||
assert_eq!(serialized["resourceType"], "Document");
|
||||
assert_eq!(serialized["timestamp"], 12345);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_tracking_state() {
|
||||
let mut state = DaemonState::new();
|
||||
assert!(!state.request_tracking);
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://example.com".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: json!({}),
|
||||
timestamp: 1,
|
||||
resource_type: "Document".to_string(),
|
||||
});
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://other.com".to_string(),
|
||||
method: "POST".to_string(),
|
||||
headers: json!({}),
|
||||
timestamp: 2,
|
||||
resource_type: "XHR".to_string(),
|
||||
});
|
||||
assert_eq!(state.tracked_requests.len(), 2);
|
||||
|
||||
// Filter
|
||||
let filtered: Vec<_> = state
|
||||
.tracked_requests
|
||||
.iter()
|
||||
.filter(|r| r.url.contains("example"))
|
||||
.collect();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].url, "https://example.com");
|
||||
|
||||
// Clear
|
||||
state.tracked_requests.clear();
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addscript_and_addinitscript_separate_dispatch() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Both should be handled (not "Not yet implemented") even without a browser
|
||||
let cmd1 = json!({ "action": "addscript", "id": "as-1", "content": "console.log(1)" });
|
||||
let result1 = execute_command(&cmd1, &mut state).await;
|
||||
let err1 = result1["error"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!err1.contains("Not yet implemented"),
|
||||
"addscript should be handled"
|
||||
);
|
||||
|
||||
let cmd2 = json!({ "action": "addinitscript", "id": "ais-1", "script": "console.log(2)" });
|
||||
let result2 = execute_command(&cmd2, &mut state).await;
|
||||
let err2 = result2["error"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!err2.contains("Not yet implemented"),
|
||||
"addinitscript should be handled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_frame_context_management() {
|
||||
let mut state = DaemonState::new();
|
||||
assert!(state.active_frame_id.is_none());
|
||||
|
||||
// Set a frame ID and verify it persists
|
||||
state.active_frame_id = Some("child-frame-123".to_string());
|
||||
assert_eq!(state.active_frame_id.as_deref(), Some("child-frame-123"));
|
||||
|
||||
// Clearing the frame ID (what mainframe does)
|
||||
state.active_frame_id = None;
|
||||
assert!(state.active_frame_id.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addstyle_supports_content_and_url() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Both content-based and url-based addstyle should be recognized
|
||||
let cmd1 = json!({ "action": "addstyle", "id": "style-1", "content": "body { color: red }" });
|
||||
let result1 = execute_command(&cmd1, &mut state).await;
|
||||
let err1 = result1["error"].as_str().unwrap_or("");
|
||||
assert!(!err1.contains("Not yet implemented"));
|
||||
|
||||
let cmd2 =
|
||||
json!({ "action": "addstyle", "id": "style-2", "url": "https://example.com/style.css" });
|
||||
let result2 = execute_command(&cmd2, &mut state).await;
|
||||
let err2 = result2["error"].as_str().unwrap_or("");
|
||||
assert!(!err2.contains("Not yet implemented"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_domain_filter_sanitize() {
|
||||
use super::network::DomainFilter;
|
||||
let filter = DomainFilter::new("example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(!filter.is_allowed("evil.com"));
|
||||
filter.check_url("https://example.com/path").unwrap();
|
||||
assert!(filter.check_url("https://evil.com").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_find_auto_returns_none_for_nonexistent() {
|
||||
use super::state;
|
||||
let result = state::find_auto_state_file("nonexistent-session-xyz");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Result of a policy check for an action.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PolicyResult {
|
||||
/// Action is allowed.
|
||||
Allow,
|
||||
/// Action is blocked with the given reason.
|
||||
Deny(String),
|
||||
/// Action requires confirmation before proceeding.
|
||||
RequiresConfirmation,
|
||||
}
|
||||
|
||||
/// Policy configuration loaded from a JSON file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionPolicy {
|
||||
#[serde(skip)]
|
||||
path: PathBuf,
|
||||
#[serde(default)]
|
||||
default: Option<String>,
|
||||
#[serde(default)]
|
||||
allow: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
deny: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
confirm: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Confirmation categories parsed from AGENT_BROWSER_CONFIRM_ACTIONS.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfirmActions {
|
||||
pub categories: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ConfirmActions {
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let val = env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()?;
|
||||
if val.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let categories: HashSet<String> = val
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if categories.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self { categories })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_confirmation(&self, action: &str) -> bool {
|
||||
self.categories.contains(action)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionPolicy {
|
||||
/// Load policy from a JSON file at the given path.
|
||||
pub fn load(path: &str) -> Result<Self, String> {
|
||||
let path_buf = PathBuf::from(path);
|
||||
let contents = fs::read_to_string(&path_buf)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = path_buf;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// Load policy if AGENT_BROWSER_ACTION_POLICY env var is set.
|
||||
/// Falls back to AGENT_BROWSER_POLICY for backwards compatibility.
|
||||
pub fn load_if_exists() -> Option<Self> {
|
||||
let path = env::var("AGENT_BROWSER_ACTION_POLICY")
|
||||
.or_else(|_| env::var("AGENT_BROWSER_POLICY"))
|
||||
.ok()?;
|
||||
Self::load(&path).ok()
|
||||
}
|
||||
|
||||
/// Check whether an action is allowed, denied, or requires confirmation.
|
||||
pub fn check(&self, action: &str) -> PolicyResult {
|
||||
if let Some(deny) = &self.deny {
|
||||
if deny.iter().any(|a| a == action) {
|
||||
return PolicyResult::Deny(format!("Action '{}' is denied by policy", action));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(confirm) = &self.confirm {
|
||||
if confirm.iter().any(|a| a == action) {
|
||||
return PolicyResult::RequiresConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(allow) = &self.allow {
|
||||
if !allow.is_empty() && !allow.iter().any(|a| a == action) {
|
||||
let is_default_deny = self
|
||||
.default
|
||||
.as_deref()
|
||||
.map(|d| d.eq_ignore_ascii_case("deny"))
|
||||
.unwrap_or(true);
|
||||
if is_default_deny {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' is not in the allow list",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(ref default) = self.default {
|
||||
if default.eq_ignore_ascii_case("deny") {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' denied: default policy is deny",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
PolicyResult::Allow
|
||||
}
|
||||
|
||||
/// Reload policy from the file. Re-reads the JSON and updates the policy.
|
||||
pub fn reload(&mut self) -> Result<(), String> {
|
||||
let contents = fs::read_to_string(&self.path)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = self.path.clone();
|
||||
*self = policy;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_policy_allow_whitelist() {
|
||||
let json = r#"{"allow": ["click", "type"], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert_eq!(policy.check("type"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny() {
|
||||
let json = r#"{"allow": [], "deny": ["delete"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("delete"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny_takes_precedence() {
|
||||
let json = r#"{"allow": ["danger"], "deny": ["danger"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("danger"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm_takes_precedence_over_allow() {
|
||||
let json = r#"{"allow": ["submit"], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_empty_allow_allows_all() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_missing_allow_allows_all() {
|
||||
let json = r#"{"deny": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_allow() {
|
||||
let json = r#"{"default": "allow", "deny": ["navigate"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_deny() {
|
||||
let json = r#"{"default": "deny", "allow": ["click"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confirm_actions_from_env() {
|
||||
env::set_var("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let ca = ConfirmActions::from_env().unwrap();
|
||||
assert!(ca.requires_confirmation("navigate"));
|
||||
assert!(ca.requires_confirmation("click"));
|
||||
assert!(ca.requires_confirmation("fill"));
|
||||
assert!(!ca.requires_confirmation("screenshot"));
|
||||
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Browser provider connections for remote CDP sessions.
|
||||
//!
|
||||
//! Supports Browserbase, Browser Use, and Kernel providers. Each provider
|
||||
//! returns a CDP WebSocket URL for connecting via BrowserManager.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
|
||||
/// Provider session info for cleanup on failure.
|
||||
pub struct ProviderSession {
|
||||
pub provider: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Connects to the specified browser provider and returns a CDP WebSocket URL
|
||||
/// along with session info for cleanup on failure.
|
||||
pub async fn connect_provider(
|
||||
provider_name: &str,
|
||||
) -> Result<(String, Option<ProviderSession>), String> {
|
||||
match provider_name.to_lowercase().as_str() {
|
||||
"browserbase" => connect_browserbase().await,
|
||||
"browser-use" | "browseruse" => connect_browser_use().await,
|
||||
"kernel" => connect_kernel().await,
|
||||
_ => Err(format!(
|
||||
"Unknown provider '{}'. Supported: browserbase, browser-use, kernel",
|
||||
provider_name
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a provider session (call on CDP connect failure).
|
||||
pub async fn close_provider_session(session: &ProviderSession) {
|
||||
let client = reqwest::Client::new();
|
||||
match session.provider.as_str() {
|
||||
"browserbase" => {
|
||||
if let Ok(api_key) = env::var("BROWSERBASE_API_KEY") {
|
||||
let _ = client
|
||||
.delete(format!(
|
||||
"https://api.browserbase.com/v1/sessions/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"browser-use" => {
|
||||
if let Ok(api_key) = env::var("BROWSER_USE_API_KEY") {
|
||||
let _ = client
|
||||
.patch(format!(
|
||||
"https://api.browser-use.com/api/v2/browsers/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("X-Browser-Use-API-Key", &api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({ "action": "stop" }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"kernel" => {
|
||||
if let Ok(api_key) = env::var("KERNEL_API_KEY") {
|
||||
let endpoint = env::var("KERNEL_ENDPOINT")
|
||||
.unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
let _ = client
|
||||
.delete(format!(
|
||||
"{}/browsers/{}",
|
||||
endpoint.trim_end_matches('/'),
|
||||
session.session_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERBASE_API_KEY")
|
||||
.map_err(|_| "BROWSERBASE_API_KEY environment variable is not set")?;
|
||||
let project_id = env::var("BROWSERBASE_PROJECT_ID")
|
||||
.map_err(|_| "BROWSERBASE_PROJECT_ID environment variable is not set")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browserbase.com/v1/sessions")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.json(&json!({ "projectId": project_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserbase request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browserbase response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browserbase API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserbase response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("connectUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserbase response missing connectUrl".to_string())?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browserbase".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSER_USE_API_KEY")
|
||||
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browser-use.com/api/v2/browsers")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Browser-Use-API-Key", &api_key)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browser Use request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browser Use response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browser Use API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browser Use response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("cdp_url")
|
||||
.or_else(|| json.get("cdpUrl"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browser Use response missing cdp_url or cdpUrl".to_string())?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browser-use".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key =
|
||||
env::var("KERNEL_API_KEY").map_err(|_| "KERNEL_API_KEY environment variable is not set")?;
|
||||
let endpoint =
|
||||
env::var("KERNEL_ENDPOINT").unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
|
||||
let url = format!("{}/browsers", endpoint.trim_end_matches('/'));
|
||||
|
||||
let headless = env::var("KERNEL_HEADLESS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
let stealth = env::var("KERNEL_STEALTH")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let timeout_seconds = env::var("KERNEL_TIMEOUT_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(300);
|
||||
|
||||
let mut body = json!({
|
||||
"headless": headless,
|
||||
"stealth": stealth,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
});
|
||||
|
||||
if let Ok(profile) = env::var("KERNEL_PROFILE_NAME") {
|
||||
if !profile.is_empty() {
|
||||
body.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("profile".to_string(), json!(profile));
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Kernel request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Kernel response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Kernel API error ({}): {}",
|
||||
status.as_u16(),
|
||||
resp_body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid Kernel response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("session_id")
|
||||
.or_else(|| json.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("cdp_ws_url")
|
||||
.or_else(|| json.get("connectUrl"))
|
||||
.or_else(|| json.get("connect_url"))
|
||||
.or_else(|| json.get("cdpUrl"))
|
||||
.or_else(|| json.get("cdp_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| {
|
||||
"Kernel response missing cdp_ws_url, connectUrl, connect_url, cdpUrl, or cdp_url"
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "kernel".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct RecordingState {
|
||||
pub active: bool,
|
||||
pub output_path: String,
|
||||
pub temp_dir: PathBuf,
|
||||
pub frame_count: u64,
|
||||
}
|
||||
|
||||
impl RecordingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
output_path: String::new(),
|
||||
temp_dir: PathBuf::new(),
|
||||
frame_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
if state.active {
|
||||
return Err("Recording already active".to_string());
|
||||
}
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
|
||||
let temp_dir = std::env::temp_dir().join(format!("agent-browser-recording-{}", timestamp));
|
||||
let _ = std::fs::create_dir_all(&temp_dir);
|
||||
|
||||
state.active = true;
|
||||
state.output_path = path.to_string();
|
||||
state.temp_dir = temp_dir;
|
||||
state.frame_count = 0;
|
||||
|
||||
Ok(json!({ "started": true, "path": path }))
|
||||
}
|
||||
|
||||
pub fn recording_add_frame(state: &mut RecordingState, frame_data: &[u8]) {
|
||||
if !state.active {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame_path = state
|
||||
.temp_dir
|
||||
.join(format!("frame_{:06}.jpg", state.frame_count));
|
||||
let _ = std::fs::write(&frame_path, frame_data);
|
||||
state.frame_count += 1;
|
||||
}
|
||||
|
||||
pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
|
||||
if !state.active {
|
||||
return Err("No recording in progress".to_string());
|
||||
}
|
||||
|
||||
state.active = false;
|
||||
|
||||
if state.frame_count == 0 {
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
return Err("No frames captured".to_string());
|
||||
}
|
||||
|
||||
let frame_pattern = state
|
||||
.temp_dir
|
||||
.join("frame_%06d.jpg")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let output = &state.output_path;
|
||||
|
||||
// Encode with ffmpeg
|
||||
let result = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-framerate",
|
||||
"30",
|
||||
"-i",
|
||||
&frame_pattern,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-preset",
|
||||
"fast",
|
||||
output,
|
||||
])
|
||||
.output();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
|
||||
match result {
|
||||
Ok(output_result) => {
|
||||
if output_result.status.success() {
|
||||
Ok(json!({ "path": output, "frames": state.frame_count }))
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output_result.stderr);
|
||||
Err(format!(
|
||||
"ffmpeg failed: {}",
|
||||
stderr.chars().take(200).collect::<String>()
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
|
||||
e
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_recording_state_new() {
|
||||
let state = RecordingState::new();
|
||||
assert!(!state.active);
|
||||
assert!(state.output_path.is_empty());
|
||||
assert_eq!(state.frame_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_sets_active() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_start(&mut state, "/tmp/test.mp4");
|
||||
assert!(result.is_ok());
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/test.mp4");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
|
||||
let temp_dir = state.temp_dir.clone();
|
||||
let result = recording_start(&mut state, "/tmp/test2.mp4");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("already active"));
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stop_not_active() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_stop(&mut state);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No recording"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stop_no_frames() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test.mp4").unwrap();
|
||||
let result = recording_stop(&mut state);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No frames"));
|
||||
assert!(!state.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_add_frame_inactive() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_add_frame(&mut state, b"fake-frame");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_add_frame_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test.mp4").unwrap();
|
||||
recording_add_frame(&mut state, b"fake-frame-1");
|
||||
recording_add_frame(&mut state, b"fake-frame-2");
|
||||
assert_eq!(state.frame_count, 2);
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
let previous = if state.active {
|
||||
let stop_result = recording_stop(state);
|
||||
stop_result
|
||||
.ok()
|
||||
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
recording_start(state, path)?;
|
||||
|
||||
Ok(json!({
|
||||
"restarted": true,
|
||||
"previousPath": previous,
|
||||
"path": path,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::RefMap;
|
||||
|
||||
pub struct ScreenshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub full_page: bool,
|
||||
pub format: String,
|
||||
pub quality: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
path: None,
|
||||
full_page: false,
|
||||
format: "png".to_string(),
|
||||
quality: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn take_screenshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
) -> Result<(String, String), String> {
|
||||
let mut params = CaptureScreenshotParams {
|
||||
format: Some(options.format.clone()),
|
||||
quality: if options.format == "jpeg" {
|
||||
options.quality.or(Some(80))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
clip: None,
|
||||
from_surface: Some(true),
|
||||
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
|
||||
};
|
||||
|
||||
if options.full_page {
|
||||
let metrics: Value = client
|
||||
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let content_size = metrics
|
||||
.get("contentSize")
|
||||
.or_else(|| metrics.get("cssContentSize"));
|
||||
if let Some(size) = content_size {
|
||||
let width = size.get("width").and_then(|v| v.as_f64()).unwrap_or(1280.0);
|
||||
let height = size.get("height").and_then(|v| v.as_f64()).unwrap_or(720.0);
|
||||
|
||||
params.clip = Some(Viewport {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width,
|
||||
height,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
} else if let Some(ref selector) = options.selector {
|
||||
// Element screenshot via bounding box
|
||||
let object_id =
|
||||
super::element::resolve_element_object_id(client, session_id, ref_map, selector)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(rect) = result.result.value {
|
||||
let x = rect.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = rect.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let w = rect.get("width").and_then(|v| v.as_f64()).unwrap_or(100.0);
|
||||
let h = rect.get("height").and_then(|v| v.as_f64()).unwrap_or(100.0);
|
||||
|
||||
params.clip = Some(Viewport {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result: CaptureScreenshotResult = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(session_id))
|
||||
.await?;
|
||||
|
||||
let ext = if options.format == "jpeg" {
|
||||
"jpg"
|
||||
} else {
|
||||
"png"
|
||||
};
|
||||
|
||||
let save_path = match &options.path {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
let dir = get_screenshot_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let name = format!("screenshot-{}.{}", timestamp, ext);
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &result.data)
|
||||
.map_err(|e| format!("Failed to decode screenshot: {}", e))?;
|
||||
|
||||
std::fs::write(&save_path, &bytes)
|
||||
.map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
|
||||
|
||||
Ok((save_path, result.data))
|
||||
}
|
||||
|
||||
fn get_screenshot_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("screenshots")
|
||||
} else {
|
||||
std::env::temp_dir()
|
||||
.join("agent-browser")
|
||||
.join("screenshots")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{
|
||||
AXNode, AXProperty, AXValue, CallFunctionOnParams, EvaluateParams, EvaluateResult,
|
||||
GetFullAXTreeResult,
|
||||
};
|
||||
use super::element::RefMap;
|
||||
|
||||
const INTERACTIVE_ROLES: &[&str] = &[
|
||||
"button",
|
||||
"link",
|
||||
"textbox",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"combobox",
|
||||
"listbox",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"option",
|
||||
"searchbox",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"switch",
|
||||
"tab",
|
||||
"treeitem",
|
||||
];
|
||||
|
||||
const CONTENT_ROLES: &[&str] = &[
|
||||
"heading",
|
||||
"cell",
|
||||
"gridcell",
|
||||
"columnheader",
|
||||
"rowheader",
|
||||
"listitem",
|
||||
"article",
|
||||
"region",
|
||||
"main",
|
||||
"navigation",
|
||||
];
|
||||
|
||||
const STRUCTURAL_ROLES: &[&str] = &[
|
||||
"generic",
|
||||
"group",
|
||||
"list",
|
||||
"table",
|
||||
"row",
|
||||
"rowgroup",
|
||||
"grid",
|
||||
"treegrid",
|
||||
"menu",
|
||||
"menubar",
|
||||
"toolbar",
|
||||
"tablist",
|
||||
"tree",
|
||||
"directory",
|
||||
"document",
|
||||
"application",
|
||||
"presentation",
|
||||
"none",
|
||||
"WebArea",
|
||||
"RootWebArea",
|
||||
];
|
||||
|
||||
pub struct SnapshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub interactive: bool,
|
||||
pub compact: bool,
|
||||
pub depth: Option<usize>,
|
||||
pub cursor: bool,
|
||||
}
|
||||
|
||||
impl Default for SnapshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
interactive: false,
|
||||
compact: false,
|
||||
depth: None,
|
||||
cursor: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TreeNode {
|
||||
role: String,
|
||||
name: String,
|
||||
level: Option<i64>,
|
||||
checked: Option<String>,
|
||||
expanded: Option<bool>,
|
||||
selected: Option<bool>,
|
||||
disabled: Option<bool>,
|
||||
required: Option<bool>,
|
||||
value_text: Option<String>,
|
||||
backend_node_id: Option<i64>,
|
||||
children: Vec<usize>,
|
||||
has_ref: bool,
|
||||
ref_id: Option<String>,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
struct RoleNameTracker {
|
||||
counts: HashMap<String, usize>,
|
||||
entries: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
impl RoleNameTracker {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
counts: HashMap::new(),
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn track(&mut self, role: &str, name: &str, node_idx: usize) -> usize {
|
||||
let key = format!("{}:{}", role, name);
|
||||
let count = self.counts.entry(key.clone()).or_insert(0);
|
||||
let nth = *count;
|
||||
*count += 1;
|
||||
self.entries.push((node_idx, key));
|
||||
nth
|
||||
}
|
||||
|
||||
fn get_duplicates(&self) -> HashMap<String, usize> {
|
||||
self.counts
|
||||
.iter()
|
||||
.filter(|(_, &count)| count > 1)
|
||||
.map(|(key, &count)| (key.clone(), count))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn take_snapshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
options: &SnapshotOptions,
|
||||
ref_map: &mut RefMap,
|
||||
) -> Result<String, String> {
|
||||
client
|
||||
.send_command_no_params("DOM.enable", Some(session_id))
|
||||
.await?;
|
||||
client
|
||||
.send_command_no_params("Accessibility.enable", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let ax_tree: GetFullAXTreeResult = client
|
||||
.send_command_typed(
|
||||
"Accessibility.getFullAXTree",
|
||||
&serde_json::json!({}),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
|
||||
|
||||
let mut tracker = RoleNameTracker::new();
|
||||
let mut next_ref: usize = ref_map.next_ref_num();
|
||||
|
||||
let mut nodes_with_refs: Vec<(usize, usize)> = Vec::new();
|
||||
|
||||
for (idx, node) in tree_nodes.iter().enumerate() {
|
||||
let role = node.role.as_str();
|
||||
let should_ref = if INTERACTIVE_ROLES.contains(&role) {
|
||||
true
|
||||
} else if CONTENT_ROLES.contains(&role) {
|
||||
!node.name.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if should_ref {
|
||||
let nth = tracker.track(role, &node.name, idx);
|
||||
nodes_with_refs.push((idx, nth));
|
||||
}
|
||||
}
|
||||
|
||||
let duplicates = tracker.get_duplicates();
|
||||
|
||||
let mut tree_nodes = tree_nodes;
|
||||
for (idx, nth) in &nodes_with_refs {
|
||||
let node = &tree_nodes[*idx];
|
||||
let key = format!("{}:{}", node.role, node.name);
|
||||
let actual_nth = if duplicates.contains_key(&key) {
|
||||
Some(*nth)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let ref_id = format!("e{}", next_ref);
|
||||
next_ref += 1;
|
||||
|
||||
ref_map.add(
|
||||
ref_id.clone(),
|
||||
tree_nodes[*idx].backend_node_id,
|
||||
&tree_nodes[*idx].role,
|
||||
&tree_nodes[*idx].name,
|
||||
actual_nth,
|
||||
);
|
||||
|
||||
tree_nodes[*idx].has_ref = true;
|
||||
tree_nodes[*idx].ref_id = Some(ref_id);
|
||||
}
|
||||
|
||||
ref_map.set_next_ref_num(next_ref);
|
||||
|
||||
let mut output = String::new();
|
||||
for &root_idx in &root_indices {
|
||||
render_tree(&tree_nodes, root_idx, 0, &mut output, options);
|
||||
}
|
||||
|
||||
if options.compact {
|
||||
output = compact_tree(&output, options.interactive);
|
||||
}
|
||||
|
||||
let mut trimmed = output.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
if options.interactive {
|
||||
return Ok("(no interactive elements)".to_string());
|
||||
}
|
||||
return Ok("(empty page)".to_string());
|
||||
}
|
||||
|
||||
if options.cursor {
|
||||
let cursor_section = find_cursor_interactive_elements(client, session_id, ref_map).await?;
|
||||
if !cursor_section.is_empty() {
|
||||
trimmed.push_str("\n# Cursor-interactive elements:\n");
|
||||
trimmed.push_str(&cursor_section);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
async fn find_cursor_interactive_elements(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &mut RefMap,
|
||||
) -> Result<String, String> {
|
||||
let js = r#"
|
||||
(function() {
|
||||
const elements = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
if (node.closest && node.closest('[hidden], [aria-hidden="true"]')) continue;
|
||||
const explicitRole = node.getAttribute ? node.getAttribute('role') : null;
|
||||
if (explicitRole) continue;
|
||||
const tag = node.tagName ? node.tagName.toLowerCase() : '';
|
||||
const hasClick = node.onclick || (node.attributes && node.attributes.getNamedItem('onclick'));
|
||||
const tabindex = node.getAttribute ? node.getAttribute('tabindex') : null;
|
||||
const contentEditable = node.getAttribute ? node.getAttribute('contenteditable') : null;
|
||||
const isInherentlyClickable =
|
||||
(tag === 'a' && node.href) || tag === 'button' ||
|
||||
(tag === 'input' && ['submit','button','image','reset'].indexOf((node.type||'').toLowerCase()) >= 0) ||
|
||||
tag === 'summary';
|
||||
const isFocusable = tabindex !== null && parseInt(tabindex, 10) >= 0;
|
||||
const isEditable = contentEditable === '' || contentEditable === 'true';
|
||||
if (hasClick || isInherentlyClickable || isFocusable || isEditable) {
|
||||
elements.push(node);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
})()
|
||||
"#;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js.to_string(),
|
||||
return_by_value: Some(false),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let array_object_id = match result.result.object_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(String::new()),
|
||||
};
|
||||
|
||||
let props_result: Value = client
|
||||
.send_command(
|
||||
"Runtime.getProperties",
|
||||
Some(serde_json::json!({ "objectId": array_object_id })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let empty: Vec<Value> = Vec::new();
|
||||
let result_array = props_result
|
||||
.get("result")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty);
|
||||
|
||||
let mut indexed: Vec<(usize, String)> = Vec::new();
|
||||
for prop in result_array {
|
||||
let name = prop.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Ok(idx) = name.parse::<usize>() {
|
||||
if let Some(obj_id) = prop
|
||||
.get("value")
|
||||
.and_then(|v| v.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
indexed.push((idx, obj_id.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
indexed.sort_by_key(|(idx, _)| *idx);
|
||||
let element_object_ids: Vec<String> = indexed.into_iter().map(|(_, id)| id).collect();
|
||||
|
||||
let mut next_ref = ref_map.next_ref_num();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let get_text_js =
|
||||
r#"function(){ return (this.innerText || this.textContent || '').trim().slice(0, 100) }"#;
|
||||
|
||||
for object_id in &element_object_ids {
|
||||
let describe: Value = client
|
||||
.send_command(
|
||||
"DOM.describeNode",
|
||||
Some(serde_json::json!({ "objectId": object_id })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let backend_node_id = describe
|
||||
.get("node")
|
||||
.and_then(|n| n.get("backendNodeId"))
|
||||
.and_then(|v| v.as_i64());
|
||||
|
||||
let text_result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: get_text_js.to_string(),
|
||||
object_id: Some(object_id.clone()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = text_result
|
||||
.result
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let kind = "clickable";
|
||||
let ref_id = format!("e{}", next_ref);
|
||||
next_ref += 1;
|
||||
|
||||
ref_map.add(ref_id.clone(), backend_node_id, kind, &text, None);
|
||||
|
||||
let escaped = text
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', " ")
|
||||
.replace('\r', " ");
|
||||
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
|
||||
}
|
||||
|
||||
ref_map.set_next_ref_num(next_ref);
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
||||
let mut tree_nodes: Vec<TreeNode> = Vec::with_capacity(nodes.len());
|
||||
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
let role = extract_ax_string(&node.role);
|
||||
let name = extract_ax_string(&node.name);
|
||||
let value_text = extract_ax_string_opt(&node.value);
|
||||
|
||||
let (level, checked, expanded, selected, disabled, required) =
|
||||
extract_properties(&node.properties);
|
||||
|
||||
if node.ignored.unwrap_or(false) && role != "RootWebArea" {
|
||||
tree_nodes.push(TreeNode {
|
||||
role: String::new(),
|
||||
name: String::new(),
|
||||
level: None,
|
||||
checked: None,
|
||||
expanded: None,
|
||||
selected: None,
|
||||
disabled: None,
|
||||
required: None,
|
||||
value_text: None,
|
||||
backend_node_id: None,
|
||||
children: Vec::new(),
|
||||
has_ref: false,
|
||||
ref_id: None,
|
||||
depth: 0,
|
||||
});
|
||||
id_to_idx.insert(node.node_id.clone(), i);
|
||||
continue;
|
||||
}
|
||||
|
||||
tree_nodes.push(TreeNode {
|
||||
role,
|
||||
name,
|
||||
level,
|
||||
checked,
|
||||
expanded,
|
||||
selected,
|
||||
disabled,
|
||||
required,
|
||||
value_text,
|
||||
backend_node_id: node.backend_d_o_m_node_id,
|
||||
children: Vec::new(),
|
||||
has_ref: false,
|
||||
ref_id: None,
|
||||
depth: 0,
|
||||
});
|
||||
id_to_idx.insert(node.node_id.clone(), i);
|
||||
}
|
||||
|
||||
// Build parent-child relationships
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
if let Some(ref child_ids) = node.child_ids {
|
||||
for cid in child_ids {
|
||||
if let Some(&child_idx) = id_to_idx.get(cid) {
|
||||
tree_nodes[i].children.push(child_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set depths
|
||||
let mut root_indices = Vec::new();
|
||||
let children_exist: Vec<bool> = nodes.iter().map(|_| false).collect();
|
||||
let mut is_child = children_exist;
|
||||
for node in &tree_nodes {
|
||||
for &child in &node.children {
|
||||
is_child[child] = true;
|
||||
}
|
||||
}
|
||||
for (i, &is_c) in is_child.iter().enumerate() {
|
||||
if !is_c {
|
||||
root_indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_depth(nodes: &mut [TreeNode], idx: usize, depth: usize) {
|
||||
nodes[idx].depth = depth;
|
||||
let children: Vec<usize> = nodes[idx].children.clone();
|
||||
for child_idx in children {
|
||||
set_depth(nodes, child_idx, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for &root in &root_indices {
|
||||
set_depth(&mut tree_nodes, root, 0);
|
||||
}
|
||||
|
||||
(tree_nodes, root_indices)
|
||||
}
|
||||
|
||||
fn render_tree(
|
||||
nodes: &[TreeNode],
|
||||
idx: usize,
|
||||
indent: usize,
|
||||
output: &mut String,
|
||||
options: &SnapshotOptions,
|
||||
) {
|
||||
let node = &nodes[idx];
|
||||
|
||||
if node.role.is_empty() {
|
||||
// Ignored node -- still render children
|
||||
for &child in &node.children {
|
||||
render_tree(nodes, child, indent, output, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(max_depth) = options.depth {
|
||||
if indent > max_depth {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let role = &node.role;
|
||||
|
||||
// Skip root WebArea wrapper
|
||||
if role == "RootWebArea" || role == "WebArea" {
|
||||
for &child in &node.children {
|
||||
render_tree(nodes, child, indent, output, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if options.interactive && !node.has_ref {
|
||||
// In interactive mode, skip non-interactive but render children
|
||||
for &child in &node.children {
|
||||
render_tree(nodes, child, indent, output, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = " ".repeat(indent);
|
||||
let mut line = format!("{}- {}", prefix, role);
|
||||
|
||||
if !node.name.is_empty() {
|
||||
line.push_str(&format!(" \"{}\"", node.name));
|
||||
}
|
||||
|
||||
// Properties
|
||||
let mut attrs = Vec::new();
|
||||
|
||||
if let Some(level) = node.level {
|
||||
attrs.push(format!("level={}", level));
|
||||
}
|
||||
if let Some(ref checked) = node.checked {
|
||||
attrs.push(format!("checked={}", checked));
|
||||
}
|
||||
if let Some(expanded) = node.expanded {
|
||||
attrs.push(format!("expanded={}", expanded));
|
||||
}
|
||||
if let Some(selected) = node.selected {
|
||||
if selected {
|
||||
attrs.push("selected".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(disabled) = node.disabled {
|
||||
if disabled {
|
||||
attrs.push("disabled".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(required) = node.required {
|
||||
if required {
|
||||
attrs.push("required".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ref_id) = node.ref_id {
|
||||
attrs.push(format!("ref={}", ref_id));
|
||||
}
|
||||
|
||||
if !attrs.is_empty() {
|
||||
line.push_str(&format!(" [{}]", attrs.join(", ")));
|
||||
}
|
||||
|
||||
// Value
|
||||
if let Some(ref val) = node.value_text {
|
||||
if !val.is_empty() && val != &node.name {
|
||||
line.push_str(&format!(": {}", val));
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str(&line);
|
||||
output.push('\n');
|
||||
|
||||
for &child in &node.children {
|
||||
render_tree(nodes, child, indent + 1, output, options);
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_tree(tree: &str, interactive: bool) -> String {
|
||||
let lines: Vec<&str> = tree.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut keep = vec![false; lines.len()];
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if line.contains("[ref=") || line.contains(": ") {
|
||||
keep[i] = true;
|
||||
// Mark ancestors
|
||||
let my_indent = count_indent(line);
|
||||
for j in (0..i).rev() {
|
||||
let ancestor_indent = count_indent(lines[j]);
|
||||
if ancestor_indent < my_indent {
|
||||
keep[j] = true;
|
||||
if ancestor_indent == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result: Vec<&str> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| keep[*i])
|
||||
.map(|(_, line)| *line)
|
||||
.collect();
|
||||
|
||||
let output = result.join("\n");
|
||||
if output.trim().is_empty() && interactive {
|
||||
return "(no interactive elements)".to_string();
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn count_indent(line: &str) -> usize {
|
||||
let trimmed = line.trim_start();
|
||||
(line.len() - trimmed.len()) / 2
|
||||
}
|
||||
|
||||
fn extract_ax_string(value: &Option<AXValue>) -> String {
|
||||
match value {
|
||||
Some(v) => match &v.value {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::Bool(b)) => b.to_string(),
|
||||
_ => String::new(),
|
||||
},
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_ax_string_opt(value: &Option<AXValue>) -> Option<String> {
|
||||
match value {
|
||||
Some(v) => match &v.value {
|
||||
Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
_ => None,
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
type NodeProperties = (
|
||||
Option<i64>, // level
|
||||
Option<String>, // checked
|
||||
Option<bool>, // expanded
|
||||
Option<bool>, // selected
|
||||
Option<bool>, // disabled
|
||||
Option<bool>, // required
|
||||
);
|
||||
|
||||
fn extract_properties(props: &Option<Vec<AXProperty>>) -> NodeProperties {
|
||||
let mut level = None;
|
||||
let mut checked = None;
|
||||
let mut expanded = None;
|
||||
let mut selected = None;
|
||||
let mut disabled = None;
|
||||
let mut required = None;
|
||||
|
||||
if let Some(properties) = props {
|
||||
for prop in properties {
|
||||
match prop.name.as_str() {
|
||||
"level" => {
|
||||
level = prop.value.value.as_ref().and_then(|v| v.as_i64());
|
||||
}
|
||||
"checked" => {
|
||||
checked = prop.value.value.as_ref().map(|v| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
_ => "false".to_string(),
|
||||
});
|
||||
}
|
||||
"expanded" => {
|
||||
expanded = prop.value.value.as_ref().and_then(|v| v.as_bool());
|
||||
}
|
||||
"selected" => {
|
||||
selected = prop.value.value.as_ref().and_then(|v| v.as_bool());
|
||||
}
|
||||
"disabled" => {
|
||||
disabled = prop.value.value.as_ref().and_then(|v| v.as_bool());
|
||||
}
|
||||
"required" => {
|
||||
required = prop.value.value.as_ref().and_then(|v| v.as_bool());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(level, checked, expanded, selected, disabled, required)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_interactive_roles() {
|
||||
assert!(INTERACTIVE_ROLES.contains(&"button"));
|
||||
assert!(INTERACTIVE_ROLES.contains(&"textbox"));
|
||||
assert!(!INTERACTIVE_ROLES.contains(&"heading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_roles() {
|
||||
assert!(CONTENT_ROLES.contains(&"heading"));
|
||||
assert!(!CONTENT_ROLES.contains(&"button"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_tree_basic() {
|
||||
let tree = "- navigation\n - link \"Home\" [ref=e1]\n - link \"About\" [ref=e2]\n- main\n - heading \"Title\"\n - paragraph\n - text: Hello\n";
|
||||
let result = compact_tree(tree, false);
|
||||
assert!(result.contains("[ref=e1]"));
|
||||
assert!(result.contains("[ref=e2]"));
|
||||
assert!(result.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_tree_empty_interactive() {
|
||||
let result = compact_tree("- generic\n", true);
|
||||
assert_eq!(result, "(no interactive elements)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_indent() {
|
||||
assert_eq!(count_indent("- heading"), 0);
|
||||
assert_eq!(count_indent(" - link"), 1);
|
||||
assert_eq!(count_indent(" - text"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_role_name_tracker() {
|
||||
let mut tracker = RoleNameTracker::new();
|
||||
assert_eq!(tracker.track("button", "Submit", 0), 0);
|
||||
assert_eq!(tracker.track("button", "Submit", 1), 1);
|
||||
assert_eq!(tracker.track("button", "Cancel", 2), 0);
|
||||
|
||||
let dups = tracker.get_duplicates();
|
||||
assert!(dups.contains_key("button:Submit"));
|
||||
assert!(!dups.contains_key("button:Cancel"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::EvaluateParams;
|
||||
use super::cookies::{self, Cookie};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageState {
|
||||
pub cookies: Vec<Cookie>,
|
||||
pub origins: Vec<OriginStorage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OriginStorage {
|
||||
pub origin: String,
|
||||
pub local_storage: Vec<StorageEntry>,
|
||||
#[serde(default)]
|
||||
pub session_storage: Vec<StorageEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageEntry {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub async fn save_state(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
path: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
session_id_str: &str,
|
||||
) -> Result<String, String> {
|
||||
let cookies = cookies::get_cookies(client, session_id, None).await?;
|
||||
|
||||
// Get current origin's storage
|
||||
let origin_js = r#"(() => {
|
||||
const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
result.localStorage.push({ name: key, value: localStorage.getItem(key) });
|
||||
}
|
||||
} catch(e) {}
|
||||
try {
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
result.sessionStorage.push({ name: key, value: sessionStorage.getItem(key) });
|
||||
}
|
||||
} catch(e) {}
|
||||
return result;
|
||||
})()"#;
|
||||
|
||||
let origin_result: super::cdp::types::EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: origin_js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let origin_data = origin_result.result.value.unwrap_or(Value::Null);
|
||||
let origins = if origin_data.is_object() {
|
||||
let origin = origin_data
|
||||
.get("origin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let local_storage: Vec<StorageEntry> = origin_data
|
||||
.get("localStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let session_storage: Vec<StorageEntry> = origin_data
|
||||
.get("sessionStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !origin.is_empty() && origin != "null" {
|
||||
vec![OriginStorage {
|
||||
origin,
|
||||
local_storage,
|
||||
session_storage,
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let state = StorageState { cookies, origins };
|
||||
let json_str = serde_json::to_string_pretty(&state)
|
||||
.map_err(|e| format!("Failed to serialize state: {}", e))?;
|
||||
|
||||
let mut save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_sessions_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let name = session_name.unwrap_or("default");
|
||||
dir.join(format!("{}-{}.json", name, session_id_str))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
|
||||
let encrypted = encrypt_data(json_str.as_bytes(), &key)?;
|
||||
save_path.push_str(".enc");
|
||||
fs::write(&save_path, &encrypted)
|
||||
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
|
||||
} else {
|
||||
fs::write(&save_path, &json_str)
|
||||
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
|
||||
}
|
||||
|
||||
Ok(save_path)
|
||||
}
|
||||
|
||||
pub async fn load_state(client: &CdpClient, session_id: &str, path: &str) -> Result<(), String> {
|
||||
let json_str = if path.ends_with(".enc") {
|
||||
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
|
||||
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
|
||||
})?;
|
||||
let data =
|
||||
fs::read(path).map_err(|e| format!("Failed to read state from {}: {}", path, e))?;
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
|
||||
} else {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
|
||||
let enc_path = format!("{}.enc", path);
|
||||
if let Ok(data) = fs::read(&enc_path) {
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|de| format!("Decrypted state is not valid UTF-8: {}", de))?
|
||||
} else {
|
||||
return Err(format!("Failed to read state from {}: {}", path, e));
|
||||
}
|
||||
} else {
|
||||
return Err(format!("Failed to read state from {}: {}", path, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let state: StorageState =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
|
||||
|
||||
// Load cookies
|
||||
if !state.cookies.is_empty() {
|
||||
let cookie_values: Vec<Value> = state
|
||||
.cookies
|
||||
.iter()
|
||||
.map(|c| serde_json::to_value(c).unwrap_or(Value::Null))
|
||||
.collect();
|
||||
cookies::set_cookies(client, session_id, cookie_values, None).await?;
|
||||
}
|
||||
|
||||
// Load storage per origin
|
||||
for origin in &state.origins {
|
||||
if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Navigate to origin to set storage
|
||||
let navigate_url = format!("{}/", origin.origin.trim_end_matches('/'));
|
||||
client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": navigate_url })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Brief wait for navigation
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
for entry in &origin.local_storage {
|
||||
let js = format!(
|
||||
"localStorage.setItem({}, {})",
|
||||
serde_json::to_string(&entry.name).unwrap_or_default(),
|
||||
serde_json::to_string(&entry.value).unwrap_or_default(),
|
||||
);
|
||||
let _ = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
for entry in &origin.session_storage {
|
||||
let js = format!(
|
||||
"sessionStorage.setItem({}, {})",
|
||||
serde_json::to_string(&entry.name).unwrap_or_default(),
|
||||
serde_json::to_string(&entry.value).unwrap_or_default(),
|
||||
);
|
||||
let _ = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_state_file(path: &std::path::Path) -> bool {
|
||||
let fname = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
fname.ends_with(".json") || fname.ends_with(".json.enc")
|
||||
}
|
||||
|
||||
fn is_encrypted_state(path: &std::path::Path) -> bool {
|
||||
path.to_string_lossy().ends_with(".json.enc")
|
||||
}
|
||||
|
||||
pub fn state_list() -> Result<Value, String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "files": [], "directory": dir.to_string_lossy() }));
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read sessions dir: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_state_file(&path) {
|
||||
let metadata = fs::metadata(&path).ok();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
let modified = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let encrypted = is_encrypted_state(&path);
|
||||
|
||||
files.push(json!({
|
||||
"filename": filename,
|
||||
"path": path.to_string_lossy(),
|
||||
"size": size,
|
||||
"modified": modified,
|
||||
"encrypted": encrypted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "files": files, "directory": dir.to_string_lossy() }))
|
||||
}
|
||||
|
||||
pub fn state_show(path: &str) -> Result<Value, String> {
|
||||
let encrypted = path.ends_with(".enc");
|
||||
let json_str = if encrypted {
|
||||
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
|
||||
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
|
||||
})?;
|
||||
let data = fs::read(path).map_err(|e| format!("Failed to read state file: {}", e))?;
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
|
||||
} else {
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read state file: {}", e))?
|
||||
};
|
||||
|
||||
let state: StorageState =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
|
||||
|
||||
let metadata = fs::metadata(path).ok();
|
||||
let filename = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(json!({
|
||||
"filename": filename,
|
||||
"path": path,
|
||||
"size": metadata.as_ref().map(|m| m.len()).unwrap_or(0),
|
||||
"modified": metadata.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
"encrypted": encrypted,
|
||||
"summary": format!("{} cookies, {} origins", state.cookies.len(), state.origins.len()),
|
||||
"state": state,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn state_clear(path: Option<&str>) -> Result<Value, String> {
|
||||
if let Some(p) = path {
|
||||
fs::remove_file(p).map_err(|e| format!("Failed to delete state: {}", e))?;
|
||||
return Ok(json!({ "deleted": p }));
|
||||
}
|
||||
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "deleted": 0 }));
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_state_file(&path) {
|
||||
let _ = fs::remove_file(&path);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "deleted": count }))
|
||||
}
|
||||
|
||||
pub fn state_clean(max_age_days: u64) -> Result<Value, String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "cleaned": 0, "keptCount": 0, "days": max_age_days }));
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now();
|
||||
let max_age = std::time::Duration::from_secs(max_age_days * 86400);
|
||||
let mut deleted = 0;
|
||||
let mut kept = 0;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !is_state_file(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(metadata) = fs::metadata(&path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(age) = now.duration_since(modified) {
|
||||
if age > max_age {
|
||||
let _ = fs::remove_file(&path);
|
||||
deleted += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "cleaned": deleted, "keptCount": kept, "days": max_age_days }))
|
||||
}
|
||||
|
||||
pub fn state_rename(old_path: &str, new_name: &str) -> Result<Value, String> {
|
||||
let old = PathBuf::from(old_path);
|
||||
if !old.exists() {
|
||||
return Err(format!("State file not found: {}", old_path));
|
||||
}
|
||||
|
||||
let fallback = PathBuf::from(".");
|
||||
let dir = old.parent().unwrap_or(&fallback);
|
||||
let new_path = dir.join(format!("{}.json", new_name));
|
||||
|
||||
fs::rename(&old, &new_path).map_err(|e| format!("Failed to rename state: {}", e))?;
|
||||
|
||||
Ok(json!({
|
||||
"renamed": true,
|
||||
"from": old_path,
|
||||
"to": new_path.to_string_lossy(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn encrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_str.as_bytes());
|
||||
let key_bytes = hasher.finalize();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
|
||||
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&nonce), data)
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn decrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
|
||||
if data.len() < 13 {
|
||||
return Err("Ciphertext too short".to_string());
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_str.as_bytes());
|
||||
let key_bytes = hasher.finalize();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{}-", session_name);
|
||||
let mut best_path: Option<(String, std::time::SystemTime)> = None;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let fname = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let is_match = fname.starts_with(&prefix)
|
||||
&& (fname.ends_with(".json") || fname.ends_with(".json.enc"));
|
||||
if !is_match {
|
||||
continue;
|
||||
}
|
||||
let modified = fs::metadata(&path)
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
}
|
||||
best_path.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
pub fn get_sessions_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("sessions")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("sessions")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_storage_state_serialization() {
|
||||
let state = StorageState {
|
||||
cookies: vec![Cookie {
|
||||
name: "session".to_string(),
|
||||
value: "abc123".to_string(),
|
||||
domain: ".example.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 0.0,
|
||||
size: 0,
|
||||
http_only: true,
|
||||
secure: false,
|
||||
session: true,
|
||||
same_site: Some("Lax".to_string()),
|
||||
}],
|
||||
origins: vec![OriginStorage {
|
||||
origin: "https://example.com".to_string(),
|
||||
local_storage: vec![StorageEntry {
|
||||
name: "key".to_string(),
|
||||
value: "val".to_string(),
|
||||
}],
|
||||
session_storage: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
let parsed: StorageState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.cookies.len(), 1);
|
||||
assert_eq!(parsed.cookies[0].name, "session");
|
||||
assert_eq!(parsed.origins.len(), 1);
|
||||
assert_eq!(parsed.origins[0].local_storage.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_state_empty() {
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
origins: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let parsed: StorageState = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.cookies.is_empty());
|
||||
assert!(parsed.origins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_show_nonexistent_file() {
|
||||
let result = state_show("/tmp/nonexistent-agent-browser-state-file.json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_clear_nonexistent_file() {
|
||||
let result = state_clear(Some("/tmp/nonexistent-agent-browser-state-file.json"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_rename_nonexistent() {
|
||||
let result = state_rename("/tmp/nonexistent-agent-browser-state-file.json", "new-name");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_list_returns_json() {
|
||||
let result = state_list().unwrap();
|
||||
assert!(result.get("files").is_some());
|
||||
assert!(result.get("directory").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_dir_path() {
|
||||
let dir = get_sessions_dir();
|
||||
assert!(dir.to_string_lossy().contains("sessions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let plain = b"hello world";
|
||||
let key = "test-secret-key";
|
||||
let encrypted = encrypt_data(plain, key).unwrap();
|
||||
assert!(encrypted.len() > 12);
|
||||
assert_ne!(&encrypted[12..], plain);
|
||||
let decrypted = decrypt_data(&encrypted, key).unwrap();
|
||||
assert_eq!(decrypted, plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_wrong_key_fails() {
|
||||
let plain = b"secret data";
|
||||
let encrypted = encrypt_data(plain, "key1").unwrap();
|
||||
let result = decrypt_data(&encrypted, "key2");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_serde_roundtrip() {
|
||||
let cookie = Cookie {
|
||||
name: "test".to_string(),
|
||||
value: "123".to_string(),
|
||||
domain: ".test.com".to_string(),
|
||||
path: "/api".to_string(),
|
||||
expires: 1700000000.0,
|
||||
size: 7,
|
||||
http_only: false,
|
||||
secure: true,
|
||||
session: false,
|
||||
same_site: Some("Strict".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&cookie).unwrap();
|
||||
assert_eq!(json["name"], "test");
|
||||
assert_eq!(json["httpOnly"], false);
|
||||
assert_eq!(json["secure"], true);
|
||||
assert_eq!(json["sameSite"], "Strict");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::EvaluateParams;
|
||||
|
||||
pub async fn storage_get(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
key: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
|
||||
if let Some(k) = key {
|
||||
let js = format!(
|
||||
"{}.getItem({})",
|
||||
st,
|
||||
serde_json::to_string(k).unwrap_or_default()
|
||||
);
|
||||
let result = eval_simple(client, session_id, &js).await?;
|
||||
Ok(json!({ "key": k, "value": result }))
|
||||
} else {
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const s = {};
|
||||
const data = {{}};
|
||||
for (let i = 0; i < s.length; i++) {{
|
||||
const key = s.key(i);
|
||||
data[key] = s.getItem(key);
|
||||
}}
|
||||
return data;
|
||||
}})()"#,
|
||||
st
|
||||
);
|
||||
let result = eval_simple(client, session_id, &js).await?;
|
||||
Ok(json!({ "data": result }))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn storage_set(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
let js = format!(
|
||||
"{}.setItem({}, {})",
|
||||
st,
|
||||
serde_json::to_string(key).unwrap_or_default(),
|
||||
serde_json::to_string(value).unwrap_or_default(),
|
||||
);
|
||||
eval_simple(client, session_id, &js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn storage_clear(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
) -> Result<(), String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
let js = format!("{}.clear()", st);
|
||||
eval_simple(client, session_id, &js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn storage_js_name(storage_type: &str) -> &str {
|
||||
match storage_type {
|
||||
"session" => "sessionStorage",
|
||||
_ => "localStorage",
|
||||
}
|
||||
}
|
||||
|
||||
async fn eval_simple(client: &CdpClient, session_id: &str, js: &str) -> Result<Value, String> {
|
||||
let result: super::cdp::types::EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ref details) = result.exception_details {
|
||||
return Err(format!("Storage error: {}", details.text));
|
||||
}
|
||||
|
||||
Ok(result.result.value.unwrap_or(Value::Null))
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
/// Frame metadata from CDP Page.screencastFrame events.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameMetadata {
|
||||
pub offset_top: f64,
|
||||
pub page_scale_factor: f64,
|
||||
pub device_width: u32,
|
||||
pub device_height: u32,
|
||||
pub scroll_offset_x: f64,
|
||||
pub scroll_offset_y: f64,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
impl Default for FrameMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
offset_top: 0.0,
|
||||
page_scale_factor: 1.0,
|
||||
device_width: 1280,
|
||||
device_height: 720,
|
||||
scroll_offset_x: 0.0,
|
||||
scroll_offset_y: 0.0,
|
||||
timestamp: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StreamServer {
|
||||
port: u16,
|
||||
frame_tx: broadcast::Sender<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl StreamServer {
|
||||
pub async fn start(
|
||||
preferred_port: u16,
|
||||
client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
) -> Result<Self, String> {
|
||||
let addr = format!("127.0.0.1:{}", preferred_port);
|
||||
let listener = TcpListener::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind stream server: {}", e))?;
|
||||
|
||||
let actual_addr = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get stream address: {}", e))?;
|
||||
let port = actual_addr.port();
|
||||
|
||||
let (frame_tx, _) = broadcast::channel::<String>(64);
|
||||
let client_count = Arc::new(Mutex::new(0usize));
|
||||
|
||||
let frame_tx_clone = frame_tx.clone();
|
||||
let client_count_clone = client_count.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
accept_loop(
|
||||
listener,
|
||||
frame_tx_clone,
|
||||
client_count_clone,
|
||||
client,
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
frame_tx,
|
||||
client_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Broadcast a raw frame string (legacy).
|
||||
pub fn broadcast_frame(&self, frame_json: &str) {
|
||||
let _ = self.frame_tx.send(frame_json.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast a screencast frame with structured metadata.
|
||||
pub fn broadcast_screencast_frame(&self, base64_data: &str, metadata: &FrameMetadata) {
|
||||
let msg = json!({
|
||||
"type": "frame",
|
||||
"data": base64_data,
|
||||
"metadata": {
|
||||
"offsetTop": metadata.offset_top,
|
||||
"pageScaleFactor": metadata.page_scale_factor,
|
||||
"deviceWidth": metadata.device_width,
|
||||
"deviceHeight": metadata.device_height,
|
||||
"scrollOffsetX": metadata.scroll_offset_x,
|
||||
"scrollOffsetY": metadata.scroll_offset_y,
|
||||
"timestamp": metadata.timestamp,
|
||||
}
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast a status message to all connected clients.
|
||||
pub fn broadcast_status(
|
||||
&self,
|
||||
connected: bool,
|
||||
screencasting: bool,
|
||||
viewport_width: u32,
|
||||
viewport_height: u32,
|
||||
) {
|
||||
let msg = json!({
|
||||
"type": "status",
|
||||
"connected": connected,
|
||||
"screencasting": screencasting,
|
||||
"viewportWidth": viewport_width,
|
||||
"viewportHeight": viewport_height,
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast an error message to all connected clients.
|
||||
pub fn broadcast_error(&self, message: &str) {
|
||||
let msg = json!({
|
||||
"type": "error",
|
||||
"message": message,
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
frame_tx: broadcast::Sender<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
cdp_client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
) {
|
||||
while let Ok((stream, addr)) = listener.accept().await {
|
||||
let frame_rx = frame_tx.subscribe();
|
||||
let client_count = client_count.clone();
|
||||
let cdp = cdp_client.clone();
|
||||
let sid = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
handle_ws_client(stream, addr, frame_rx, client_count, cdp, sid).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ws_client(
|
||||
stream: tokio::net::TcpStream,
|
||||
_addr: SocketAddr,
|
||||
mut frame_rx: broadcast::Receiver<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
cdp_client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
) {
|
||||
// Origin checking on WebSocket handshake
|
||||
let callback =
|
||||
|req: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
let origin = req
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
if !is_allowed_origin(origin.as_deref()) {
|
||||
let mut reject =
|
||||
tokio_tungstenite::tungstenite::handshake::server::ErrorResponse::new(Some(
|
||||
"Origin not allowed".to_string(),
|
||||
));
|
||||
*reject.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
|
||||
return Err(reject);
|
||||
}
|
||||
Ok(resp)
|
||||
};
|
||||
|
||||
let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
|
||||
Ok(ws) => ws,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
{
|
||||
let mut count = client_count.lock().await;
|
||||
*count += 1;
|
||||
}
|
||||
|
||||
let (mut ws_tx, mut ws_rx) = ws_stream.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = frame_rx.recv() => {
|
||||
match frame {
|
||||
Ok(data) => {
|
||||
if ws_tx.send(Message::Text(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
msg = ws_rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
handle_client_message(&text, &cdp_client, &session_id).await;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut count = client_count.lock().await;
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client_message(msg: &str, client: &CdpClient, session_id: &str) {
|
||||
let parsed: Value = match serde_json::from_str(msg) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
"input_mouse" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchMouseEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("mouseMoved"),
|
||||
"x": parsed.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"y": parsed.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"button": parsed.get("button").and_then(|v| v.as_str()).unwrap_or("none"),
|
||||
"clickCount": parsed.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
"deltaX": parsed.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"deltaY": parsed.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"input_keyboard" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchKeyEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("keyDown"),
|
||||
"key": parsed.get("key"),
|
||||
"code": parsed.get("code"),
|
||||
"text": parsed.get("text"),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"input_touch" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchTouchEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("touchStart"),
|
||||
"touchPoints": parsed.get("touchPoints").unwrap_or(&json!([])),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"status" => {
|
||||
// Client requesting status -- handled via broadcast_status from the caller
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_allowed_origin(origin: Option<&str>) -> bool {
|
||||
match origin {
|
||||
None => true,
|
||||
Some(o) => {
|
||||
if o.starts_with("file://") {
|
||||
return true;
|
||||
}
|
||||
if let Ok(url) = url::Url::parse(o) {
|
||||
let host = url.host_str().unwrap_or("");
|
||||
host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_screencast(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
format: &str,
|
||||
quality: i32,
|
||||
max_width: i32,
|
||||
max_height: i32,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Page.startScreencast",
|
||||
Some(json!({
|
||||
"format": format,
|
||||
"quality": quality,
|
||||
"maxWidth": max_width,
|
||||
"maxHeight": max_height,
|
||||
"everyNthFrame": 1,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_screencast(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
client
|
||||
.send_command_no_params("Page.stopScreencast", Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ack_screencast_frame(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
screencast_session_id: i64,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Page.screencastFrameAck",
|
||||
Some(json!({ "sessionId": screencast_session_id })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_none() {
|
||||
assert!(is_allowed_origin(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_file() {
|
||||
assert!(is_allowed_origin(Some("file:///path/to/file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_localhost() {
|
||||
assert!(is_allowed_origin(Some("http://localhost:3000")));
|
||||
assert!(is_allowed_origin(Some("http://127.0.0.1:8080")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disallowed_origin() {
|
||||
assert!(!is_allowed_origin(Some("http://evil.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frame_metadata_default() {
|
||||
let meta = FrameMetadata::default();
|
||||
assert_eq!(meta.device_width, 1280);
|
||||
assert_eq!(meta.device_height, 720);
|
||||
assert_eq!(meta.page_scale_factor, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
const MAX_PROFILE_EVENTS: usize = 5_000_000;
|
||||
|
||||
const DEFAULT_PROFILER_CATEGORIES: &[&str] = &[
|
||||
"devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"v8.execute",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
"disabled-by-default-v8.cpu_profiler.hires",
|
||||
"v8",
|
||||
"disabled-by-default-v8.runtime_stats",
|
||||
"blink",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"renderer.scheduler",
|
||||
"sequence_manager",
|
||||
"toplevel",
|
||||
];
|
||||
|
||||
pub struct TracingState {
|
||||
pub active: bool,
|
||||
pub events: Vec<Value>,
|
||||
pub events_dropped: bool,
|
||||
}
|
||||
|
||||
impl TracingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
events: Vec::new(),
|
||||
events_dropped: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn trace_start(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
) -> Result<Value, String> {
|
||||
if tracing_state.active {
|
||||
return Err("Tracing already active".to_string());
|
||||
}
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Tracing.start",
|
||||
Some(json!({
|
||||
"traceConfig": {
|
||||
"recordMode": "recordContinuously",
|
||||
},
|
||||
"transferMode": "ReturnAsStream",
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing_state.active = true;
|
||||
tracing_state.events.clear();
|
||||
tracing_state.events_dropped = false;
|
||||
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
pub async fn trace_stop(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
path: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if !tracing_state.active {
|
||||
return Err("No tracing in progress".to_string());
|
||||
}
|
||||
|
||||
// Subscribe to events before stopping
|
||||
let mut rx = client.subscribe();
|
||||
|
||||
client
|
||||
.send_command_no_params("Tracing.end", Some(session_id))
|
||||
.await?;
|
||||
|
||||
// Collect trace data with timeout
|
||||
let mut trace_events: Vec<Value> = Vec::new();
|
||||
let mut stream_handle: Option<String> = None;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => {
|
||||
if event.session_id.as_deref() != Some(session_id) {
|
||||
continue;
|
||||
}
|
||||
match event.method.as_str() {
|
||||
"Tracing.dataCollected" => {
|
||||
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(arr.iter().cloned());
|
||||
}
|
||||
}
|
||||
"Tracing.tracingComplete" => {
|
||||
stream_handle = event
|
||||
.params
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {
|
||||
return Err("Tracing stop timed out after 30s".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If ReturnAsStream mode was used, read trace data from the IO stream
|
||||
if let Some(handle) = stream_handle {
|
||||
if trace_events.is_empty() {
|
||||
let stream_data = read_io_stream(client, session_id, &handle).await?;
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(&stream_data) {
|
||||
if let Some(events) = parsed.get("traceEvents").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(events.iter().cloned());
|
||||
}
|
||||
} else {
|
||||
// Try parsing as newline-delimited JSON
|
||||
for line in stream_data.lines() {
|
||||
if let Ok(val) = serde_json::from_str::<Value>(line) {
|
||||
if let Some(events) = val.get("traceEvents").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(events.iter().cloned());
|
||||
} else {
|
||||
trace_events.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Close the IO stream
|
||||
let _ = client
|
||||
.send_command(
|
||||
"IO.close",
|
||||
Some(json!({ "handle": handle })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing_state.active = false;
|
||||
|
||||
let save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_traces_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
dir.join(format!("trace-{}.json", timestamp))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let trace_json = json!({ "traceEvents": trace_events });
|
||||
let json_str = serde_json::to_string(&trace_json)
|
||||
.map_err(|e| format!("Failed to serialize trace: {}", e))?;
|
||||
std::fs::write(&save_path, json_str)
|
||||
.map_err(|e| format!("Failed to write trace to {}: {}", save_path, e))?;
|
||||
|
||||
Ok(json!({ "path": save_path, "eventCount": trace_events.len() }))
|
||||
}
|
||||
|
||||
pub async fn profiler_start(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
categories: Option<Vec<String>>,
|
||||
) -> Result<Value, String> {
|
||||
if tracing_state.active {
|
||||
return Err("Profiling/tracing already active".to_string());
|
||||
}
|
||||
|
||||
let cats: Vec<String> = categories.unwrap_or_else(|| {
|
||||
DEFAULT_PROFILER_CATEGORIES
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
});
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Tracing.start",
|
||||
Some(json!({
|
||||
"traceConfig": {
|
||||
"includedCategories": cats,
|
||||
"enableSampling": true,
|
||||
},
|
||||
"transferMode": "ReportEvents",
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing_state.active = true;
|
||||
tracing_state.events.clear();
|
||||
tracing_state.events_dropped = false;
|
||||
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
pub async fn profiler_stop(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
path: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if !tracing_state.active {
|
||||
return Err("No profiling in progress".to_string());
|
||||
}
|
||||
|
||||
let mut rx = client.subscribe();
|
||||
|
||||
client
|
||||
.send_command_no_params("Tracing.end", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let mut events: Vec<Value> = Vec::new();
|
||||
let mut dropped = false;
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => {
|
||||
if event.session_id.as_deref() != Some(session_id) {
|
||||
continue;
|
||||
}
|
||||
match event.method.as_str() {
|
||||
"Tracing.dataCollected" => {
|
||||
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
|
||||
if events.len() + arr.len() > MAX_PROFILE_EVENTS {
|
||||
dropped = true;
|
||||
} else {
|
||||
events.extend(arr.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
"Tracing.tracingComplete" => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {
|
||||
return Err("Profiler stop timed out after 30s".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing_state.active = false;
|
||||
|
||||
let save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_profiles_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
dir.join(format!("profile-{}.json", timestamp))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let clock_domain = get_clock_domain();
|
||||
let mut profile = json!({ "traceEvents": events });
|
||||
if let Some(cd) = clock_domain {
|
||||
profile
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("metadata".to_string(), json!({ "clock-domain": cd }));
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
std::fs::write(&save_path, json_str)
|
||||
.map_err(|e| format!("Failed to write profile to {}: {}", save_path, e))?;
|
||||
|
||||
let event_count = events.len();
|
||||
let mut result = json!({ "path": save_path, "eventCount": event_count });
|
||||
if dropped {
|
||||
result.as_object_mut().unwrap().insert(
|
||||
"warning".to_string(),
|
||||
Value::String(format!(
|
||||
"Events exceeded {} limit; some dropped",
|
||||
MAX_PROFILE_EVENTS
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Read all data from a CDP IO stream handle.
|
||||
async fn read_io_stream(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
handle: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut data = String::new();
|
||||
loop {
|
||||
let result = client
|
||||
.send_command(
|
||||
"IO.read",
|
||||
Some(json!({
|
||||
"handle": handle,
|
||||
"size": 1024 * 1024,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(chunk) = result.get("data").and_then(|v| v.as_str()) {
|
||||
data.push_str(chunk);
|
||||
}
|
||||
|
||||
let eof = result.get("eof").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn get_clock_domain() -> Option<&'static str> {
|
||||
if cfg!(target_os = "linux") {
|
||||
Some("LINUX_CLOCK_MONOTONIC")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Some("MAC_MACH_ABSOLUTE_TIME")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_traces_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("traces")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("traces")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profiles_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("profiles")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("profiles")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::client::WebDriverClient;
|
||||
|
||||
const APPIUM_DEFAULT_PORT: u16 = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
pub struct AppiumManager {
|
||||
pub client: WebDriverClient,
|
||||
appium_process: Option<Child>,
|
||||
pub device_udid: Option<String>,
|
||||
}
|
||||
|
||||
impl AppiumManager {
|
||||
pub async fn connect_or_launch(device_udid: Option<&str>) -> Result<Self, String> {
|
||||
let port = APPIUM_DEFAULT_PORT;
|
||||
let client = WebDriverClient::new(port);
|
||||
|
||||
// Check if Appium is already running
|
||||
if is_appium_running(port).await {
|
||||
return Ok(Self {
|
||||
client,
|
||||
appium_process: None,
|
||||
device_udid: device_udid.map(String::from),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to launch Appium
|
||||
let appium_process = launch_appium(port)?;
|
||||
|
||||
// Wait for Appium to be ready
|
||||
wait_for_appium(port, APPIUM_STARTUP_TIMEOUT_SECS).await?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
appium_process: Some(appium_process),
|
||||
device_udid: device_udid.map(String::from),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_ios_session(
|
||||
&mut self,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let mut caps = json!({
|
||||
"platformName": "iOS",
|
||||
"automationName": "XCUITest",
|
||||
"browserName": "Safari",
|
||||
"noReset": true,
|
||||
});
|
||||
|
||||
if let Some(name) = device_name {
|
||||
caps["deviceName"] = json!(name);
|
||||
} else {
|
||||
caps["deviceName"] = json!("iPhone");
|
||||
}
|
||||
|
||||
if let Some(ver) = platform_version {
|
||||
caps["platformVersion"] = json!(ver);
|
||||
}
|
||||
|
||||
if let Some(ref udid) = self.device_udid {
|
||||
caps["udid"] = json!(udid);
|
||||
}
|
||||
|
||||
self.client.create_session(caps).await
|
||||
}
|
||||
|
||||
pub async fn tap(&self, x: f64, y: f64) -> Result<(), String> {
|
||||
let sid = self
|
||||
.client
|
||||
.session_id_pub()
|
||||
.ok_or("No active session")?
|
||||
.to_string();
|
||||
let actions = json!({
|
||||
"actions": [{
|
||||
"type": "pointer",
|
||||
"id": "finger1",
|
||||
"parameters": { "pointerType": "touch" },
|
||||
"actions": [
|
||||
{ "type": "pointerMove", "duration": 0, "x": x as i64, "y": y as i64 },
|
||||
{ "type": "pointerDown", "button": 0 },
|
||||
{ "type": "pause", "duration": 100 },
|
||||
{ "type": "pointerUp", "button": 0 },
|
||||
]
|
||||
}]
|
||||
});
|
||||
self.client.execute_actions(&sid, &actions).await
|
||||
}
|
||||
|
||||
pub async fn swipe(
|
||||
&self,
|
||||
start_x: f64,
|
||||
start_y: f64,
|
||||
end_x: f64,
|
||||
end_y: f64,
|
||||
duration_ms: u64,
|
||||
) -> Result<(), String> {
|
||||
let sid = self
|
||||
.client
|
||||
.session_id_pub()
|
||||
.ok_or("No active session")?
|
||||
.to_string();
|
||||
let actions = json!({
|
||||
"actions": [{
|
||||
"type": "pointer",
|
||||
"id": "finger1",
|
||||
"parameters": { "pointerType": "touch" },
|
||||
"actions": [
|
||||
{ "type": "pointerMove", "duration": 0, "x": start_x as i64, "y": start_y as i64 },
|
||||
{ "type": "pointerDown", "button": 0 },
|
||||
{ "type": "pointerMove", "duration": duration_ms, "x": end_x as i64, "y": end_y as i64 },
|
||||
{ "type": "pointerUp", "button": 0 },
|
||||
]
|
||||
}]
|
||||
});
|
||||
self.client.execute_actions(&sid, &actions).await
|
||||
}
|
||||
|
||||
pub async fn close(&mut self) -> Result<(), String> {
|
||||
let _ = self.client.delete_session().await;
|
||||
if let Some(ref mut child) = self.appium_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AppiumManager {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref mut child) = self.appium_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_appium_running(port: u16) -> bool {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn launch_appium(port: u16) -> Result<Child, String> {
|
||||
// Try npx appium first, then direct appium
|
||||
let result = Command::new("npx")
|
||||
.args(["appium", "--relaxed-security", "--port", &port.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
match result {
|
||||
Ok(child) => Ok(child),
|
||||
Err(_) => Command::new("appium")
|
||||
.args(["--relaxed-security", "--port", &port.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to launch Appium. Install it with: npm install -g appium. Error: {}",
|
||||
e
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_appium(port: u16, timeout_secs: u64) -> Result<(), String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
return Err("Timeout waiting for Appium to start".to_string());
|
||||
}
|
||||
if is_appium_running(port).await {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_appium_constants() {
|
||||
assert_eq!(APPIUM_DEFAULT_PORT, 4723);
|
||||
assert_eq!(APPIUM_STARTUP_TIMEOUT_SECS, 30);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Abstract backend for browser automation. CDP (Chromium) and WebDriver
|
||||
/// (Safari/iOS) share this interface so actions.rs can remain backend-agnostic
|
||||
/// in the future.
|
||||
#[async_trait]
|
||||
pub trait BrowserBackend: Send + Sync {
|
||||
async fn navigate(&self, url: &str) -> Result<(), String>;
|
||||
async fn get_url(&self) -> Result<String, String>;
|
||||
async fn get_title(&self) -> Result<String, String>;
|
||||
async fn get_content(&self) -> Result<String, String>;
|
||||
async fn evaluate(&self, script: &str) -> Result<Value, String>;
|
||||
async fn screenshot(&self) -> Result<String, String>;
|
||||
async fn click(&self, selector: &str) -> Result<(), String>;
|
||||
async fn fill(&self, selector: &str, value: &str) -> Result<(), String>;
|
||||
async fn close(&mut self) -> Result<(), String>;
|
||||
async fn back(&self) -> Result<(), String>;
|
||||
async fn forward(&self) -> Result<(), String>;
|
||||
async fn reload(&self) -> Result<(), String>;
|
||||
async fn get_cookies(&self) -> Result<Value, String>;
|
||||
fn backend_type(&self) -> &str;
|
||||
|
||||
fn supports(&self, feature: &str) -> bool {
|
||||
match feature {
|
||||
"navigate" | "evaluate" | "screenshot" | "click" | "fill" => true,
|
||||
"screencast" | "tracing" | "network_intercept" | "cdp" => self.backend_type() == "cdp",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_error(&self, action: &str) -> String {
|
||||
format!(
|
||||
"Action '{}' is not supported on the {} backend",
|
||||
action,
|
||||
self.backend_type()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDriver implementation of BrowserBackend
|
||||
pub struct WebDriverBackend {
|
||||
client: super::client::WebDriverClient,
|
||||
}
|
||||
|
||||
impl WebDriverBackend {
|
||||
pub fn new(client: super::client::WebDriverClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BrowserBackend for WebDriverBackend {
|
||||
async fn navigate(&self, url: &str) -> Result<(), String> {
|
||||
self.client.navigate(url).await
|
||||
}
|
||||
|
||||
async fn get_url(&self) -> Result<String, String> {
|
||||
self.client.get_url().await
|
||||
}
|
||||
|
||||
async fn get_title(&self) -> Result<String, String> {
|
||||
self.client.get_title().await
|
||||
}
|
||||
|
||||
async fn get_content(&self) -> Result<String, String> {
|
||||
self.client.get_page_source().await
|
||||
}
|
||||
|
||||
async fn evaluate(&self, script: &str) -> Result<Value, String> {
|
||||
self.client.execute_script(script, vec![]).await
|
||||
}
|
||||
|
||||
async fn screenshot(&self) -> Result<String, String> {
|
||||
self.client.screenshot().await
|
||||
}
|
||||
|
||||
async fn click(&self, selector: &str) -> Result<(), String> {
|
||||
let element_id = self.client.find_element("css selector", selector).await?;
|
||||
self.client.click_element(&element_id).await
|
||||
}
|
||||
|
||||
async fn fill(&self, selector: &str, value: &str) -> Result<(), String> {
|
||||
let element_id = self.client.find_element("css selector", selector).await?;
|
||||
self.client.clear_element(&element_id).await?;
|
||||
self.client.send_keys(&element_id, value).await
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), String> {
|
||||
self.client.delete_session().await
|
||||
}
|
||||
|
||||
async fn back(&self) -> Result<(), String> {
|
||||
self.client.back().await
|
||||
}
|
||||
|
||||
async fn forward(&self) -> Result<(), String> {
|
||||
self.client.forward().await
|
||||
}
|
||||
|
||||
async fn reload(&self) -> Result<(), String> {
|
||||
self.client.refresh().await
|
||||
}
|
||||
|
||||
async fn get_cookies(&self) -> Result<Value, String> {
|
||||
self.client.get_cookies().await
|
||||
}
|
||||
|
||||
fn backend_type(&self) -> &str {
|
||||
"webdriver"
|
||||
}
|
||||
}
|
||||
|
||||
/// CDP-backed backend constants for unsupported actions on WebDriver
|
||||
pub const WEBDRIVER_UNSUPPORTED_ACTIONS: &[&str] = &[
|
||||
"screencast_start",
|
||||
"screencast_stop",
|
||||
"trace_start",
|
||||
"trace_stop",
|
||||
"profiler_start",
|
||||
"profiler_stop",
|
||||
"route",
|
||||
"unroute",
|
||||
"expose",
|
||||
"addscript",
|
||||
"addinitscript",
|
||||
"network",
|
||||
"har_start",
|
||||
"har_stop",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_actions() {
|
||||
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"screencast_start"));
|
||||
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"trace_start"));
|
||||
assert!(!WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"navigate"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct WebDriverClient {
|
||||
base_url: String,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WebDriverClient {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session(&mut self, capabilities: Value) -> Result<Value, String> {
|
||||
let body = json!({
|
||||
"capabilities": {
|
||||
"alwaysMatch": capabilities,
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.post("/session", &body).await?;
|
||||
|
||||
let session_id = response
|
||||
.get("value")
|
||||
.and_then(|v| v.get("sessionId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No sessionId in response")?
|
||||
.to_string();
|
||||
|
||||
self.session_id = Some(session_id);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn delete_session(&mut self) -> Result<(), String> {
|
||||
if let Some(ref sid) = self.session_id.clone() {
|
||||
let _ = self.delete(&format!("/session/{}", sid)).await;
|
||||
self.session_id = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn navigate(&self, url: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/url", sid), &json!({ "url": url }))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_url(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/url", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_title(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/title", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn find_element(&self, using: &str, value: &str) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self
|
||||
.post(
|
||||
&format!("/session/{}/element", sid),
|
||||
&json!({ "using": using, "value": value }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let element_value = response.get("value").ok_or("No element in response")?;
|
||||
|
||||
element_value
|
||||
.get("element-6066-11e4-a52e-4f735466cecf")
|
||||
.or_else(|| element_value.get("ELEMENT"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or("No element ID in response".to_string())
|
||||
}
|
||||
|
||||
pub async fn click_element(&self, element_id: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/click", sid, element_id),
|
||||
&json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_keys(&self, element_id: &str, text: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/value", sid, element_id),
|
||||
&json!({ "text": text }),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_element(&self, element_id: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/clear", sid, element_id),
|
||||
&json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_script(&self, script: &str, args: Vec<Value>) -> Result<Value, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self
|
||||
.post(
|
||||
&format!("/session/{}/execute/sync", sid),
|
||||
&json!({ "script": script, "args": args }),
|
||||
)
|
||||
.await?;
|
||||
Ok(response.get("value").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn screenshot(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/screenshot", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_cookies(&self) -> Result<Value, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/cookie", sid)).await?;
|
||||
Ok(response.get("value").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn get_page_source(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/source", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn back(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/back", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn forward(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/forward", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/refresh", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_id_pub(&self) -> Option<&str> {
|
||||
self.session_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn new_with_session(port: u16, session_id: String) -> Self {
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
session_id: Some(session_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_actions(&self, session_id: &str, actions: &Value) -> Result<(), String> {
|
||||
self.post(&format!("/session/{}/actions", session_id), actions)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn session_id(&self) -> Result<&str, String> {
|
||||
self.session_id
|
||||
.as_deref()
|
||||
.ok_or("No active WebDriver session".to_string())
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<Value, String> {
|
||||
http_request("GET", &format!("{}{}", self.base_url, path), None).await
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: &Value) -> Result<Value, String> {
|
||||
http_request("POST", &format!("{}{}", self.base_url, path), Some(body)).await
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<Value, String> {
|
||||
http_request("DELETE", &format!("{}{}", self.base_url, path), None).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_new() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:4444");
|
||||
assert!(client.session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_none() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
let result = client.session_id();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No active WebDriver session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_custom_port() {
|
||||
let client = WebDriverClient::new(9515);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:9515");
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<Value, String> {
|
||||
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port().unwrap_or(80);
|
||||
let path = parsed.path();
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let stream = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("Connection timeout: {}", addr))?
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let body_str = body
|
||||
.map(|b| serde_json::to_string(b).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let request = if body.is_some() {
|
||||
format!(
|
||||
"{} {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
method, path, addr, body_str.len(), body_str
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
||||
method, path, addr
|
||||
)
|
||||
};
|
||||
|
||||
let mut stream = stream;
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Write failed: {}", e))?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| format!("Read failed: {}", e))?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
let body_part = response_str.split("\r\n\r\n").nth(1).unwrap_or("").trim();
|
||||
|
||||
// Handle chunked encoding
|
||||
let json_body = if body_part.contains('\n')
|
||||
&& body_part
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_hexdigit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Chunked: skip chunk size lines
|
||||
body_part
|
||||
.lines()
|
||||
.filter(|l| !l.chars().all(|c| c.is_ascii_hexdigit() || c == '\r'))
|
||||
.collect::<Vec<&str>>()
|
||||
.join("")
|
||||
} else {
|
||||
body_part.to_string()
|
||||
};
|
||||
|
||||
if json_body.is_empty() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
|
||||
serde_json::from_str(&json_body).map_err(|e| {
|
||||
format!(
|
||||
"Invalid JSON response: {} (body: {})",
|
||||
e,
|
||||
json_body.chars().take(100).collect::<String>()
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IosDevice {
|
||||
pub name: String,
|
||||
pub udid: String,
|
||||
pub state: String,
|
||||
pub runtime: String,
|
||||
pub is_real: bool,
|
||||
}
|
||||
|
||||
pub fn list_simulators() -> Result<Vec<IosDevice>, String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "list", "devices", "--json"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xcrun simctl: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("xcrun simctl failed. Xcode may not be installed.".to_string());
|
||||
}
|
||||
|
||||
let json_str = String::from_utf8_lossy(&output.stdout);
|
||||
let parsed: Value =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse simctl: {}", e))?;
|
||||
|
||||
let mut devices = Vec::new();
|
||||
if let Some(device_map) = parsed.get("devices").and_then(|v| v.as_object()) {
|
||||
for (runtime, device_list) in device_map {
|
||||
if let Some(arr) = device_list.as_array() {
|
||||
for device in arr {
|
||||
let name = device
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let udid = device
|
||||
.get("udid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let state = device
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
devices.push(IosDevice {
|
||||
name,
|
||||
udid,
|
||||
state,
|
||||
runtime: runtime.clone(),
|
||||
is_real: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn list_real_devices() -> Result<Vec<IosDevice>, String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["xctrace", "list", "devices"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xcrun xctrace: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut devices = Vec::new();
|
||||
let mut in_devices = false;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("== Devices ==") {
|
||||
in_devices = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("== Simulators ==") {
|
||||
break;
|
||||
}
|
||||
if !in_devices || trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Format: "Device Name (OS Version) (UDID)"
|
||||
if let Some(udid_start) = trimmed.rfind('(') {
|
||||
let udid_end = trimmed.len() - 1;
|
||||
let udid = &trimmed[udid_start + 1..udid_end];
|
||||
// Validate it looks like a UDID (contains hyphens)
|
||||
if udid.contains('-') && udid.len() > 20 {
|
||||
let name_part = trimmed[..udid_start].trim();
|
||||
let name = if let Some(paren_pos) = name_part.rfind('(') {
|
||||
name_part[..paren_pos].trim().to_string()
|
||||
} else {
|
||||
name_part.to_string()
|
||||
};
|
||||
devices.push(IosDevice {
|
||||
name,
|
||||
udid: udid.to_string(),
|
||||
state: "Connected".to_string(),
|
||||
runtime: String::new(),
|
||||
is_real: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn list_all_devices() -> Result<Vec<IosDevice>, String> {
|
||||
let mut all = list_simulators().unwrap_or_default();
|
||||
all.extend(list_real_devices().unwrap_or_default());
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn boot_simulator(udid: &str) -> Result<(), String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "boot", udid])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to boot simulator: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("current state: Booted") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to boot simulator {}: {}", udid, stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown_simulator(udid: &str) -> Result<(), String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "shutdown", udid])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to shutdown simulator: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("current state: Shutdown") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to shutdown simulator {}: {}", udid, stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn select_device(device_name: Option<&str>, udid: Option<&str>) -> Result<IosDevice, String> {
|
||||
if let Some(u) = udid {
|
||||
let devices = list_all_devices()?;
|
||||
return devices
|
||||
.into_iter()
|
||||
.find(|d| d.udid == u)
|
||||
.ok_or_else(|| format!("Device with UDID '{}' not found", u));
|
||||
}
|
||||
|
||||
if let Some(name) = device_name {
|
||||
let devices = list_all_devices()?;
|
||||
return devices
|
||||
.into_iter()
|
||||
.find(|d| d.name.to_lowercase().contains(&name.to_lowercase()))
|
||||
.ok_or_else(|| format!("Device '{}' not found", name));
|
||||
}
|
||||
|
||||
// Default: prefer most recent iPhone, prefer Pro
|
||||
let devices = list_simulators()?;
|
||||
let iphone_devices: Vec<&IosDevice> = devices
|
||||
.iter()
|
||||
.filter(|d| d.name.starts_with("iPhone"))
|
||||
.collect();
|
||||
|
||||
if iphone_devices.is_empty() {
|
||||
return devices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or("No iOS simulators found".to_string());
|
||||
}
|
||||
|
||||
// Prefer Pro models
|
||||
if let Some(pro) = iphone_devices.iter().find(|d| d.name.contains("Pro")) {
|
||||
return Ok((*pro).clone());
|
||||
}
|
||||
|
||||
Ok((*iphone_devices.last().unwrap()).clone())
|
||||
}
|
||||
|
||||
pub fn to_device_json(devices: &[IosDevice]) -> Value {
|
||||
let list: Vec<Value> = devices
|
||||
.iter()
|
||||
.map(|d| {
|
||||
json!({
|
||||
"name": d.name,
|
||||
"udid": d.udid,
|
||||
"state": d.state,
|
||||
"runtime": d.runtime,
|
||||
"isReal": d.is_real,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "devices": list })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ios_device_struct() {
|
||||
let device = IosDevice {
|
||||
name: "iPhone 15 Pro".to_string(),
|
||||
udid: "ABC-123".to_string(),
|
||||
state: "Booted".to_string(),
|
||||
runtime: "iOS-17-0".to_string(),
|
||||
is_real: false,
|
||||
};
|
||||
assert_eq!(device.name, "iPhone 15 Pro");
|
||||
assert!(!device.is_real);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_device_json() {
|
||||
let devices = vec![IosDevice {
|
||||
name: "Test".to_string(),
|
||||
udid: "123".to_string(),
|
||||
state: "Shutdown".to_string(),
|
||||
runtime: "iOS-17".to_string(),
|
||||
is_real: false,
|
||||
}];
|
||||
let json = to_device_json(&devices);
|
||||
assert!(json.get("devices").unwrap().as_array().unwrap().len() == 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod appium;
|
||||
pub mod backend;
|
||||
pub mod client;
|
||||
pub mod ios;
|
||||
pub mod safari;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct SafariDriverProcess {
|
||||
child: Child,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl SafariDriverProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SafariDriverProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_safaridriver() -> Option<PathBuf> {
|
||||
let candidates = ["/usr/bin/safaridriver"];
|
||||
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Try PATH
|
||||
if let Ok(output) = Command::new("which").arg("safaridriver").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn launch_safaridriver(port: u16) -> Result<SafariDriverProcess, String> {
|
||||
let driver_path = find_safaridriver()
|
||||
.ok_or("safaridriver not found. Safari WebDriver requires macOS with Safari.")?;
|
||||
|
||||
let child = Command::new(&driver_path)
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch safaridriver: {}", e))?;
|
||||
|
||||
// Wait for driver to be ready
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
Ok(SafariDriverProcess { child, port })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_safaridriver() {
|
||||
// Only check on macOS
|
||||
if cfg!(target_os = "macos") {
|
||||
let result = find_safaridriver();
|
||||
// Don't assert Some since it may not be enabled
|
||||
if let Some(path) = result {
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewSessionRequest {
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Capabilities {
|
||||
pub always_match: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionResponse {
|
||||
pub value: SessionValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionValue {
|
||||
pub session_id: String,
|
||||
pub capabilities: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebDriverResponse {
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebDriverError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ElementResponse {
|
||||
pub value: ElementValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ElementValue {
|
||||
#[serde(rename = "element-6066-11e4-a52e-4f735466cecf")]
|
||||
pub element_id: Option<String>,
|
||||
#[serde(rename = "ELEMENT")]
|
||||
pub element_legacy: Option<String>,
|
||||
}
|
||||
|
||||
impl ElementValue {
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
self.element_id
|
||||
.as_deref()
|
||||
.or(self.element_legacy.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FindElementRequest {
|
||||
pub using: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExecuteScriptRequest {
|
||||
pub script: String,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieRequest {
|
||||
pub cookie: CookieData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieData {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secure: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiry: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
+526
-26
@@ -1,9 +1,92 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::color;
|
||||
use crate::connection::Response;
|
||||
|
||||
pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
if json_mode {
|
||||
println!("{}", serde_json::to_string(resp).unwrap_or_default());
|
||||
static BOUNDARY_NONCE: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Per-process nonce for content boundary markers. Uses a CSPRNG (getrandom) so
|
||||
/// that untrusted page content cannot predict or spoof the boundary delimiter.
|
||||
/// Process ID or timestamps would be insufficient since pages can read those.
|
||||
fn get_boundary_nonce() -> &'static str {
|
||||
BOUNDARY_NONCE.get_or_init(|| {
|
||||
let mut buf = [0u8; 16];
|
||||
getrandom::getrandom(&mut buf).expect("failed to generate random nonce");
|
||||
buf.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OutputOptions {
|
||||
pub json: bool,
|
||||
pub content_boundaries: bool,
|
||||
pub max_output: Option<usize>,
|
||||
}
|
||||
|
||||
fn truncate_if_needed(content: &str, max: Option<usize>) -> String {
|
||||
let Some(limit) = max else {
|
||||
return content.to_string();
|
||||
};
|
||||
// Fast path: byte length is a lower bound on char count, so if the
|
||||
// byte length is within the limit the char count must be too.
|
||||
if content.len() <= limit {
|
||||
return content.to_string();
|
||||
}
|
||||
// Find the byte offset of the limit-th character.
|
||||
match content.char_indices().nth(limit).map(|(i, _)| i) {
|
||||
Some(byte_offset) => {
|
||||
let total_chars = content.chars().count();
|
||||
format!(
|
||||
"{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]",
|
||||
&content[..byte_offset],
|
||||
limit,
|
||||
total_chars
|
||||
)
|
||||
}
|
||||
// Content has fewer than `limit` chars despite more bytes
|
||||
None => content.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptions) {
|
||||
let content = truncate_if_needed(content, opts.max_output);
|
||||
if opts.content_boundaries {
|
||||
let origin_str = origin.unwrap_or("unknown");
|
||||
let nonce = get_boundary_nonce();
|
||||
println!(
|
||||
"--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---",
|
||||
nonce, origin_str
|
||||
);
|
||||
println!("{}", content);
|
||||
println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce);
|
||||
} else {
|
||||
println!("{}", content);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
|
||||
if opts.json {
|
||||
if opts.content_boundaries {
|
||||
let mut json_val = serde_json::to_value(resp).unwrap_or_default();
|
||||
if let Some(obj) = json_val.as_object_mut() {
|
||||
let nonce = get_boundary_nonce();
|
||||
let origin = obj
|
||||
.get("data")
|
||||
.and_then(|d| d.get("origin"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
obj.insert(
|
||||
"_boundary".to_string(),
|
||||
serde_json::json!({
|
||||
"nonce": nonce,
|
||||
"origin": origin,
|
||||
}),
|
||||
);
|
||||
}
|
||||
println!("{}", serde_json::to_string(&json_val).unwrap_or_default());
|
||||
} else {
|
||||
println!("{}", serde_json::to_string(resp).unwrap_or_default());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,6 +108,39 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
if let Some(warning) = data.get("warning").and_then(|v| v.as_str()) {
|
||||
println!("{} {}", color::warning_indicator(), warning);
|
||||
}
|
||||
if let Some(risk_signals) = data.get("riskSignals").and_then(|v| v.as_array()) {
|
||||
for signal in risk_signals {
|
||||
let code = signal
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown_risk");
|
||||
let source = signal
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let evidence = signal
|
||||
.get("evidence")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
let confidence = signal
|
||||
.get("confidence")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
println!(
|
||||
"{} risk-signal code={} source={} evidence={} confidence={:.2}",
|
||||
color::warning_indicator(),
|
||||
code,
|
||||
source,
|
||||
evidence,
|
||||
confidence
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(warnings) = data.get("warnings").and_then(|v| v.as_array()) {
|
||||
for warning in warnings.iter().filter_map(|v| v.as_str()) {
|
||||
println!("{} {}", color::warning_indicator(), warning);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
println!("{}", url);
|
||||
@@ -33,6 +149,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
// Diff responses -- route by action to avoid fragile shape probing
|
||||
if let Some(obj) = data.as_object() {
|
||||
match action {
|
||||
Some("doctor") => {
|
||||
print_doctor_report(obj);
|
||||
return;
|
||||
}
|
||||
Some("diff_snapshot") => {
|
||||
print_snapshot_diff(obj);
|
||||
return;
|
||||
@@ -55,9 +175,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let origin = data.get("origin").and_then(|v| v.as_str());
|
||||
// Snapshot
|
||||
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
|
||||
println!("{}", snapshot);
|
||||
print_with_boundaries(snapshot, origin, opts);
|
||||
return;
|
||||
}
|
||||
// Title
|
||||
@@ -67,12 +188,12 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
}
|
||||
// Text
|
||||
if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
|
||||
println!("{}", text);
|
||||
print_with_boundaries(text, origin, opts);
|
||||
return;
|
||||
}
|
||||
// HTML
|
||||
if let Some(html) = data.get("html").and_then(|v| v.as_str()) {
|
||||
println!("{}", html);
|
||||
print_with_boundaries(html, origin, opts);
|
||||
return;
|
||||
}
|
||||
// Value
|
||||
@@ -100,10 +221,8 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
}
|
||||
// Eval result
|
||||
if let Some(result) = data.get("result") {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(result).unwrap_or_default()
|
||||
);
|
||||
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
|
||||
print_with_boundaries(&formatted, origin, opts);
|
||||
return;
|
||||
}
|
||||
// iOS Devices
|
||||
@@ -190,10 +309,27 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
}
|
||||
// Console logs
|
||||
if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) {
|
||||
for log in logs {
|
||||
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} {}", color::console_level_prefix(level), text);
|
||||
if opts.content_boundaries {
|
||||
let mut console_output = String::new();
|
||||
for log in logs {
|
||||
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
console_output.push_str(&format!(
|
||||
"{} {}\n",
|
||||
color::console_level_prefix(level),
|
||||
text
|
||||
));
|
||||
}
|
||||
if console_output.ends_with('\n') {
|
||||
console_output.pop();
|
||||
}
|
||||
print_with_boundaries(&console_output, origin, opts);
|
||||
} else {
|
||||
for log in logs {
|
||||
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} {}", color::console_level_prefix(level), text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -231,10 +367,14 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Cleared requests
|
||||
// Cleared (cookies or request log)
|
||||
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_bool()) {
|
||||
if cleared {
|
||||
println!("{} Request log cleared", color::success_indicator());
|
||||
let label = match action {
|
||||
Some("cookies_clear") => "Cookies cleared",
|
||||
_ => "Request log cleared",
|
||||
};
|
||||
println!("{} {}", color::success_indicator(), label);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -295,9 +435,13 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Closed
|
||||
// Closed (browser or tab)
|
||||
if data.get("closed").is_some() {
|
||||
println!("{} Browser closed", color::success_indicator());
|
||||
let label = match action {
|
||||
Some("tab_close") => "Tab closed",
|
||||
_ => "Browser closed",
|
||||
};
|
||||
println!("{} {}", color::success_indicator(), label);
|
||||
return;
|
||||
}
|
||||
// Recording start (has "started" field)
|
||||
@@ -566,6 +710,145 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
||||
println!("{}", note);
|
||||
return;
|
||||
}
|
||||
// Auth list
|
||||
if let Some(profiles) = data.get("profiles").and_then(|v| v.as_array()) {
|
||||
if profiles.is_empty() {
|
||||
println!("{}", color::dim("No auth profiles saved"));
|
||||
} else {
|
||||
println!("{}", color::bold("Auth profiles:"));
|
||||
for p in profiles {
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = p.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let user = p.get("username").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(
|
||||
" {} {} {}",
|
||||
color::green(name),
|
||||
color::dim(user),
|
||||
color::dim(url)
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth show
|
||||
if let Some(profile) = data.get("profile").and_then(|v| v.as_object()) {
|
||||
let name = profile.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = profile.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let user = profile
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let created = profile
|
||||
.get("createdAt")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str());
|
||||
println!("Name: {}", name);
|
||||
println!("URL: {}", url);
|
||||
println!("Username: {}", user);
|
||||
println!("Created: {}", created);
|
||||
if let Some(ll) = last_login {
|
||||
println!("Last login: {}", ll);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth save/update/login/delete
|
||||
if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(
|
||||
"{} Auth profile '{}' saved",
|
||||
color::success_indicator(),
|
||||
name
|
||||
);
|
||||
return;
|
||||
}
|
||||
if data
|
||||
.get("updated")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
&& !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false)
|
||||
{
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(
|
||||
"{} Auth profile '{}' updated",
|
||||
color::success_indicator(),
|
||||
name
|
||||
);
|
||||
return;
|
||||
}
|
||||
if data
|
||||
.get("loggedIn")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||
println!(
|
||||
"{} Logged in as '{}' - {}",
|
||||
color::success_indicator(),
|
||||
name,
|
||||
title
|
||||
);
|
||||
} else {
|
||||
println!("{} Logged in as '{}'", color::success_indicator(), name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if data
|
||||
.get("deleted")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
|
||||
println!(
|
||||
"{} Auth profile '{}' deleted",
|
||||
color::success_indicator(),
|
||||
name
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation required (for orchestrator use)
|
||||
if data
|
||||
.get("confirmation_required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let description = data
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let cid = data
|
||||
.get("confirmation_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
println!("Confirmation required:");
|
||||
println!(" {}: {}", category, description);
|
||||
println!(" Run: agent-browser confirm {}", cid);
|
||||
println!(" Or: agent-browser deny {}", cid);
|
||||
return;
|
||||
}
|
||||
if data
|
||||
.get("confirmed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
println!("{} Action confirmed", color::success_indicator());
|
||||
return;
|
||||
}
|
||||
if data
|
||||
.get("denied")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
println!("{} Action denied", color::success_indicator());
|
||||
return;
|
||||
}
|
||||
|
||||
// Default success
|
||||
println!("{} Done", color::success_indicator());
|
||||
}
|
||||
@@ -590,10 +873,14 @@ Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
--headers <json> Set HTTP headers (scoped to this origin)
|
||||
--risk-mode <mode> Risk handling for verify/captcha pages: off, warn, block
|
||||
--wait-until <mode> Navigation wait strategy: load, domcontentloaded, networkidle
|
||||
--headed Show browser window
|
||||
|
||||
Examples:
|
||||
agent-browser open example.com
|
||||
agent-browser --risk-mode block open example.com
|
||||
agent-browser --wait-until domcontentloaded open example.com
|
||||
agent-browser open https://github.com
|
||||
agent-browser open localhost:3000
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
|
||||
@@ -1457,8 +1744,8 @@ Operations:
|
||||
|
||||
Cookie Set Options:
|
||||
--url <url> URL for the cookie (allows setting before page load)
|
||||
--domain <domain> Cookie domain (e.g., ".example.com")
|
||||
--path <path> Cookie path (e.g., "/api")
|
||||
--domain <domain> Cookie domain (use with --path, e.g., ".example.com")
|
||||
--path <path> Cookie path (use with --domain, e.g., "/api")
|
||||
--httpOnly Set HttpOnly flag (prevents JavaScript access)
|
||||
--secure Set Secure flag (HTTPS only)
|
||||
--sameSite <Strict|Lax|None> SameSite policy
|
||||
@@ -1466,6 +1753,7 @@ Cookie Set Options:
|
||||
|
||||
Note: If --url, --domain, and --path are all omitted, the cookie will be set
|
||||
for the current page URL.
|
||||
When --url is omitted, --domain and --path must be provided together.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1570,6 +1858,64 @@ Examples:
|
||||
"##
|
||||
}
|
||||
|
||||
// === Auth ===
|
||||
"auth" => {
|
||||
r##"
|
||||
agent-browser auth - Manage authentication profiles
|
||||
|
||||
Usage: agent-browser auth <subcommand> [args]
|
||||
|
||||
Subcommands:
|
||||
save <name> Save credentials for a login profile
|
||||
login <name> Login using saved credentials
|
||||
list List saved profiles (names and URLs only)
|
||||
show <name> Show profile metadata (no passwords)
|
||||
delete <name> Delete a saved profile
|
||||
|
||||
Save Options:
|
||||
--url <url> Login page URL (required)
|
||||
--username <user> Username (required)
|
||||
--password <pass> Password (required unless --password-stdin)
|
||||
--password-stdin Read password from stdin (recommended)
|
||||
--username-selector <s> Custom CSS selector for username field
|
||||
--password-selector <s> Custom CSS selector for password field
|
||||
--submit-selector <s> Custom CSS selector for submit button
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
agent-browser auth save github --url https://github.com/login --username user --password pass
|
||||
agent-browser auth login github
|
||||
agent-browser auth list
|
||||
agent-browser auth show github
|
||||
agent-browser auth delete github
|
||||
"##
|
||||
}
|
||||
|
||||
// === Confirm/Deny ===
|
||||
"confirm" | "deny" => {
|
||||
r##"
|
||||
agent-browser confirm/deny - Approve or deny pending actions
|
||||
|
||||
Usage:
|
||||
agent-browser confirm <confirmation-id>
|
||||
agent-browser deny <confirmation-id>
|
||||
|
||||
When --confirm-actions is set, certain action categories return a
|
||||
confirmation_required response with a confirmation ID. Use confirm/deny
|
||||
to approve or reject the action.
|
||||
|
||||
Pending confirmations auto-deny after 60 seconds.
|
||||
|
||||
Examples:
|
||||
agent-browser confirm c_8f3a1234
|
||||
agent-browser deny c_8f3a1234
|
||||
"##
|
||||
}
|
||||
|
||||
// === Dialog ===
|
||||
"dialog" => {
|
||||
r##"
|
||||
@@ -1777,7 +2123,8 @@ Operations:
|
||||
clean --older-than <days> Delete expired state files
|
||||
|
||||
Automatic State Persistence:
|
||||
Use --session-name to auto-save/restore state across restarts:
|
||||
Use --session-name to auto-save/restore state across restarts.
|
||||
If omitted, it defaults to --session (or "default"):
|
||||
agent-browser --session-name myapp open https://example.com
|
||||
Or set AGENT_BROWSER_SESSION_NAME environment variable.
|
||||
|
||||
@@ -1885,6 +2232,33 @@ Examples:
|
||||
agent-browser click @e1
|
||||
"##
|
||||
}
|
||||
"doctor" => {
|
||||
r##"
|
||||
agent-browser doctor - Diagnose CDP, sourceURL sanitization, and tab-group plugin health
|
||||
|
||||
Usage: agent-browser doctor
|
||||
|
||||
Runs a non-destructive health check focused on:
|
||||
- CDP endpoint reachability (preferred :9333 + common ports)
|
||||
- DevToolsActivePort discovery from local Chrome profiles
|
||||
- CDP sourceURL sanitization probe (Runtime.evaluate leakage check)
|
||||
- Plugin handshake page context suitability (internal page vs http(s))
|
||||
- Tab-group plugin handshake status (when connected via CDP)
|
||||
|
||||
Notes:
|
||||
- doctor does not accept positional arguments
|
||||
- If browser is not already connected, doctor will still report CDP probe results
|
||||
- Plugin handshake requires CDP mode, a normal http(s) page, and the extension installed
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser doctor
|
||||
agent-browser --json doctor
|
||||
"##
|
||||
}
|
||||
|
||||
// === iOS Commands ===
|
||||
"tap" => {
|
||||
@@ -2019,6 +2393,7 @@ pub fn print_help() {
|
||||
agent-browser - fast browser automation CLI for AI agents
|
||||
|
||||
Usage: agent-browser <command> [args] [options]
|
||||
Aliases: agent-browser, agent-browser-stealth, abs
|
||||
|
||||
Core Commands:
|
||||
open <url> Navigate to URL
|
||||
@@ -2095,6 +2470,17 @@ Debug:
|
||||
errors [--clear] View page errors
|
||||
highlight <sel> Highlight element
|
||||
|
||||
Auth Vault:
|
||||
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
|
||||
auth login <name> Login using saved credentials
|
||||
auth list List saved auth profiles
|
||||
auth show <name> Show auth profile metadata
|
||||
auth delete <name> Delete auth profile
|
||||
|
||||
Confirmation:
|
||||
confirm <id> Approve a pending action
|
||||
deny <id> Deny a pending action
|
||||
|
||||
Sessions:
|
||||
session Show current session name
|
||||
session list List active sessions
|
||||
@@ -2102,6 +2488,7 @@ Sessions:
|
||||
Setup:
|
||||
install Install browser binaries
|
||||
install --with-deps Also install system dependencies (Linux)
|
||||
doctor Diagnose CDP + sourceURL + plugin health
|
||||
|
||||
Snapshot Options:
|
||||
-i, --interactive Only interactive elements
|
||||
@@ -2132,10 +2519,21 @@ Options:
|
||||
--headed Show browser window (not headless)
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--auto-connect Auto-discover and connect to running Chrome
|
||||
Project default: require existing browser at localhost:9333 (no auto local fallback)
|
||||
Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
|
||||
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
|
||||
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||
--tab-group <name> Base title for agent tab groups (CDP plugin mode; silent no-op if plugin unavailable)
|
||||
--tab-group-plugin-id <id> Expected Chrome extension ID for tab-group handshake (or AGENT_BROWSER_TAB_GROUP_PLUGIN_ID)
|
||||
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
|
||||
--wait-until <mode> Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle
|
||||
--session-name <name> Auto-save/restore session state (defaults to --session)
|
||||
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
|
||||
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
|
||||
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
|
||||
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
|
||||
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
|
||||
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
|
||||
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
|
||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||
--debug Debug output
|
||||
--version, -V Show version (fork builds include upstream/fork info)
|
||||
@@ -2143,7 +2541,7 @@ Options:
|
||||
Policy:
|
||||
--profile / AGENT_BROWSER_PROFILE are forbidden
|
||||
--channel / AGENT_BROWSER_CHANNEL are forbidden
|
||||
Use existing browser session (CDP localhost:9333) or pass --cdp explicitly
|
||||
Auto-attach existing browser (prefer CDP localhost:9333, then auto-discovery), or pass --cdp explicitly
|
||||
|
||||
Configuration:
|
||||
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
|
||||
@@ -2167,7 +2565,7 @@ Configuration:
|
||||
Environment:
|
||||
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
||||
AGENT_BROWSER_SESSION Session name (default: "default")
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name (default: AGENT_BROWSER_SESSION)
|
||||
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
|
||||
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
|
||||
@@ -2186,6 +2584,9 @@ Environment:
|
||||
AGENT_BROWSER_TIMEZONE Override auto-detected timezone (e.g., Asia/Taipei)
|
||||
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
|
||||
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
|
||||
AGENT_BROWSER_TAB_GROUP Base title for tab groups (default: "Agent Browser Stealth"; session suffix auto-appended)
|
||||
AGENT_BROWSER_TAB_GROUP_PLUGIN_ID Expected Chrome extension ID for tab-group handshake (default: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block)
|
||||
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
|
||||
@@ -2193,6 +2594,13 @@ Environment:
|
||||
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
|
||||
AGENT_BROWSER_IOS_DEVICE Default iOS device name
|
||||
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
||||
AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers
|
||||
AGENT_BROWSER_MAX_OUTPUT Max characters for page output
|
||||
AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed domain patterns
|
||||
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
|
||||
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
|
||||
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
|
||||
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
|
||||
|
||||
Install (recommended, fastest - native Rust CLI):
|
||||
npm install -g agent-browser-stealth
|
||||
@@ -2214,6 +2622,7 @@ Examples:
|
||||
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
||||
agent-browser --color-scheme dark open example.com # Dark mode
|
||||
agent-browser --risk-mode block open example.com # Block on verification/captcha pages
|
||||
agent-browser --session-name myapp open example.com # Auto-save/restore state
|
||||
|
||||
Command Chaining:
|
||||
@@ -2233,6 +2642,94 @@ iOS Simulator (requires Xcode and Appium):
|
||||
);
|
||||
}
|
||||
|
||||
pub fn print_response(resp: &Response, json: bool, action: Option<&str>) {
|
||||
let opts = OutputOptions {
|
||||
json,
|
||||
content_boundaries: false,
|
||||
max_output: None,
|
||||
};
|
||||
print_response_with_opts(resp, action, &opts);
|
||||
}
|
||||
|
||||
fn status_badge(status: &str) -> String {
|
||||
match status {
|
||||
"pass" => color::green("PASS"),
|
||||
"warn" => color::yellow("WARN"),
|
||||
"fail" => color::red("FAIL"),
|
||||
"skip" => color::dim("SKIP"),
|
||||
_ => status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_doctor_report(data: &serde_json::Map<String, serde_json::Value>) {
|
||||
let ok = data.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let summary = if ok {
|
||||
format!("{} doctor checks passed", color::success_indicator())
|
||||
} else {
|
||||
format!("{} doctor found issues", color::error_indicator())
|
||||
};
|
||||
println!("{}", summary);
|
||||
|
||||
if let Some(context) = data.get("context").and_then(|v| v.as_object()) {
|
||||
let launched = context
|
||||
.get("launched")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let connection = context
|
||||
.get("connectionKind")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let session = context
|
||||
.get("session")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("default");
|
||||
let cdp_endpoint = context
|
||||
.get("cdpEndpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
println!(
|
||||
" context: launched={} connection={} session={} cdp={}",
|
||||
launched, connection, session, cdp_endpoint
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(checks) = data.get("checks").and_then(|v| v.as_array()) {
|
||||
for check in checks {
|
||||
let Some(obj) = check.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let name = obj
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let status = obj
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let message = obj.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(" [{}] {} - {}", status_badge(status), name, message);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(plugin) = data.get("plugin").and_then(|v| v.as_object()) {
|
||||
let plugin_status = plugin
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let plugin_message = plugin.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let plugin_id = plugin
|
||||
.get("configuredPluginId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
println!(
|
||||
" plugin: [{}] id={} {}",
|
||||
status_badge(plugin_status),
|
||||
plugin_id,
|
||||
plugin_message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_snapshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
||||
let changed = data
|
||||
.get("changed")
|
||||
@@ -2324,7 +2821,10 @@ fn parse_fork_version(version: &str) -> Option<(&str, &str)> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !fork.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
|
||||
if !fork
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((upstream, fork))
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# PRD: CLI Web 数据采集体验优化(以小红书场景为例)
|
||||
|
||||
- 文档版本: v0.1
|
||||
- 状态: Draft
|
||||
- 作者: Codex
|
||||
- 日期: 2026-03-04
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
在使用 `agent-browser` CLI 执行「小红书宠物博主采集(100 条)」时,当前流程可完成任务,但存在明显的可用性与稳定性痛点:
|
||||
|
||||
1. 网络层可观测性不足,响应体抓取不稳定,需注入脚本劫持。
|
||||
2. 分页采集依赖手工 `scroll down + wait`,重复劳动且易漏数据。
|
||||
3. 结构化导出缺少一站式命令,需要 `eval` 二次解析。
|
||||
4. 页面交互依赖文本选择,页面文案变动后脆弱。
|
||||
5. 反爬失败时缺少可解释的自动回退策略。
|
||||
6. 用户对“可抓字段”预期不清(例如搜索接口无联系方式)。
|
||||
7. 长会话缺少快照与断点续抓机制。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
## 2.1 目标
|
||||
|
||||
1. 将常见采集链路从“脚本拼接”降为“CLI 原生命令组合”。
|
||||
2. 让关键动作具备可观测性(日志)和可恢复性(快照/续跑)。
|
||||
3. 降低站点轻微改版、反爬限制带来的失败率。
|
||||
|
||||
## 2.2 非目标
|
||||
|
||||
1. 不承诺绕过平台强风控或登录体系。
|
||||
2. 不在本期实现完整通用爬虫 DSL。
|
||||
3. 不默认抓取平台未公开展示的隐私字段。
|
||||
|
||||
## 3. 目标用户与核心场景
|
||||
|
||||
1. 增长/运营: 按关键词采集账号基础数据并导出 CSV。
|
||||
2. 测试/研发: 复现抓取问题,定位请求失败原因。
|
||||
3. AI Agent 工作流: 在 CLI 内稳定执行“搜索 -> 翻页 -> 提取 -> 导出”。
|
||||
|
||||
## 4. 需求范围与优先级
|
||||
|
||||
## 4.1 P0
|
||||
|
||||
1. `network capture` 增强模式(可过滤、可落盘 response body)。
|
||||
2. `scroll-collect` 自动滚动采集(按页数或直到无新增)。
|
||||
3. `extract` / `extract-to` 结构化导出(JSON/CSV)。
|
||||
|
||||
## 4.2 P1
|
||||
|
||||
1. 语义选择器与 fallback 链(role/aria/data/text)。
|
||||
2. 401/403/406 智能回退(页面触发 + 回包监听)。
|
||||
3. 可抓字段矩阵与二段式采集文档提示。
|
||||
|
||||
## 4.3 P2
|
||||
|
||||
1. `session snapshot` + `crawl resume` 断点续抓。
|
||||
|
||||
## 5. CLI 方案设计
|
||||
|
||||
## 5.1 网络捕获增强
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser network capture --match '/api/sns/web/v1/search/usersearch' --save ./out.ndjson
|
||||
agent-browser network capture --domain edith.xiaohongshu.com --method POST --save ./xhs_usersearch.ndjson
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
- `--match <regex>`: 按 URL 正则过滤。
|
||||
- `--domain <host>`: 按域名过滤。
|
||||
- `--method <GET|POST|...>`: 按方法过滤。
|
||||
- `--status <code|range>`: 按状态过滤。
|
||||
- `--save <path>`: NDJSON 输出文件。
|
||||
- `--include-body <request|response|both>`: 控制 body 输出范围。
|
||||
- `--max-body-bytes <n>`: 单条 body 截断阈值。
|
||||
|
||||
NDJSON 记录结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-03-04T10:00:00.123Z",
|
||||
"session_id": "sess_abc",
|
||||
"request_id": "req_123",
|
||||
"method": "POST",
|
||||
"url": "https://edith.xiaohongshu.com/api/sns/web/v1/search/usersearch",
|
||||
"status": 200,
|
||||
"duration_ms": 312,
|
||||
"request_headers": {"content-type": "application/json"},
|
||||
"request_body": "{...}",
|
||||
"response_headers": {"content-type": "application/json"},
|
||||
"response_body": "{...}",
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
## 5.2 自动滚动采集
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser scroll-collect --until no-new-items --max-steps 200 --idle-rounds 3
|
||||
agent-browser scroll-collect --pages 20 --wait-ms 1200
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
1. 每轮执行滚动与等待。
|
||||
2. 基于 DOM 项数量或网络新增请求判断“是否有新增”。
|
||||
3. 达到停止条件后输出结束原因。
|
||||
|
||||
输出示例:
|
||||
|
||||
```text
|
||||
step=1 new_items=15 total_items=15
|
||||
step=2 new_items=15 total_items=30
|
||||
...
|
||||
stop_reason=no-new-items idle_rounds=3 total_items=135
|
||||
```
|
||||
|
||||
## 5.3 结构化提取与导出
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser extract --from network --match usersearch --fields 'name,fans,note_count,red_id'
|
||||
agent-browser extract-to --from network --match usersearch --fields 'name,fans,note_count,red_id,url' --format csv --out ./users.csv
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
- `--from <network|dom|eval>`: 数据源。
|
||||
- `--match <pattern>`: 来源过滤(URL/事件名)。
|
||||
- `--query <JMESPath|JSONPath>`: 自定义提取表达式。
|
||||
- `--fields <a,b,c>`: 字段映射快捷写法。
|
||||
- `--dedupe-by <field>`: 去重键。
|
||||
- `--limit <n>`: 限制条数。
|
||||
- `--format <json|ndjson|csv>`: 输出格式。
|
||||
- `--out <path>`: 文件输出路径。
|
||||
|
||||
## 5.4 语义选择器与回退链
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser click --selector 'role=tab[name="用户"]' --fallback 'aria=用户,text=用户'
|
||||
agent-browser find --selector 'data-testid=user-tab' --fallback 'role=tab[name="用户"],text=用户'
|
||||
```
|
||||
|
||||
策略:
|
||||
|
||||
1. 主选择器失败后按 fallback 顺序重试。
|
||||
2. 日志打印每次尝试与失败原因。
|
||||
|
||||
## 5.5 反爬失败自动回退
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser request replay --on-status 401,403,406 --fallback page-action
|
||||
```
|
||||
|
||||
策略:
|
||||
|
||||
1. 直接请求失败后自动回退到页面行为触发。
|
||||
2. 自动复用 UA/Referer/Cookie Jar。
|
||||
3. 捕获最终有效响应并给出“回退成功/失败”日志。
|
||||
|
||||
## 5.6 会话快照与断点续抓
|
||||
|
||||
命令草案:
|
||||
|
||||
```bash
|
||||
agent-browser session snapshot save ./snapshots/xhs-20260304.json
|
||||
agent-browser crawl resume --snapshot ./snapshots/xhs-20260304.json --out ./users.csv
|
||||
```
|
||||
|
||||
快照最小字段:
|
||||
|
||||
- 当前 URL
|
||||
- 关键词/筛选参数
|
||||
- 已抓 user_id 集合摘要(可哈希分片)
|
||||
- 分页进度(page/scroll step)
|
||||
- 导出配置(fields/format/out)
|
||||
|
||||
## 6. 错误码设计(草案)
|
||||
|
||||
- `AB_NET_CAPTURE_BODY_UNAVAILABLE` (1001): 响应体不可用(被浏览器策略阻断或已释放)。
|
||||
- `AB_SCROLL_TIMEOUT_NO_PROGRESS` (1101): 滚动超时且无新增。
|
||||
- `AB_EXTRACT_QUERY_INVALID` (1201): 提取表达式语法错误。
|
||||
- `AB_EXTRACT_OUTPUT_FAILED` (1202): 导出失败(权限/路径不可写)。
|
||||
- `AB_SELECTOR_NOT_FOUND` (1301): 主选择器与 fallback 全部失败。
|
||||
- `AB_REQUEST_BLOCKED_406` (1406): 请求被风控拦截,且回退链路失败。
|
||||
- `AB_RESUME_SNAPSHOT_INVALID` (1501): 快照损坏或版本不兼容。
|
||||
|
||||
要求:
|
||||
|
||||
1. CLI 退出码与错误码可映射。
|
||||
2. 错误输出提供 `hint`(下一步建议命令)。
|
||||
|
||||
## 7. 日志与可观测性
|
||||
|
||||
默认人类可读,开启 `--log-format json` 输出结构化日志。
|
||||
|
||||
JSON 日志字段:
|
||||
|
||||
- `ts`
|
||||
- `level`
|
||||
- `session_id`
|
||||
- `command`
|
||||
- `event`
|
||||
- `step`
|
||||
- `url`
|
||||
- `status`
|
||||
- `error_code`
|
||||
- `message`
|
||||
- `hint`
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{"ts":"2026-03-04T10:11:22.123Z","level":"INFO","command":"scroll-collect","event":"step","step":12,"new_items":15,"total_items":180}
|
||||
{"ts":"2026-03-04T10:13:01.001Z","level":"WARN","command":"request replay","event":"fallback","status":406,"message":"direct request blocked, fallback to page-action"}
|
||||
```
|
||||
|
||||
## 8. 文档与帮助信息更新要求
|
||||
|
||||
当功能落地时,需要同步更新以下位置(按仓库规范):
|
||||
|
||||
1. `cli/src/output.rs`(`--help`、示例、环境变量)
|
||||
2. `README.md`(命令选项、样例)
|
||||
3. `skills/agent-browser/SKILL.md`(Agent 工作流)
|
||||
4. `docs/src/app/`(新增/更新 MDX 页面,表格使用 HTML `<table>`)
|
||||
5. 对应源码内联注释
|
||||
|
||||
## 9. 验收用例(首批)
|
||||
|
||||
1. `network capture` 能稳定保存目标接口完整 request/response body。
|
||||
2. 设置 `--max-body-bytes` 后被截断记录带 `truncated=true`。
|
||||
3. `scroll-collect --pages 5` 精确执行 5 轮并退出。
|
||||
4. `scroll-collect --until no-new-items` 在连续空增量 N 轮后退出。
|
||||
5. `extract-to ... --format csv` 产出可打开 CSV 且列名正确。
|
||||
6. `extract --dedupe-by user_id` 去重结果稳定。
|
||||
7. selector 主规则失败时,fallback 生效并成功点击。
|
||||
8. 对 406 场景触发自动回退并成功捕获有效响应。
|
||||
9. 回退失败时返回 `AB_REQUEST_BLOCKED_406` 且提供 hint。
|
||||
10. `session snapshot save/load` 前后任务可恢复。
|
||||
11. `crawl resume` 不重复导出已抓 ID。
|
||||
12. `--log-format json` 日志字段完整,便于机器消费。
|
||||
|
||||
## 10. 里程碑建议
|
||||
|
||||
1. M1(1 周): `network capture` + `scroll-collect`。
|
||||
2. M2(1 周): `extract-to` + selector fallback。
|
||||
3. M3(1 周): 406 回退链路 + 文档补全。
|
||||
4. M4(1 周): snapshot/resume + 稳定性打磨。
|
||||
|
||||
## 11. 风险与缓解
|
||||
|
||||
1. 平台策略变化导致规则失效。
|
||||
缓解: 增加站点适配层与策略开关,保留回退日志。
|
||||
2. 响应体过大带来内存与 IO 压力。
|
||||
缓解: 流式写入 NDJSON + 截断阈值。
|
||||
3. 通用提取表达式学习成本高。
|
||||
缓解: 提供字段模板与场景 presets。
|
||||
|
||||
## 12. 开放问题
|
||||
|
||||
1. `extract` 表达式标准优先 JSONPath 还是 JMESPath?
|
||||
2. `session snapshot` 是否需要加密(含 cookie 元信息)?
|
||||
3. 是否提供站点模板(如 `preset xiaohongshu-user-search`)以降低上手成本?
|
||||
@@ -0,0 +1,192 @@
|
||||
# 浏览器自动化攻防方案设计:检测模型与分层控制面
|
||||
|
||||
本文聚焦浏览器自动化的攻防方案设计,按两个部分组织:
|
||||
|
||||
1. **原理**:风险评分系统如何形成结论
|
||||
2. **控制面**:如何用分层设计降低风险与波动
|
||||
|
||||
本文不包含命令行操作与工程实现步骤。
|
||||
|
||||
Turnstile 专题内容见:
|
||||
[Cloudflare Turnstile 攻防方案设计:系统原理与控制面](https://blog.misonote.com/zh/posts/cloudflare-turnstile-stability-principles/)
|
||||
|
||||
---
|
||||
|
||||
## 一、原理
|
||||
|
||||
### 1.1 风险评分不是单点命中
|
||||
|
||||
高风控站点的“是否挑战/是否降权”通常来自多维评分,而不是某一条规则的二元判断。
|
||||
|
||||
主要输入维度:
|
||||
|
||||
1. **一致性**:同一身份在不同表面是否互相矛盾
|
||||
2. **稀有性**:低频异常组合是否出现
|
||||
3. **时序性**:行为时间序列是否呈机械统计特征
|
||||
4. **执行完整性**:关键链路(挑战脚本、跨域资源、worker)是否被破坏
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["环境与行为"] --> B["一致性评分"]
|
||||
A --> C["稀有性评分"]
|
||||
A --> D["时序评分"]
|
||||
A --> E["执行完整性评分"]
|
||||
B --> F["综合风险"]
|
||||
C --> F
|
||||
D --> F
|
||||
E --> F
|
||||
F --> G{"放行/挑战/限流"}
|
||||
```
|
||||
|
||||
### 1.2 一致性:约束集合而非单点修饰
|
||||
|
||||
一致性问题的本质是“同一身份在多个观测面上的约束必须同时成立”。
|
||||
|
||||
#### 1.2.1 约束集合示意
|
||||
|
||||
可以把身份一致性建模为“约束图”:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
UA["UA 字符串"] --> UACH["UA-CH / userAgentMetadata"]
|
||||
UA --> LangH["Accept-Language"]
|
||||
LangH --> LangJS["navigator.language(s)"]
|
||||
LangJS --> Intl["Intl locale/timeZone"]
|
||||
Plat["platform"] --> Rend["渲染能力/WebGL"]
|
||||
Rend --> Win["窗口/屏幕参数"]
|
||||
UACH --> Plat
|
||||
```
|
||||
|
||||
图中每条边表示“两个表面必须相互一致”,否则会形成冲突分值。
|
||||
|
||||
#### 1.2.2 典型冲突类型
|
||||
|
||||
- UA 显示平台/版本与 UA-CH 不一致
|
||||
- `Accept-Language` 与 `navigator.languages` 不一致
|
||||
- `Intl` 时区与偏移/地区推断不一致
|
||||
- 设备声明与渲染能力组合异常
|
||||
|
||||
工程含义:
|
||||
|
||||
- 修一个点可能打破另一个点
|
||||
- 设计顺序应是“先定约束集合,再决定每个表面如何满足约束”
|
||||
|
||||
### 1.3 稀有性:组合风险而非单值风险
|
||||
|
||||
稀有性来自“低频组合”,其危险性来自共现而非单项。
|
||||
|
||||
可以将稀有性理解为“联合分布”偏离:
|
||||
|
||||
- 单项偏离:可被容忍
|
||||
- 多项共现偏离:风险迅速累积
|
||||
|
||||
工程含义:
|
||||
|
||||
- 目标是减少低频组合在同一会话内叠加
|
||||
- 目标不是拟合某个固定画像
|
||||
|
||||
### 1.4 时序性:统计特征而非行为语义
|
||||
|
||||
行为检测通常关注统计分布特征:
|
||||
|
||||
- 低方差:动作间隔过于稳定
|
||||
- 强周期:间隔呈固定节奏
|
||||
- 强同步:不同类型动作间隔一致
|
||||
|
||||
工程含义:
|
||||
|
||||
- 行为治理的目标是“分布塑形”(variance/jitter/backoff)
|
||||
- 行为治理不是“添加更多动作”
|
||||
|
||||
### 1.5 执行完整性:上游条件
|
||||
|
||||
执行完整性属于“系统是否能正确运行”的前置条件。
|
||||
|
||||
- challenge 脚本、跨域 iframe、跨域 worker 的语义被破坏时,失败率会显著上升
|
||||
- 此类失败可能与“是否被识别”为不同类别的问题
|
||||
|
||||
工程原则:
|
||||
|
||||
> 执行链路保护优先于信号修饰。
|
||||
|
||||
---
|
||||
|
||||
## 二、控制面(分层设计)
|
||||
|
||||
### 2.1 控制面总览
|
||||
|
||||
攻防方案可以拆为四层控制面:
|
||||
|
||||
1. **启动控制**:治理启动早期显式风险
|
||||
2. **协议控制**:治理协议层身份一致性
|
||||
3. **运行时控制**:治理页面脚本可观测表面
|
||||
4. **行为与会话控制**:治理时序分布与上下文漂移
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["启动控制"] --> B["协议控制"]
|
||||
B --> C["运行时控制"]
|
||||
C --> D["行为与会话控制"]
|
||||
D --> E["一致性与稳定性"]
|
||||
```
|
||||
|
||||
### 2.2 启动控制
|
||||
|
||||
目标:降低会话早期显式风险。
|
||||
|
||||
设计约束:
|
||||
|
||||
- 只处理高置信度自动化标识
|
||||
- 避免引入与协议层/运行时层不一致的改动
|
||||
|
||||
### 2.3 协议控制
|
||||
|
||||
目标:将身份约束集合落实到协议层输出。
|
||||
|
||||
设计要点:
|
||||
|
||||
- 将 UA 与 UA-CH 视为同一约束集合的不同投影
|
||||
- 覆盖范围需要与目标(页面/worker/子目标)一致
|
||||
|
||||
### 2.4 运行时控制
|
||||
|
||||
目标:覆盖高频探测面,同时保证不破坏执行语义。
|
||||
|
||||
设计要点:
|
||||
|
||||
- 优先治理高频、可解释的探测路径
|
||||
- 对跨域挑战链路对象设置严格注入边界
|
||||
|
||||
### 2.5 行为与会话控制
|
||||
|
||||
目标:塑形时间分布,减少上下文漂移。
|
||||
|
||||
设计要点:
|
||||
|
||||
- 行为治理以统计分布为目标(variance/jitter/backoff)
|
||||
- 会话治理以一致上下文为目标(避免身份漂移)
|
||||
|
||||
### 2.6 挑战场景控制面摘要(Turnstile)
|
||||
|
||||
Turnstile 场景下的关键控制面可抽象为:
|
||||
|
||||
1. 能力令牌语义:服务端验证、有限时效、单次消费
|
||||
2. 作用域收缩:`hostname/action/cdata` 收缩滥用空间
|
||||
3. 执行链路保护:跨域脚本/iframe/worker 语义保护
|
||||
4. 摩擦与安全分离:clearance 属于体验层,不替代安全决策层
|
||||
|
||||
该摘要用于将 Turnstile 纳入统一控制面框架;细节见专题文章。
|
||||
|
||||
---
|
||||
|
||||
## 三、方案设计优先级
|
||||
|
||||
控制面设计通常按以下优先级推进:
|
||||
|
||||
1. 执行完整性(保证链路可运行)
|
||||
2. 一致性约束集合(消除跨表面矛盾)
|
||||
3. 稀有性控制(避免低频组合叠加)
|
||||
4. 时序分布塑形(降低机械统计特征)
|
||||
5. 体验优化(降低重复挑战摩擦)
|
||||
|
||||
该顺序的含义是先保证“系统正确性”,再优化“稳定性与摩擦”。
|
||||
@@ -0,0 +1,250 @@
|
||||
# Cloudflare Turnstile 攻防方案设计:系统原理与控制面
|
||||
|
||||
本文聚焦 Turnstile 的攻防方案设计:
|
||||
|
||||
1. **系统原理**:token 的安全语义、挑战执行链路、风险评分的输入输出
|
||||
2. **控制面设计**:在不同攻击面下,哪些约束是必要的、哪些约束容易引入副作用
|
||||
|
||||
本文不包含命令行操作与工程实现步骤。
|
||||
|
||||
---
|
||||
|
||||
## 一、系统原理
|
||||
|
||||
### 1.1 Turnstile 是“能力令牌”系统
|
||||
|
||||
Turnstile 的本质是签发一个短生命周期、单次消费的能力令牌(capability token)。
|
||||
|
||||
- **签发端**:浏览器端完成挑战执行后获得 token
|
||||
- **消费端**:业务服务端通过 Siteverify 验证 token 并决定是否放行
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["浏览器端挑战执行"] --> B["token"]
|
||||
B --> C["业务服务端"]
|
||||
C --> D["Siteverify"]
|
||||
D --> E{"放行/拒绝"}
|
||||
```
|
||||
|
||||
关键含义:
|
||||
|
||||
- 前端任何“通过”状态都不是业务放行条件
|
||||
- 业务放行条件是“token 被正确消费”
|
||||
|
||||
### 1.2 Token 的三条安全语义
|
||||
|
||||
token 的安全语义可以抽象为三条约束:
|
||||
|
||||
1. **必须服务端验证**:不允许仅以前端回调作为依据
|
||||
2. **有限时效**:token 超过时效窗口即失效
|
||||
3. **单次消费**:同一 token 重复消费应失败
|
||||
|
||||
这三条语义分别封装了三个常见攻击目标:
|
||||
|
||||
- 伪通过:绕过服务端验证
|
||||
- 延迟提交:绕过时效窗口
|
||||
- 重放/并发:绕过单次消费
|
||||
|
||||
### 1.3 挑战执行链路是“跨域执行系统”
|
||||
|
||||
Turnstile 的 token 产生依赖多组件协作,且跨域链路占主导:
|
||||
|
||||
- `api.js` 脚本
|
||||
- challenge iframe
|
||||
- challenge worker
|
||||
- 跨域资源请求
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["加载 api.js"] --> B["创建 iframe"]
|
||||
B --> C["执行 worker"]
|
||||
C --> D["收集信号 + 风险评估"]
|
||||
D --> E["签发 token"]
|
||||
```
|
||||
|
||||
该链路的工程含义:
|
||||
|
||||
- 任何对跨域脚本/iframe/worker 的语义改写,都可能导致 token 生成失败或质量下降
|
||||
- token 失败不一定意味着“被识别”,也可能是“链路被破坏”
|
||||
|
||||
### 1.4 风险评分:输入不是“真假”,而是“自洽程度”
|
||||
|
||||
挑战执行阶段会收集环境与行为信号,形成风险评分。
|
||||
|
||||
- **信号输入**:环境一致性(UA/UA-CH、语言/时区、渲染能力、能力暴露)
|
||||
- **行为输入**:时序分布(方差、周期性、同步性)
|
||||
|
||||
风险评分的关键不是“拟合某种固定画像”,而是“同一身份在多表面是否自洽”。
|
||||
|
||||
### 1.5 作用域绑定:hostname / action / cdata
|
||||
|
||||
服务端校验时提供用于绑定业务语义的字段:
|
||||
|
||||
- `hostname`:token 允许的站点作用域
|
||||
- `action`:token 允许的动作作用域
|
||||
- `cdata`:token 允许的上下文作用域
|
||||
|
||||
这些字段的作用是“收缩 token 可被滥用的范围”,而不是“提高通过率”。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["token"] --> B["hostname 作用域"]
|
||||
A --> C["action 作用域"]
|
||||
A --> D["cdata 作用域"]
|
||||
B --> E["降低站外盗用收益"]
|
||||
C --> F["降低动作错配收益"]
|
||||
D --> G["降低跨流程重放收益"]
|
||||
```
|
||||
|
||||
### 1.6 Token 状态机(能力令牌视角)
|
||||
|
||||
从能力令牌视角,token 生命周期可抽象为:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Issued: challenge ok
|
||||
Issued --> Consumed: siteverify ok
|
||||
Issued --> Expired: time window
|
||||
Issued --> Rejected: binding mismatch
|
||||
Issued --> Replayed: reused
|
||||
Replayed --> Rejected
|
||||
Expired --> Rejected
|
||||
Consumed --> [*]
|
||||
```
|
||||
|
||||
设计目标是让“非法路径”快速失败,并且失败类型可被服务端语义区分。
|
||||
|
||||
### 1.7 攻击树(高层)
|
||||
|
||||
Turnstile 的主要攻击目标可以抽象为:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["绕过业务动作门禁"] --> B["伪造或跳过服务端验证"]
|
||||
A --> C["重放 token"]
|
||||
A --> D["扩大 token 作用域"]
|
||||
A --> E["破坏挑战执行以制造降级路径"]
|
||||
C --> C1["并发提交"]
|
||||
C --> C2["延迟提交"]
|
||||
D --> D1["Any Hostname"]
|
||||
D --> D2["action/cdata 缺失"]
|
||||
```
|
||||
|
||||
该攻击树强调设计重点:
|
||||
|
||||
- 安全决策必须在服务端闭环
|
||||
- token 必须被作用域收缩并按语义消费
|
||||
|
||||
---
|
||||
|
||||
## 二、控制面设计(攻防视角)
|
||||
|
||||
### 2.1 控制面分层
|
||||
|
||||
Turnstile 防线可以分为四层控制面:
|
||||
|
||||
1. **挑战执行控制**:保证脚本/iframe/worker 跨域链路完整
|
||||
2. **服务端消费控制**:保证 token 的语义被正确消费
|
||||
3. **作用域控制**:收缩 `hostname/action/cdata` 的可用范围
|
||||
4. **摩擦控制**:clearance 用于降低挑战摩擦(不作为安全决策依据)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["挑战执行控制"] --> E["token 可生成"]
|
||||
A --> F["token 质量"]
|
||||
B["服务端消费控制"] --> G["安全决策闭环"]
|
||||
C["作用域控制"] --> H["滥用收益收缩"]
|
||||
D["摩擦控制"] --> I["挑战频率下降"]
|
||||
```
|
||||
|
||||
### 2.2 挑战执行控制:跨域语义保护优先
|
||||
|
||||
挑战执行链路对跨域执行语义高度敏感。
|
||||
|
||||
原则:
|
||||
|
||||
- 跨域脚本/iframe/worker 避免语义改写
|
||||
- 所有指纹修饰必须先满足“不破坏挑战执行”这一硬约束
|
||||
|
||||
该原则的工程含义:
|
||||
|
||||
- “执行完整性”是上游条件
|
||||
- “信号修饰”是下游优化
|
||||
|
||||
### 2.3 服务端消费控制:把 token 当作能力消费
|
||||
|
||||
服务端消费控制的设计关键在于“放行条件定义”,而不是“接口调用细节”。
|
||||
|
||||
放行条件应体现三类约束:
|
||||
|
||||
- 真实性:校验 `success`
|
||||
- 作用域:校验 `hostname`
|
||||
- 语义绑定:校验 `action/cdata`
|
||||
|
||||
并且必须贯彻 token 的两个安全语义:
|
||||
|
||||
- 时效性:过期拒绝
|
||||
- 单次性:重放拒绝
|
||||
|
||||
从攻防角度,该层解决的是“绕过与重放”。
|
||||
|
||||
### 2.4 作用域控制:Hostname Management 与 Any Hostname
|
||||
|
||||
Hostname 管理解决“站外盗用”的攻击面。
|
||||
|
||||
- 启用 Hostname Management:收缩 token 可用站点范围
|
||||
- 启用 Any Hostname:扩大 token 可用站点范围
|
||||
|
||||
设计结论:
|
||||
|
||||
- Any Hostname 不是“更灵活”,而是“扩大攻击面”,必须用更强的服务端约束做补偿控制(来源域白名单 + 业务绑定)。
|
||||
|
||||
### 2.5 摩擦控制:Pre-clearance 与 cf_clearance 的边界
|
||||
|
||||
Pre-clearance 通过后可产生 clearance,用于后续 WAF 挑战联动。
|
||||
|
||||
边界定义:
|
||||
|
||||
- clearance 用于体验层(降低重复挑战摩擦)
|
||||
- Siteverify 用于安全决策层(业务放行依据)
|
||||
|
||||
将两者混用会引入“体验信号替代安全信号”的设计缺陷。
|
||||
|
||||
### 2.6 高对抗场景:代理池与设备关联
|
||||
|
||||
在代理池与分布式滥用场景中,单一 IP 维度约束容易失效。
|
||||
|
||||
设计方向是引入更稳定的关联维度(例如设备级 ephemeral id),用于聚类与阈值策略。
|
||||
|
||||
该层属于平台能力与业务风控的交界:
|
||||
|
||||
- 平台提供关联信号
|
||||
- 业务定义动作分层、阈值与处置策略
|
||||
|
||||
---
|
||||
|
||||
## 三、方案设计优先级
|
||||
|
||||
Turnstile 攻防设计通常按以下优先级推进:
|
||||
|
||||
1. 服务端消费语义闭环(真实性 + 作用域 + 绑定 + 单次性 + 时效性)
|
||||
2. 挑战执行链路完整性(跨域语义保护)
|
||||
3. 信号一致性(减少跨字段矛盾)
|
||||
4. 行为时序(降低机械分布)
|
||||
5. 体验优化(clearance 等摩擦控制)
|
||||
|
||||
该顺序的含义是先定义“正确的安全决策”,再优化“挑战摩擦与通过率波动”。
|
||||
|
||||
---
|
||||
|
||||
## 官方参考(概念与配置)
|
||||
|
||||
- Widgets: <https://developers.cloudflare.com/turnstile/concepts/widget/>
|
||||
- Widget configurations: <https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/>
|
||||
- Server-side validation: <https://developers.cloudflare.com/turnstile/get-started/server-side-validation/>
|
||||
- CSP: <https://developers.cloudflare.com/turnstile/reference/content-security-policy/>
|
||||
- Hostname management: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/>
|
||||
- Any Hostname: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/any-hostname/>
|
||||
- Pre-clearance: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/pre-clearance/>
|
||||
- Cloudflare clearance: <https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/>
|
||||
- Ephemeral IDs: <https://developers.cloudflare.com/turnstile/additional-configuration/ephemeral-id/>
|
||||
@@ -0,0 +1,97 @@
|
||||
# agent-browser 与 agent-browser-stealth:能力差异与选型
|
||||
|
||||
本文给出 `agent-browser` 与 `agent-browser-stealth` 的技术差异、适用场景和升级验证步骤。
|
||||
|
||||
项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser)
|
||||
|
||||
---
|
||||
|
||||
## 1. 定位差异
|
||||
|
||||
- `agent-browser`:标准浏览器自动化能力
|
||||
- `agent-browser-stealth`:在标准自动化能力基础上,增加反检测与高风控场景稳定性能力
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心能力对比
|
||||
|
||||
| 维度 | agent-browser | agent-browser-stealth |
|
||||
| --- | --- | --- |
|
||||
| 自动化基础能力 | 支持 | 支持 |
|
||||
| 指纹一致性治理 | 基础 | 多层(launch/CDP/init-script) |
|
||||
| 高风控站点稳定性 | 一般 | 更高 |
|
||||
| 会话连续性(附着现有浏览器) | 支持 | 支持,默认附着策略更明确 |
|
||||
| Cloudflare/Turnstile 回归工具 | 无专用脚本 | `check:turnstile-testkey` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cloudflare/Turnstile 相关能力(v0.15.2-fork.2+)
|
||||
|
||||
### 3.1 挑战链路保护
|
||||
|
||||
- 同源 worker 注入保留
|
||||
- 跨域 challenge worker 不做注入改写
|
||||
- 降低 challenge worker 执行异常概率
|
||||
|
||||
### 3.2 导航等待策略
|
||||
|
||||
`open/navigate` 支持:
|
||||
|
||||
- `--wait-until load`
|
||||
- `--wait-until domcontentloaded`
|
||||
- `--wait-until networkidle`
|
||||
|
||||
挑战页建议优先 `domcontentloaded`,减少 `load` 阶段超时误判。
|
||||
|
||||
### 3.3 确定性回归
|
||||
|
||||
提供官方 test key 回归脚本:
|
||||
|
||||
```bash
|
||||
pnpm run check:turnstile-testkey
|
||||
```
|
||||
|
||||
通过特征:输出 `XXXX.DUMMY.TOKEN.XXXX`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 适用场景
|
||||
|
||||
优先使用 `agent-browser-stealth` 的场景:
|
||||
|
||||
1. 目标站点存在挑战页/验证码/限流
|
||||
2. 自动化链路对稳定性要求高
|
||||
3. 需要长期回归验证与版本门禁
|
||||
|
||||
使用 `agent-browser` 的场景:
|
||||
|
||||
1. 低风控站点
|
||||
2. 以基础自动化能力验证为主
|
||||
|
||||
---
|
||||
|
||||
## 5. 升级验证步骤
|
||||
|
||||
```bash
|
||||
# 1) 检查版本
|
||||
agent-browser -V
|
||||
|
||||
# 2) 关闭旧 daemon,避免版本漂移
|
||||
agent-browser --session default close
|
||||
|
||||
# 3) 运行确定性回归
|
||||
pnpm run check:turnstile-testkey
|
||||
|
||||
# 4) 可选:真实站点回归
|
||||
agent-browser --wait-until domcontentloaded open https://www.anyviewer.com/cloudflare.html
|
||||
```
|
||||
|
||||
如果启用域名白名单(`AGENT_BROWSER_ALLOWED_DOMAINS`),需包含 `challenges.cloudflare.com`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 结论
|
||||
|
||||
`agent-browser-stealth` 适用于高风控与稳定性敏感场景;`agent-browser` 适用于标准自动化场景。
|
||||
选型建议按目标站点风控强度与回归要求决定。
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+12
-8
@@ -102,9 +102,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.1.18
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@4.1.18)
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
@@ -3467,6 +3467,11 @@ packages:
|
||||
tailwind-merge@3.4.0:
|
||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||
|
||||
tailwindcss-animate@1.0.7:
|
||||
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders'
|
||||
|
||||
tailwindcss@4.1.18:
|
||||
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
||||
|
||||
@@ -3521,9 +3526,6 @@ packages:
|
||||
turndown@7.2.2:
|
||||
resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==}
|
||||
|
||||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -7827,6 +7829,10 @@ snapshots:
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.18):
|
||||
dependencies:
|
||||
tailwindcss: 4.1.18
|
||||
|
||||
tailwindcss@4.1.18: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
@@ -7891,8 +7897,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@mixmark-io/domino': 2.2.0
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const docPath = searchParams.get("path");
|
||||
|
||||
if (!docPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing ?path= parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = docPath
|
||||
.replace(/^\//, "")
|
||||
.replace(/\.\./g, "")
|
||||
.replace(/[^a-zA-Z0-9/_-]/g, "");
|
||||
|
||||
const slug = normalized;
|
||||
const filePath = slug
|
||||
? join(process.cwd(), "src", "app", ...slug.split("/"), "page.mdx")
|
||||
: join(process.cwd(), "src", "app", "page.mdx");
|
||||
|
||||
try {
|
||||
const raw = await readFile(filePath, "utf-8");
|
||||
const markdown = mdxToCleanMarkdown(raw);
|
||||
|
||||
return new NextResponse(markdown, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
+130
-25
@@ -1,12 +1,12 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
|
||||
export const metadata = pageMetadata("cdp-mode")
|
||||
export const metadata = pageMetadata('cdp-mode');
|
||||
|
||||
# CDP Mode
|
||||
|
||||
Connect to an existing browser via Chrome DevTools Protocol:
|
||||
|
||||
Default behavior in this fork: when `--cdp` is omitted, agent-browser requires an existing browser at `localhost:9333`. If CDP is unavailable, the command fails fast (no local-launch fallback).
|
||||
Default behavior in this fork: when `--cdp` is omitted, agent-browser auto-attaches to an existing browser by trying `localhost:9333` first, then auto-discovery. If both fail, the command exits (no managed local-launch fallback).
|
||||
|
||||
Project policy:
|
||||
|
||||
@@ -88,12 +88,24 @@ AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.co
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Connection type</th><th>Stealth capabilities</th></tr>
|
||||
<tr>
|
||||
<th>Connection type</th>
|
||||
<th>Stealth capabilities</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Local launch</td><td>Chromium launch args + context init scripts</td></tr>
|
||||
<tr><td>CDP / auto-connect</td><td>Context init scripts</td></tr>
|
||||
<tr><td>Cloud providers</td><td>Context init scripts (Kernel may also apply provider-managed stealth)</td></tr>
|
||||
<tr>
|
||||
<td>Local launch</td>
|
||||
<td>Chromium launch args + context init scripts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CDP / auto-connect</td>
|
||||
<td>Context init scripts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cloud providers</td>
|
||||
<td>Context init scripts (Kernel may also apply provider-managed stealth)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -113,26 +125,119 @@ This enables control of:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Option</th><th>Description</th></tr>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>--session <name></code></td><td>Use isolated session</td></tr>
|
||||
<tr><td><code>-p <provider></code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
|
||||
<tr><td><code>--headers <json></code></td><td>HTTP headers scoped to origin</td></tr>
|
||||
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
|
||||
<tr><td><code>--args <args></code></td><td>Browser launch args (comma-separated)</td></tr>
|
||||
<tr><td><code>--user-agent <ua></code></td><td>Custom User-Agent string</td></tr>
|
||||
<tr><td><code>--proxy <url></code></td><td>Proxy server URL</td></tr>
|
||||
<tr><td><code>--proxy-bypass <hosts></code></td><td>Hosts to bypass proxy</td></tr>
|
||||
<tr><td><code>--json</code></td><td>JSON output for scripts</td></tr>
|
||||
<tr><td><code>--full, -f</code></td><td>Full page screenshot</td></tr>
|
||||
<tr><td><code>--name, -n</code></td><td>Locator name filter</td></tr>
|
||||
<tr><td><code>--exact</code></td><td>Exact text match</td></tr>
|
||||
<tr><td><code>--headed</code></td><td>Show browser window</td></tr>
|
||||
<tr><td><code>{"--cdp <port|url>"}</code></td><td>CDP connection (port or WebSocket URL)</td></tr>
|
||||
<tr><td><code>--auto-connect</code></td><td>Auto-discover and connect to running Chrome</td></tr>
|
||||
<tr><td><code>--color-scheme <scheme></code></td><td>Persistent color scheme (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
|
||||
<tr><td><code>--debug</code></td><td>Debug output</td></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--session <name></code>
|
||||
</td>
|
||||
<td>Use isolated session</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>-p <provider></code>
|
||||
</td>
|
||||
<td>
|
||||
Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>,{' '}
|
||||
<code>kernel</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--headers <json></code>
|
||||
</td>
|
||||
<td>HTTP headers scoped to origin</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--executable-path</code>
|
||||
</td>
|
||||
<td>Custom browser executable</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--args <args></code>
|
||||
</td>
|
||||
<td>Browser launch args (comma-separated)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--user-agent <ua></code>
|
||||
</td>
|
||||
<td>Custom User-Agent string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--proxy <url></code>
|
||||
</td>
|
||||
<td>Proxy server URL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--proxy-bypass <hosts></code>
|
||||
</td>
|
||||
<td>Hosts to bypass proxy</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--json</code>
|
||||
</td>
|
||||
<td>JSON output for scripts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--full, -f</code>
|
||||
</td>
|
||||
<td>Full page screenshot</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--name, -n</code>
|
||||
</td>
|
||||
<td>Locator name filter</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--exact</code>
|
||||
</td>
|
||||
<td>Exact text match</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--headed</code>
|
||||
</td>
|
||||
<td>Show browser window</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>{'--cdp <port|url>'}</code>
|
||||
</td>
|
||||
<td>CDP connection (port or WebSocket URL)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--auto-connect</code>
|
||||
</td>
|
||||
<td>Auto-discover and connect to running Chrome</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--color-scheme <scheme></code>
|
||||
</td>
|
||||
<td>
|
||||
Persistent color scheme (<code>dark</code>, <code>light</code>, <code>no-preference</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--debug</code>
|
||||
</td>
|
||||
<td>Debug output</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -4,6 +4,179 @@ export const metadata = pageMetadata("changelog")
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.16.0
|
||||
|
||||
<p className="text-[#888] text-sm">March 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Native Rust daemon (experimental).** A pure Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating Node.js and Playwright dependencies entirely. Enable with `--native`, `AGENT_BROWSER_NATIVE=1`, or `"native": true` in your config file. Supports 150+ commands with full parity to the default Node.js daemon.
|
||||
|
||||
```bash
|
||||
# Via flag
|
||||
agent-browser --native open example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
Or add to `agent-browser.json`:
|
||||
|
||||
```json
|
||||
{"native": true}
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th></th><th>Default (Node.js)</th><th>Native (<code>--native</code>)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Runtime</strong></td><td>Node.js + Playwright</td><td>Pure Rust binary</td></tr>
|
||||
<tr><td><strong>Protocol</strong></td><td>Playwright protocol</td><td>Direct CDP / WebDriver</td></tr>
|
||||
<tr><td><strong>Install size</strong></td><td>Larger (Node.js + npm deps)</td><td>Smaller (single binary)</td></tr>
|
||||
<tr><td><strong>Browser support</strong></td><td>Chromium, Firefox, WebKit</td><td>Chromium, Safari (via WebDriver)</td></tr>
|
||||
<tr><td><strong>Stability</strong></td><td>Stable</td><td>Experimental</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### What's Supported
|
||||
|
||||
All core commands work in native mode: navigation, interaction (click, fill, type, press, hover, scroll, drag), observation (snapshot, screenshot, eval), state management (cookies, storage, state save/load), tabs, emulation (viewport, device, timezone, locale, geolocation), streaming, diffing, recording, and profiling.
|
||||
|
||||
The native daemon also includes a WebDriver backend for Safari and iOS support.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Firefox and WebKit are not yet supported (Chromium and Safari only)
|
||||
- Playwright trace format is not available (uses Chrome's built-in tracing)
|
||||
- HAR export is not available
|
||||
- Network route interception uses CDP Fetch domain instead of Playwright's route API
|
||||
- The native and Node.js daemons share the same session socket. Use `agent-browser close` before switching between modes.
|
||||
|
||||
See the [Native Mode](/native-mode) page for full details.
|
||||
|
||||
---
|
||||
|
||||
## v0.15.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Authentication vault** -- Store credentials locally (always AES-256-GCM encrypted) and reference them by name. The LLM never sees passwords. Commands: `auth save`, `auth login`, `auth list`, `auth show`, `auth delete`. Passwords can be piped via stdin (`--password-stdin`) to avoid shell history exposure.
|
||||
- **Content boundary markers** -- `--content-boundaries` wraps page-sourced output in structural delimiters with a per-process CSPRNG nonce, so LLMs can distinguish trusted tool output from untrusted page content. In `--json` mode, a `_boundary` object is injected with `nonce` and `origin` fields.
|
||||
- **Domain allowlist** -- `--allowed-domains` restricts navigation, sub-resource requests, WebSocket connections, and EventSource streams to trusted domains. Supports exact match and wildcard prefix patterns (e.g., `*.example.com`).
|
||||
- **Action policy** -- `--action-policy` gates actions using a static JSON policy file with `allow`/`deny` lists across 13 action categories. Auth vault operations bypass policy enforcement.
|
||||
- **Action confirmation** -- `--confirm-actions` requires explicit approval for sensitive action categories. New `confirm` and `deny` commands for orchestrator use. `--confirm-interactive` enables human-in-the-loop terminal prompts (auto-denies if stdin is not a TTY). Pending confirmations auto-deny after 60 seconds.
|
||||
- **Output length limits** -- `--max-output` truncates large page outputs to prevent LLM context flooding.
|
||||
- **`--download-path` option** -- Set a default download directory via flag, `AGENT_BROWSER_DOWNLOAD_PATH` env var, or `downloadPath` config key. Without it, downloads go to a temporary directory deleted when the browser closes.
|
||||
- **`--selector` flag for scroll** -- Scroll within a specific container element instead of the page: `agent-browser scroll down 500 --selector "div.scroll-container"`
|
||||
|
||||
```bash
|
||||
# Auth vault
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
agent-browser auth login github
|
||||
|
||||
# Security flags
|
||||
agent-browser --content-boundaries --allowed-domains "example.com,*.example.com" --max-output 50000 open https://example.com
|
||||
|
||||
# Download path
|
||||
agent-browser --download-path ./downloads open https://example.com
|
||||
|
||||
# Scroll within container
|
||||
agent-browser scroll down 500 --selector "div.content"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Six new environment variables for security configuration: `AGENT_BROWSER_CONTENT_BOUNDARIES`, `AGENT_BROWSER_MAX_OUTPUT`, `AGENT_BROWSER_ALLOWED_DOMAINS`, `AGENT_BROWSER_ACTION_POLICY`, `AGENT_BROWSER_CONFIRM_ACTIONS`, `AGENT_BROWSER_CONFIRM_INTERACTIVE`.
|
||||
|
||||
---
|
||||
|
||||
## v0.14.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **`keyboard` command** -- Type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector (`keyboard type`, `keyboard inserttext`).
|
||||
- **`--color-scheme` flag** -- Persistent dark/light mode preference across browser sessions via flag or `AGENT_BROWSER_COLOR_SCHEME` env var.
|
||||
|
||||
```bash
|
||||
agent-browser keyboard type "Hello world"
|
||||
agent-browser keyboard inserttext "pasted text"
|
||||
agent-browser --color-scheme dark open https://example.com
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed IPC EAGAIN errors (os error 35/11) with backpressure-aware socket writes, command serialization, and lowered default Playwright timeout to 25s (configurable via `AGENT_BROWSER_DEFAULT_TIMEOUT`).
|
||||
- Fixed remote debugging (CDP) reconnection.
|
||||
- Fixed state load failing when no browser is running.
|
||||
- Fixed `--annotate` flag warning appearing when not explicitly passed via CLI.
|
||||
|
||||
---
|
||||
|
||||
## v0.13.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Diff commands** -- Compare snapshots, screenshots, and URLs between page states. Run visual pixel diffs against baseline images, compare accessibility tree snapshots with customizable depth and selectors, and diff two URLs side-by-side with optional screenshot comparison.
|
||||
|
||||
```bash
|
||||
agent-browser diff snapshot
|
||||
agent-browser diff screenshot --baseline before.png
|
||||
agent-browser diff url https://staging.example.com https://prod.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.12.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Annotated screenshots** -- `--annotate` flag overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. Enables multimodal AI models to reason about visual layout while using the same `@eN` refs for subsequent interactions. Also settable via `AGENT_BROWSER_ANNOTATE` env var.
|
||||
|
||||
```bash
|
||||
agent-browser screenshot --annotate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.11.1
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added documentation for command chaining with `&&` across README, CLI help output, docs, and skill files.
|
||||
|
||||
---
|
||||
|
||||
## v0.11.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Configuration file support** -- Automatic loading from user (`~/.agent-browser/config.json`) and project (`./agent-browser.json`) directories with priority-based merging.
|
||||
- **Profiler commands** -- Chrome DevTools profiling with `profiler start` and `profiler stop`.
|
||||
- **Browser extension loading** -- `--extension` flag to load browser extensions.
|
||||
- **Storage state management** -- `state save` and `state load` commands for auth state persistence.
|
||||
- **iOS device emulation** -- `--device` flag for device emulation.
|
||||
- **Enhanced click** -- `--new-tab` option for click commands.
|
||||
- **Enhanced find** -- Additional actions and filtering options.
|
||||
- **CDP WebSocket URLs** -- `--cdp` now accepts WebSocket URLs in addition to ports.
|
||||
|
||||
---
|
||||
|
||||
## v0.10.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
|
||||
export const metadata = pageMetadata("commands")
|
||||
export const metadata = pageMetadata('commands');
|
||||
|
||||
# Commands
|
||||
|
||||
Executable aliases: `agent-browser`, `agent-browser-stealth`, `abs`.
|
||||
|
||||
## Core
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser --risk-mode block open <url> # Block when verification/captcha interstitial is detected
|
||||
agent-browser click <sel> # Click element (--new-tab to open in new tab)
|
||||
agent-browser dblclick <sel> # Double-click
|
||||
agent-browser fill <sel> <text> # Clear and fill
|
||||
@@ -32,6 +35,7 @@ agent-browser pdf <path> # Save page as PDF
|
||||
agent-browser snapshot # Accessibility tree with refs
|
||||
agent-browser eval <js> # Run JavaScript
|
||||
agent-browser connect <port|url> # Connect to browser via CDP
|
||||
agent-browser doctor # Diagnose CDP + sourceURL + tab-group plugin health
|
||||
agent-browser --version # Show CLI version
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
```
|
||||
@@ -110,6 +114,16 @@ agent-browser wait --fn "condition" # Wait for JS condition
|
||||
agent-browser wait --download [path] # Wait for download
|
||||
```
|
||||
|
||||
## Risk Mode
|
||||
|
||||
Control how `open`/`navigate` handles verification or captcha interstitials:
|
||||
|
||||
```bash
|
||||
agent-browser --risk-mode warn open https://example.com # default: wait for auto-clear, then retry/warn with riskSignals
|
||||
agent-browser --risk-mode block open https://example.com # fail fast on detection
|
||||
agent-browser --risk-mode off open https://example.com # disable detection/retry
|
||||
```
|
||||
|
||||
## Downloads
|
||||
|
||||
```bash
|
||||
@@ -119,6 +133,32 @@ agent-browser wait --download [path] # Wait for any download to complete
|
||||
|
||||
Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes.
|
||||
|
||||
## Tab grouping
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com
|
||||
# CDP mode groups tabs when tab-group plugin is installed
|
||||
|
||||
# Override the default group title
|
||||
agent-browser --tab-group "My Agent Group" open https://example.com
|
||||
```
|
||||
|
||||
CDP mode uses a browser extension handshake to group tabs.
|
||||
|
||||
- Extension available: tabs are grouped by `session`.
|
||||
- Extension missing/unavailable: silent no-op (commands still succeed).
|
||||
- Default titles:
|
||||
- `default` session: `Agent Browser Stealth`
|
||||
- non-default: `Agent Browser Stealth • <session>`
|
||||
- Extension side panel (`agent-browser-stealth`) also provides:
|
||||
- Session window isolation and deterministic group colors.
|
||||
- `Keep Only This`, `Focus`, `Clean Empty Groups` quick actions.
|
||||
- Toggle switches for strict isolation / activation guard / auto-clean.
|
||||
- Session allowlist editing (domain fallback to `about:blank` when violated).
|
||||
- Download routing to `agent-browser-stealth/<session>/...`.
|
||||
- Use `--tab-group` / `AGENT_BROWSER_TAB_GROUP` for base title.
|
||||
- Use `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` (or `--tab-group-plugin-id`) to override expected extension ID.
|
||||
|
||||
## Mouse
|
||||
|
||||
```bash
|
||||
@@ -161,6 +201,14 @@ agent-browser storage local clear # Clear all
|
||||
agent-browser storage session # Same for sessionStorage
|
||||
```
|
||||
|
||||
For `cookies set`, use one of these patterns:
|
||||
|
||||
- `--url <url>`
|
||||
- `--domain <domain> --path <path>`
|
||||
- omit all three to scope from the current page URL
|
||||
|
||||
When `--url` is omitted, `--domain` and `--path` must be provided together.
|
||||
|
||||
## Network
|
||||
|
||||
```bash
|
||||
@@ -207,6 +255,8 @@ agent-browser console --clear # Clear console log
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors --clear # Clear error log
|
||||
agent-browser highlight <sel> # Highlight element
|
||||
agent-browser doctor # Diagnose CDP + sourceURL + plugin handshake status
|
||||
pnpm run check:turnstile-testkey # Deterministic Turnstile smoke check (official test key)
|
||||
```
|
||||
|
||||
## State management
|
||||
@@ -241,7 +291,7 @@ agent-browser reload # Reload page
|
||||
|
||||
```bash
|
||||
--session <name> # Isolated browser session
|
||||
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
|
||||
--session-name <name> # Auto-save/restore session state (defaults to --session when omitted)
|
||||
--state <path> # Load storage state from JSON file
|
||||
--headers <json> # HTTP headers scoped to URL's origin
|
||||
--executable-path <path> # Custom browser executable
|
||||
@@ -261,6 +311,9 @@ agent-browser reload # Reload page
|
||||
--headed # Show browser window (not headless)
|
||||
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
|
||||
--auto-connect # Auto-discover and connect to running Chrome
|
||||
--tab-group <name> # Base title for agent tab groups (CDP plugin mode)
|
||||
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
|
||||
--wait-until <mode> # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle)
|
||||
--debug # Debug output (includes stealth connection type + capabilities)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
|
||||
export const metadata = pageMetadata("configuration")
|
||||
export const metadata = pageMetadata('configuration');
|
||||
|
||||
# Configuration
|
||||
|
||||
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
||||
|
||||
In this fork, default launch behavior requires a resident browser at `localhost:9333` (CDP). If unavailable, commands fail fast instead of launching a managed browser.
|
||||
In this fork, default launch behavior auto-attaches to an existing browser by trying `localhost:9333` (CDP) first, then auto-discovery. If both fail, commands exit instead of launching a managed browser.
|
||||
|
||||
## Config File Locations
|
||||
|
||||
@@ -14,13 +14,39 @@ agent-browser checks two locations, merged in priority order:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Priority</th><th>Location</th><th>Scope</th></tr>
|
||||
<tr>
|
||||
<th>Priority</th>
|
||||
<th>Location</th>
|
||||
<th>Scope</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>1 (lowest)</td><td><code>~/.agent-browser/config.json</code></td><td>User-level defaults</td></tr>
|
||||
<tr><td>2</td><td><code>./agent-browser.json</code></td><td>Project-level overrides</td></tr>
|
||||
<tr><td>3</td><td><code>AGENT_BROWSER_*</code> env vars</td><td>Override config values</td></tr>
|
||||
<tr><td>4 (highest)</td><td>CLI flags</td><td>Override everything</td></tr>
|
||||
<tr>
|
||||
<td>1 (lowest)</td>
|
||||
<td>
|
||||
<code>~/.agent-browser/config.json</code>
|
||||
</td>
|
||||
<td>User-level defaults</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>
|
||||
<code>./agent-browser.json</code>
|
||||
</td>
|
||||
<td>Project-level overrides</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_*</code> env vars
|
||||
</td>
|
||||
<td>Override config values</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>4 (highest)</td>
|
||||
<td>CLI flags</td>
|
||||
<td>Override everything</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -50,34 +76,253 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Config Key</th><th>CLI Flag</th><th>Type</th></tr>
|
||||
<tr>
|
||||
<th>Config Key</th>
|
||||
<th>CLI Flag</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>headed</code></td><td><code>--headed</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>json</code></td><td><code>--json</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>full</code></td><td><code>--full, -f</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
|
||||
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
|
||||
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
|
||||
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
|
||||
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
|
||||
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
|
||||
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
|
||||
<tr><td><code>args</code></td><td><code>--args</code></td><td>string</td></tr>
|
||||
<tr><td><code>userAgent</code></td><td><code>--user-agent</code></td><td>string</td></tr>
|
||||
<tr><td><code>provider</code></td><td><code>-p, --provider</code></td><td>string</td></tr>
|
||||
<tr><td><code>device</code></td><td><code>--device</code></td><td>string</td></tr>
|
||||
<tr><td><code>ignoreHttpsErrors</code></td><td><code>--ignore-https-errors</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>allowFileAccess</code></td><td><code>--allow-file-access</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>cdp</code></td><td><code>--cdp</code></td><td>string</td></tr>
|
||||
<tr><td><code>autoConnect</code></td><td><code>--auto-connect</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>colorScheme</code></td><td><code>--color-scheme</code></td><td>string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
|
||||
<tr><td><code>downloadPath</code></td><td><code>--download-path</code></td><td>string</td></tr>
|
||||
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>headed</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--headed</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>json</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--json</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>full</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--full, -f</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>debug</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--debug</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>session</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--session</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>sessionName</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--session-name</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>executablePath</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--executable-path</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>extensions</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--extension</code>
|
||||
</td>
|
||||
<td>string[]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>state</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--state</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>proxy</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--proxy</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>proxyBypass</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--proxy-bypass</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>args</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--args</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>userAgent</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--user-agent</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>provider</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>-p, --provider</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>device</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--device</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>ignoreHttpsErrors</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--ignore-https-errors</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>allowFileAccess</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--allow-file-access</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>cdp</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--cdp</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>autoConnect</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--auto-connect</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>colorScheme</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--color-scheme</code>
|
||||
</td>
|
||||
<td>
|
||||
string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>downloadPath</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--download-path</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>tabGroup</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--tab-group</code>
|
||||
</td>
|
||||
<td>string (base title for session tab grouping via CDP plugin handshake)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>tabGroupPluginId</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--tab-group-plugin-id</code>
|
||||
</td>
|
||||
<td>string (expected extension ID for tab-group plugin handshake)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>riskMode</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--risk-mode</code>
|
||||
</td>
|
||||
<td>
|
||||
string (<code>off</code>, <code>warn</code>, <code>block</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>headers</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--headers</code>
|
||||
</td>
|
||||
<td>string (JSON)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
`riskMode` defaults to `warn` when unset.
|
||||
|
||||
For tab grouping in CDP mode, grouping is best-effort through the extension handshake:
|
||||
extension available => grouped by session; extension missing/unavailable => silent no-op.
|
||||
|
||||
With the `agent-browser-stealth` extension installed, the side panel also exposes
|
||||
session window isolation controls, activation guard toggles, empty-group cleanup, and per-session allowlist policy editing.
|
||||
|
||||
## Common Configurations
|
||||
|
||||
### Local Development
|
||||
@@ -146,21 +391,148 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Description</th><th>Default</th></tr>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Description</th>
|
||||
<th>Default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>AGENT_BROWSER_AUTO_CONNECT</code></td><td>Auto-discover and connect to a running Chrome instance.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code></td><td>Allow <code>file://</code> URLs to access local files.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_COLOR_SCHEME</code></td><td>Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DOWNLOAD_PATH</code></td><td>Default directory for browser downloads.</td><td>(temp directory)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete saved session states older than N days.</td><td><code>30</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DEBUG</code></td><td>Enable debug output (<code>1</code> to enable).</td><td>(disabled)</td></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_AUTO_CONNECT</code>
|
||||
</td>
|
||||
<td>Auto-discover and connect to a running Chrome instance.</td>
|
||||
<td>(disabled)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code>
|
||||
</td>
|
||||
<td>
|
||||
Allow <code>file://</code> URLs to access local files.
|
||||
</td>
|
||||
<td>(disabled)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_COLOR_SCHEME</code>
|
||||
</td>
|
||||
<td>
|
||||
Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).
|
||||
</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_DOWNLOAD_PATH</code>
|
||||
</td>
|
||||
<td>Default directory for browser downloads.</td>
|
||||
<td>(temp directory)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_TAB_GROUP</code>
|
||||
</td>
|
||||
<td>Base title for tab grouping. Session suffix is appended automatically in CDP mode.</td>
|
||||
<td>
|
||||
<code>Agent Browser Stealth</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_TAB_GROUP_PLUGIN_ID</code>
|
||||
</td>
|
||||
<td>Expected extension ID for CDP tab-group plugin handshake.</td>
|
||||
<td>
|
||||
<code>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_RISK_MODE</code>
|
||||
</td>
|
||||
<td>
|
||||
Verification/captcha handling mode (<code>off</code>, <code>warn</code>, <code>block</code>
|
||||
).
|
||||
</td>
|
||||
<td>
|
||||
<code>warn</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_DEFAULT_TIMEOUT</code>
|
||||
</td>
|
||||
<td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td>
|
||||
<td>
|
||||
<code>25000</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_SESSION_NAME</code>
|
||||
</td>
|
||||
<td>
|
||||
Auto-save/load state persistence name (defaults to <code>AGENT_BROWSER_SESSION</code> when
|
||||
unset).
|
||||
</td>
|
||||
<td>
|
||||
(same as <code>AGENT_BROWSER_SESSION</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code>
|
||||
</td>
|
||||
<td>Auto-delete saved session states older than N days.</td>
|
||||
<td>
|
||||
<code>30</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_ENCRYPTION_KEY</code>
|
||||
</td>
|
||||
<td>64-char hex key for AES-256-GCM session encryption.</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_STREAM_PORT</code>
|
||||
</td>
|
||||
<td>
|
||||
Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).
|
||||
</td>
|
||||
<td>(disabled)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_IOS_DEVICE</code>
|
||||
</td>
|
||||
<td>
|
||||
Default iOS device name for the <code>ios</code> provider.
|
||||
</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_IOS_UDID</code>
|
||||
</td>
|
||||
<td>
|
||||
Default iOS device UDID for the <code>ios</code> provider.
|
||||
</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_DEBUG</code>
|
||||
</td>
|
||||
<td>
|
||||
Enable debug output (<code>1</code> to enable).
|
||||
</td>
|
||||
<td>(disabled)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
+158
-115
@@ -1,117 +1,112 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@source "../../node_modules/streamdown/dist/index.js";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", "Cascadia Mono", "Segoe UI Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-border: var(--border);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(1.0 0 0);
|
||||
--foreground: oklch(0.1 0 0);
|
||||
--card: oklch(0.98 0 0);
|
||||
--card-foreground: oklch(0.1 0 0);
|
||||
--popover: oklch(0.98 0 0);
|
||||
--popover-foreground: oklch(0.1 0 0);
|
||||
--primary: oklch(0.1 0 0);
|
||||
--primary-foreground: oklch(1.0 0 0);
|
||||
--secondary: oklch(0.92 0 0);
|
||||
--secondary-foreground: oklch(0.1 0 0);
|
||||
--muted: oklch(0.92 0 0);
|
||||
--muted-foreground: oklch(0.45 0 0);
|
||||
--accent: oklch(0.92 0 0);
|
||||
--accent-foreground: oklch(0.1 0 0);
|
||||
--destructive: oklch(0.55 0.2 25);
|
||||
--destructive-foreground: oklch(1.0 0 0);
|
||||
--border: oklch(0.85 0 0);
|
||||
--input: oklch(0.85 0 0);
|
||||
--ring: oklch(0.6 0 0);
|
||||
--chat-bg: oklch(0.95 0 0);
|
||||
--background: #fff;
|
||||
--foreground: #171717;
|
||||
--border: #e5e5e5;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.0 0 0);
|
||||
--foreground: oklch(0.98 0 0);
|
||||
--card: oklch(0.08 0 0);
|
||||
--card-foreground: oklch(0.98 0 0);
|
||||
--popover: oklch(0.08 0 0);
|
||||
--popover-foreground: oklch(0.98 0 0);
|
||||
--primary: oklch(0.98 0 0);
|
||||
--primary-foreground: oklch(0.0 0 0);
|
||||
--secondary: oklch(0.15 0 0);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.15 0 0);
|
||||
--muted-foreground: oklch(0.6 0 0);
|
||||
--accent: oklch(0.15 0 0);
|
||||
--accent-foreground: oklch(0.1 0 0);
|
||||
--destructive: oklch(0.65 0.2 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--border: oklch(0.25 0 0);
|
||||
--input: oklch(0.25 0 0);
|
||||
--ring: oklch(0.4 0 0);
|
||||
--chat-bg: oklch(0.25 0 0);
|
||||
--background: #0a0a0a;
|
||||
--foreground: #f5f5f5;
|
||||
--border: #262626;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a3a3a3;
|
||||
--primary: #f5f5f5;
|
||||
--primary-foreground: #0a0a0a;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: var(--font-geist);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Hide page scrollbar */
|
||||
html {
|
||||
scrollbar-width: none;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
::selection {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::selection {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Article tables */
|
||||
article table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
article th {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
article td {
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
:is(.dark) article th {
|
||||
border-bottom-color: #262626;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
:is(.dark) article td {
|
||||
border-bottom-color: rgba(38, 38, 38, 0.5);
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre {
|
||||
background: var(--card) !important;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.875rem;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
pre:not(.shiki) {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -127,17 +122,26 @@ pre {
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
background: var(--card);
|
||||
background: var(--muted);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Shiki dual theme support */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
color: var(--shiki-light) !important;
|
||||
background-color: var(--shiki-light-bg) !important;
|
||||
}
|
||||
|
||||
.dark .shiki,
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
|
||||
/* Prose */
|
||||
.prose {
|
||||
max-width: 100%;
|
||||
@@ -145,9 +149,9 @@ code {
|
||||
|
||||
.prose h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@@ -158,59 +162,102 @@ code {
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--foreground);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.7;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.65;
|
||||
color: #525252;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
:is(.dark) .prose p {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.25rem;
|
||||
color: #525252;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:is(.dark) .prose li {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.prose li strong {
|
||||
color: var(--foreground);
|
||||
opacity: 0.85;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: #d4d4d4;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
text-decoration-color: var(--foreground);
|
||||
}
|
||||
|
||||
:is(.dark) .prose a {
|
||||
text-decoration-color: #525252;
|
||||
}
|
||||
|
||||
:is(.dark) .prose a:hover {
|
||||
text-decoration-color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin-bottom: 1rem;
|
||||
border-left: 2px solid #e5e5e5;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
:is(.dark) .prose blockquote {
|
||||
border-left-color: #525252;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -284,7 +331,3 @@ code {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
+20
-19
@@ -1,18 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter, Geist_Mono } from "next/font/google";
|
||||
import { GeistPixelSquare } from "geist/font/pixel";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { MobileNavProvider } from "@/components/mobile-nav-context";
|
||||
import { Header } from "@/components/header";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||
import { CopyPageButton } from "@/components/copy-page-button";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
import { cookies } from "next/headers";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
|
||||
const geist = Geist({
|
||||
variable: "--font-geist",
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
@@ -66,23 +67,23 @@ export default async function RootLayout({
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${geist.variable} ${geistMono.variable} ${GeistPixelSquare.variable} antialiased bg-background text-foreground`}
|
||||
className={`${inter.variable} ${geistMono.variable} ${GeistPixelSquare.variable} bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<MobileNavProvider>
|
||||
<Header />
|
||||
<div className="flex min-h-[calc(100vh-3.5rem)]">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Header />
|
||||
<DocsMobileNav />
|
||||
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
|
||||
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
||||
<DocsSidebar />
|
||||
</aside>
|
||||
<div className="flex-1 min-w-0 max-w-2xl pb-20">
|
||||
<div className="flex justify-end mb-4">
|
||||
<CopyPageButton />
|
||||
</div>
|
||||
<article className="prose">{children}</article>
|
||||
</div>
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
</MobileNavProvider>
|
||||
</div>
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
</ThemeProvider>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("native-mode")
|
||||
|
||||
# Native Mode (Experimental)
|
||||
|
||||
agent-browser includes an experimental native Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating the Node.js and Playwright dependencies entirely.
|
||||
|
||||
## Enabling Native Mode
|
||||
|
||||
Native mode is opt-in. Enable it with the `--native` flag or the `AGENT_BROWSER_NATIVE` environment variable.
|
||||
|
||||
### CLI Flag
|
||||
|
||||
```bash
|
||||
agent-browser --native open example.com
|
||||
agent-browser --native snapshot
|
||||
agent-browser --native close
|
||||
```
|
||||
|
||||
### Environment Variable
|
||||
|
||||
Set `AGENT_BROWSER_NATIVE=1` to avoid passing the flag on every command:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
agent-browser snapshot
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
### Config File
|
||||
|
||||
Add `"native": true` to your `agent-browser.json`:
|
||||
|
||||
```json
|
||||
{"native": true}
|
||||
```
|
||||
|
||||
## Architecture Comparison
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th></th><th>Default (Node.js)</th><th>Native (<code>--native</code>)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Runtime</strong></td><td>Node.js + Playwright</td><td>Pure Rust binary</td></tr>
|
||||
<tr><td><strong>Protocol</strong></td><td>Playwright protocol</td><td>Direct CDP / WebDriver</td></tr>
|
||||
<tr><td><strong>Install size</strong></td><td>Larger (Node.js + npm deps)</td><td>Smaller (single binary)</td></tr>
|
||||
<tr><td><strong>Browser support</strong></td><td>Chromium, Firefox, WebKit</td><td>Chromium, Safari (via WebDriver)</td></tr>
|
||||
<tr><td><strong>Stability</strong></td><td>Stable</td><td>Experimental</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## What Works
|
||||
|
||||
All core commands are supported in native mode:
|
||||
|
||||
- Navigation: `open`, `back`, `forward`, `reload`
|
||||
- Interaction: `click`, `fill`, `type`, `press`, `hover`, `select`, `check`, `uncheck`, `scroll`, `focus`, `clear`, `upload`, `drag`
|
||||
- Observation: `snapshot`, `screenshot`, `eval`, `get text/html/value/attr/count/box/styles`, `is visible/enabled/checked`
|
||||
- State: `cookies get/set/clear`, `storage local/session`, `state save/load/list`
|
||||
- Tabs: `tab new/list/close`, tab switching
|
||||
- Emulation: `set viewport`, `set device`, `set geo`, user agent, timezone, locale
|
||||
- Streaming: WebSocket screencast and remote input
|
||||
- Diffing: `diff snapshot`, `diff url`
|
||||
- Recording: `record start/stop`
|
||||
- Profiling: `profiler start/stop`, `trace start/stop`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Firefox and WebKit** are not yet supported (Chromium and Safari only)
|
||||
- **Playwright trace format** is not available (native tracing uses Chrome's built-in tracing)
|
||||
- **HAR export** is not available
|
||||
- **Network route interception** uses CDP Fetch domain instead of Playwright's route API
|
||||
|
||||
## Switching Between Modes
|
||||
|
||||
The native daemon and Node.js daemon share the same session socket. You cannot run both simultaneously for the same session. Close the current daemon before switching:
|
||||
|
||||
```bash
|
||||
agent-browser close
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
```
|
||||
@@ -14,6 +14,8 @@ brew install agent-browser # macOS
|
||||
npx agent-browser-stealth open example.com
|
||||
```
|
||||
|
||||
Executable aliases after install: `agent-browser`, `agent-browser-stealth`, and `abs`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Agent-first** - Compact text output uses fewer tokens than JSON, designed for AI context efficiency
|
||||
@@ -61,7 +63,8 @@ has a unique ref like `@e1`, `@e2`. This provides:
|
||||
Client-daemon architecture for optimal performance:
|
||||
|
||||
1. **Rust CLI** - Parses commands, communicates with daemon
|
||||
2. **Node.js Daemon** - Manages Playwright browser instance
|
||||
2. **Node.js Daemon** (default) - Manages Playwright browser instance
|
||||
3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required
|
||||
|
||||
Daemon starts automatically and persists between commands.
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("security")
|
||||
|
||||
# Security
|
||||
|
||||
agent-browser includes security features to protect against credential exposure, prompt injection via untrusted page content, and unauthorized browser actions.
|
||||
|
||||
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. Enable these features as needed for your deployment -- existing workflows are unaffected until you explicitly activate a feature.
|
||||
|
||||
## Threat Model
|
||||
|
||||
These features are designed to mitigate the following threats when an LLM-based agent drives a browser:
|
||||
|
||||
- **Credential exposure** -- Passwords stored in the auth vault are never included in LLM context. The CLI handles vault operations locally; credentials do not pass through the daemon's IPC channel.
|
||||
- **Prompt injection via page content** -- Malicious pages can embed text that looks like tool output or system instructions. Content boundary markers (`--content-boundaries`) let the orchestrator distinguish trusted tool output from untrusted page content.
|
||||
- **Unauthorized navigation / data exfiltration** -- A compromised or manipulated agent could navigate to attacker-controlled domains to exfiltrate data. The domain allowlist (`--allowed-domains`) blocks navigations, sub-resource requests, WebSocket connections, EventSource streams, and `sendBeacon` calls to non-allowed domains.
|
||||
- **Unauthorized destructive actions** -- Action policy (`--action-policy`) and confirmation gating (`--confirm-actions`) prevent the agent from performing dangerous operations (eval, downloads, uploads) without explicit approval.
|
||||
- **Context flooding** -- Large page outputs can overwhelm an LLM's context window. Output truncation (`--max-output`) caps the size of page-sourced content.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **WebSocket/EventSource blocking is best-effort.** It works by overriding browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. Deny `eval` via `--action-policy` for maximum protection.
|
||||
- **Domain filter timing on remote connections.** When connecting to a pre-existing browser via CDP or a cloud provider, pages may have already loaded content before the domain filter is installed. agent-browser navigates disallowed pages to `about:blank` after the filter is active, but resources loaded before that point are not retroactively blocked.
|
||||
- **Content boundaries are defense-in-depth.** They rely on the LLM and orchestrator respecting the structural markers. A sufficiently capable adversarial page could attempt to mimic the boundary format, though the per-process CSPRNG nonce makes this impractical to predict.
|
||||
- **Confirmation timeout.** Pending confirmations auto-deny after 60 seconds. Orchestrators must respond within that window.
|
||||
- **Non-TTY auto-deny.** When `--confirm-interactive` is set but stdin is not a terminal (e.g., piped input), actions are automatically denied to prevent accidental approval in non-interactive contexts.
|
||||
|
||||
## Authentication Vault
|
||||
|
||||
Store credentials locally and reference them by name. The LLM never sees passwords.
|
||||
|
||||
```bash
|
||||
# Save credentials (encrypted if AGENT_BROWSER_ENCRYPTION_KEY is set)
|
||||
# Recommended: pipe password via stdin to avoid shell history / process listing exposure
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
|
||||
# Or pass directly (a warning will be shown)
|
||||
agent-browser auth save github --url https://github.com/login --username user --password pass
|
||||
|
||||
# Login using saved credentials
|
||||
agent-browser auth login github
|
||||
|
||||
# List saved profiles (names and URLs only, no secrets)
|
||||
agent-browser auth list
|
||||
|
||||
# Show profile metadata
|
||||
agent-browser auth show github
|
||||
|
||||
# Delete a profile
|
||||
agent-browser auth delete github
|
||||
```
|
||||
|
||||
Custom selectors can be specified if auto-detection fails:
|
||||
|
||||
```bash
|
||||
agent-browser auth save myapp \
|
||||
--url https://app.example.com/login \
|
||||
--username user --password pass \
|
||||
--username-selector "#email" \
|
||||
--password-selector "#password" \
|
||||
--submit-selector "button.login"
|
||||
```
|
||||
|
||||
Profiles are stored in `~/.agent-browser/auth/` and always encrypted with AES-256-GCM. If `AGENT_BROWSER_ENCRYPTION_KEY` is not set, a key is auto-generated at `~/.agent-browser/.encryption-key` on first use. Back up this file or set the environment variable explicitly for portability.
|
||||
|
||||
File permissions are enforced on both Unix (`chmod 600`/`700`) and Windows (`icacls` restricted to the current user) to prevent other users from reading encryption keys or auth profiles.
|
||||
|
||||
## Content Boundary Markers
|
||||
|
||||
When `--content-boundaries` is enabled, all page-sourced output is wrapped in structural markers so LLMs can distinguish tool output from untrusted page content:
|
||||
|
||||
```
|
||||
--- AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 origin=https://example.com ---
|
||||
[snapshot / text / html / eval output here]
|
||||
--- END_AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 ---
|
||||
```
|
||||
|
||||
The nonce is a random value generated per CLI process invocation, making it unpredictable to page content that might attempt to spoof the boundary.
|
||||
|
||||
Enable via flag or environment variable:
|
||||
|
||||
```bash
|
||||
agent-browser --content-boundaries snapshot
|
||||
# or
|
||||
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
|
||||
```
|
||||
|
||||
Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`.
|
||||
|
||||
In `--json` mode, boundary metadata is injected into the JSON response as a `_boundary` object containing `nonce` and `origin` fields, allowing orchestrators to verify provenance programmatically:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "snapshot": "...", "origin": "https://example.com" },
|
||||
"_boundary": { "nonce": "a1b2c3d4e5f6...", "origin": "https://example.com" }
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Allowlist
|
||||
|
||||
Restrict which domains the browser can interact with, preventing redirect-based attacks and data exfiltration:
|
||||
|
||||
```bash
|
||||
agent-browser --allowed-domains "example.com,*.example.com,github.com" open https://example.com
|
||||
# or
|
||||
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
|
||||
```
|
||||
|
||||
Supports exact match (`github.com`) and wildcard prefix (`*.example.com`, which also matches the bare domain `example.com`). Both page navigations and sub-resource requests (scripts, images, fetch, XHR, etc.) to non-allowed domains are blocked, preventing data exfiltration. WebSocket and EventSource connections are also blocked via constructor-level patching. Non-http(s) sub-resources (data URIs, blobs) are still allowed. When a request is blocked, the command returns an error.
|
||||
|
||||
> **Note:** The WebSocket/EventSource blocking is best-effort -- it works by overriding the browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. For maximum protection, deny the `eval` category via `--action-policy` when using `--allowed-domains`.
|
||||
|
||||
Config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedDomains": ["example.com", "*.example.com", "github.com"]
|
||||
}
|
||||
```
|
||||
|
||||
> **CDN and third-party resources:** The domain filter blocks all sub-resource requests (scripts, stylesheets, images, fonts, fetch/XHR) to non-allowed domains. Most websites load assets from CDN domains. Include these in your allowlist or pages will break. For example:
|
||||
>
|
||||
> ```bash
|
||||
> --allowed-domains "myapp.com,*.myapp.com,cdn.jsdelivr.net,fonts.googleapis.com,fonts.gstatic.com"
|
||||
> ```
|
||||
|
||||
## Action Policy
|
||||
|
||||
Gate actions using a static policy file. The policy is enforced by the daemon -- denied actions fail immediately.
|
||||
|
||||
```bash
|
||||
agent-browser --action-policy ./policy.json open https://example.com
|
||||
# or
|
||||
export AGENT_BROWSER_ACTION_POLICY=./policy.json
|
||||
```
|
||||
|
||||
Example policy (permissive with specific denials):
|
||||
|
||||
```json
|
||||
{
|
||||
"default": "allow",
|
||||
"deny": ["eval", "download", "upload"]
|
||||
}
|
||||
```
|
||||
|
||||
Example policy (restrictive):
|
||||
|
||||
```json
|
||||
{
|
||||
"default": "deny",
|
||||
"allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]
|
||||
}
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Category</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>navigate</code></td><td>open, back, forward, reload, tab new</td></tr>
|
||||
<tr><td><code>click</code></td><td>click, dblclick, tap</td></tr>
|
||||
<tr><td><code>fill</code></td><td>fill, type, keyboard type/inserttext, select, check, uncheck</td></tr>
|
||||
<tr><td><code>eval</code></td><td>eval, evalhandle, addscript, addinitscript, addstyle, expose, setcontent</td></tr>
|
||||
<tr><td><code>download</code></td><td>download, waitfordownload</td></tr>
|
||||
<tr><td><code>upload</code></td><td>upload</td></tr>
|
||||
<tr><td><code>snapshot</code></td><td>snapshot, screenshot, pdf, diff</td></tr>
|
||||
<tr><td><code>scroll</code></td><td>scroll, scrollintoview</td></tr>
|
||||
<tr><td><code>wait</code></td><td>wait, waitforurl, waitforloadstate, waitforfunction</td></tr>
|
||||
<tr><td><code>get</code></td><td>get text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.</td></tr>
|
||||
<tr><td><code>interact</code></td><td>hover, focus, drag, press, keydown, keyup, mousemove, dispatch</td></tr>
|
||||
<tr><td><code>network</code></td><td>network route/unroute, requests</td></tr>
|
||||
<tr><td><code>state</code></td><td>state save/load, cookies set, storage set</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Auth vault operations (`auth save`, `auth login`, `auth list`, `auth show`, `auth delete`) and other internal/meta operations bypass action policy enforcement since they are trusted local operations. Domain allowlist restrictions still apply to `auth login` navigations.
|
||||
|
||||
## Action Confirmation
|
||||
|
||||
For actions that require explicit approval, use `--confirm-actions` to specify categories that require confirmation:
|
||||
|
||||
```bash
|
||||
# Orchestrator mode: returns confirmation_required response
|
||||
agent-browser --confirm-actions eval,download eval "document.title"
|
||||
|
||||
# Then approve or deny:
|
||||
agent-browser confirm c_8f3a1234
|
||||
agent-browser deny c_8f3a1234
|
||||
```
|
||||
|
||||
For interactive (human-in-the-loop) confirmation:
|
||||
|
||||
```bash
|
||||
agent-browser --confirm-actions eval,download --confirm-interactive eval "document.title"
|
||||
# Prompts: Allow? [y/N]
|
||||
```
|
||||
|
||||
Pending confirmations auto-deny after 60 seconds.
|
||||
|
||||
> **Non-TTY behavior:** When `--confirm-interactive` is set but stdin is not a TTY (e.g., piped input or running inside an automated pipeline), actions are automatically denied. This prevents accidental approval in non-interactive contexts.
|
||||
|
||||
## Output Length Limits
|
||||
|
||||
Prevent context flooding by truncating large page outputs:
|
||||
|
||||
```bash
|
||||
agent-browser --max-output 50000 get text body
|
||||
# or
|
||||
export AGENT_BROWSER_MAX_OUTPUT=50000
|
||||
```
|
||||
|
||||
Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM encryption (auth vault + sessions)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Recommended Configuration
|
||||
|
||||
For production AI agent deployments:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentBoundaries": true,
|
||||
"maxOutput": 50000,
|
||||
"allowedDomains": ["your-app.com", "*.your-app.com"],
|
||||
"actionPolicy": "./policy.json"
|
||||
}
|
||||
```
|
||||
@@ -50,6 +50,8 @@ export AGENT_BROWSER_SESSION_NAME=twitter
|
||||
agent-browser open twitter.com
|
||||
```
|
||||
|
||||
If `--session-name` is omitted, it defaults to `--session` (or `default`).
|
||||
|
||||
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
|
||||
|
||||
### Session name rules
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("skills")
|
||||
|
||||
# Skills
|
||||
|
||||
agent-browser ships with skills that teach AI coding agents how to use it for specific workflows. Install a skill and your agent in Cursor, Claude Code, or Codex can automate browser tasks without manual guidance.
|
||||
|
||||
## Available Skills
|
||||
|
||||
- **agent-browser** — General browser automation: navigation, snapshots, forms, screenshots, data extraction, sessions, authentication, diffing, and the full command reference.
|
||||
- **dogfood** — Systematic exploratory testing. Navigates an app like a real user, finds bugs and UX issues, and produces a structured report with screenshots and repro videos.
|
||||
- **electron** — Automate any Electron app (VS Code, Slack, Discord, Figma, etc.) by connecting to its built-in Chrome DevTools Protocol port. This is how agent-browser drives native desktop apps like the Slack macOS app.
|
||||
- **slack** — Browser-based Slack automation. Check unreads, navigate channels, search conversations, send messages, and extract data — no API tokens needed.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npx skills add vercel-labs/agent-browser --skill agent-browser
|
||||
npx skills add vercel-labs/agent-browser --skill dogfood
|
||||
npx skills add vercel-labs/agent-browser --skill electron
|
||||
npx skills add vercel-labs/agent-browser --skill slack
|
||||
```
|
||||
|
||||
After installing, your AI agent will automatically activate the right skill when it encounters a matching request.
|
||||
|
||||
## agent-browser
|
||||
|
||||
The core skill. Teaches agents the full agent-browser API: the navigate-snapshot-interact-re-snapshot workflow, all commands, command chaining, authentication (auth vault and state persistence), sessions, diffing, JavaScript evaluation, annotated screenshots, semantic locators, and configuration.
|
||||
|
||||
Example agent interactions:
|
||||
|
||||
- "Open example.com and fill out the contact form"
|
||||
- "Take a screenshot of the dashboard after logging in"
|
||||
- "Compare staging and production versions of the homepage"
|
||||
|
||||
## dogfood
|
||||
|
||||
A structured workflow for exploratory testing. The agent opens a target URL, systematically explores the app (navigating pages, testing forms, clicking buttons, checking console errors), and documents every issue it finds with:
|
||||
|
||||
- Numbered repro steps
|
||||
- Step-by-step screenshots
|
||||
- Repro videos for interactive bugs
|
||||
- Severity classification
|
||||
|
||||
The output is a markdown report in an output directory, ready to hand to the responsible team. Run it with a single prompt like "dogfood vercel.com" or "QA http://localhost:3000 — focus on the billing page".
|
||||
|
||||
## electron
|
||||
|
||||
Electron apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to. This skill teaches agents how to launch or connect to any Electron app, then use the standard snapshot-interact workflow to automate it.
|
||||
|
||||
Electron apps are built on Chromium, so they expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to. Launch the app with `--remote-debugging-port`, connect, and use the standard snapshot-interact workflow. This is the foundation that the **slack** skill builds on.
|
||||
|
||||
## slack
|
||||
|
||||
Browser-based Slack automation. Connects to an existing Slack session (via `agent-browser connect 9222`) or opens Slack in a new browser, then uses snapshots and element refs to navigate the UI. Covers checking unreads, navigating channels and DMs, searching conversations, extracting message data, and taking screenshots — all without needing Slack API tokens or bot setup.
|
||||
|
||||
## Source
|
||||
|
||||
All skill files are in the [`skills/`](https://github.com/vercel-labs/agent-browser/tree/main/skills) directory of the repository.
|
||||
@@ -10,7 +10,10 @@ export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
|
||||
const trimmedCode = code.trim();
|
||||
const html = await codeToHtml(trimmedCode, {
|
||||
lang,
|
||||
theme: "github-dark-default",
|
||||
themes: {
|
||||
light: "github-light-default",
|
||||
dark: "github-dark-default",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function CopyPageButton() {
|
||||
const pathname = usePathname();
|
||||
const [state, setState] = useState<"idle" | "loading" | "copied">("idle");
|
||||
|
||||
const handleCopy = async () => {
|
||||
setState("loading");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/docs-markdown?path=${encodeURIComponent(pathname)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch markdown");
|
||||
}
|
||||
const markdown = await response.text();
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setState("copied");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
} catch {
|
||||
setState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={state === "loading"}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label="Copy page as Markdown"
|
||||
>
|
||||
{state === "copied" ? (
|
||||
<>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Copy Page
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -494,7 +494,7 @@ export function DocsChat({
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
|
||||
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity text-sm font-medium"
|
||||
aria-label="Ask AI"
|
||||
>
|
||||
Ask AI
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { navigation, allDocsPages } from "@/lib/docs-navigation";
|
||||
|
||||
export function DocsMobileNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
const page = allDocsPages.find((p) => p.href === pathname);
|
||||
return page ?? allDocsPages[0];
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger className="lg:hidden sticky top-14 z-40 w-full px-6 py-3 bg-background/80 backdrop-blur-sm border-b border-border flex items-center justify-between focus:outline-none">
|
||||
<div className="text-sm font-medium">{currentPage?.name}</div>
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" showCloseButton={false} className="overflow-y-auto p-6">
|
||||
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
|
||||
<nav className="space-y-6">
|
||||
{navigation.map((section, sectionIndex) => (
|
||||
<div key={section.title ?? sectionIndex}>
|
||||
{section.title && (
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{section.title}
|
||||
</h4>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`text-sm block py-2 transition-colors ${
|
||||
pathname === item.href
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { navigation } from "@/lib/docs-navigation";
|
||||
|
||||
export function DocsSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="space-y-6 pb-8">
|
||||
{navigation.map((section, sectionIndex) => (
|
||||
<div key={section.title ?? sectionIndex}>
|
||||
{section.title && (
|
||||
<h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
|
||||
{section.title}
|
||||
</h4>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"text-sm transition-colors block py-1",
|
||||
isActive
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMobileNav } from "./mobile-nav-context";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export function Header() {
|
||||
const { isOpen, toggle } = useMobileNav();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-background/90 backdrop-blur-sm">
|
||||
<header className="sticky top-0 z-50 bg-white/90 backdrop-blur-sm dark:bg-neutral-950/90">
|
||||
<div className="flex h-14 items-center justify-between px-4 gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="https://vercel.com" title="Made with love by Vercel">
|
||||
@@ -25,10 +22,10 @@ export function Header() {
|
||||
clipRule="evenodd"
|
||||
d="M8 1L16 15H0L8 1Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<span className="text-border">
|
||||
<span className="text-neutral-300 dark:text-neutral-700">
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="16"
|
||||
@@ -42,11 +39,14 @@ export function Header() {
|
||||
clipRule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<Link href="/">
|
||||
<span className="font-medium tracking-tight text-lg" style={{ fontFamily: "var(--font-geist-pixel-square)" }}>
|
||||
<span
|
||||
className="font-medium tracking-tight text-lg"
|
||||
style={{ fontFamily: "var(--font-geist-pixel-square)" }}
|
||||
>
|
||||
agent-browser
|
||||
</span>
|
||||
</Link>
|
||||
@@ -56,7 +56,7 @@ export function Header() {
|
||||
href="https://github.com/leeguooooo/agent-browser"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-900 transition-colors dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
@@ -66,32 +66,17 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>14k</span>
|
||||
<span>16k</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/agent-browser-stealth"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-sm text-neutral-500 hover:text-neutral-900 transition-colors dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
>
|
||||
npm
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="lg:hidden p-2 -mr-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isOpen ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
type MobileNavContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
const MobileNavContext = createContext<MobileNavContextType | null>(null);
|
||||
|
||||
export function MobileNavProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, []);
|
||||
|
||||
const toggle = () => setIsOpen(!isOpen);
|
||||
|
||||
return (
|
||||
<MobileNavContext.Provider value={{ isOpen, setIsOpen, toggle }}>
|
||||
{children}
|
||||
</MobileNavContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMobileNav() {
|
||||
const context = useContext(MobileNavContext);
|
||||
if (!context) {
|
||||
throw new Error("useMobileNav must be used within a MobileNavProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMobileNav } from "./mobile-nav-context";
|
||||
import { navigation } from "@/lib/docs-navigation";
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isOpen, setIsOpen } = useMobileNav();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-background/80"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed lg:sticky top-14 left-0 z-50 lg:z-auto
|
||||
w-56 lg:w-48 h-[calc(100vh-3.5rem)]
|
||||
bg-background
|
||||
transform transition-transform duration-150 ease-out
|
||||
${isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
|
||||
`}
|
||||
>
|
||||
<div className="h-full overflow-y-auto py-5 pl-3 pr-5">
|
||||
<nav className="space-y-4">
|
||||
{navigation.map((section, sectionIndex) => (
|
||||
<div key={section.title ?? sectionIndex}>
|
||||
{section.title && (
|
||||
<div className="px-2 pb-1 text-xs font-medium text-muted-foreground/60 uppercase tracking-wider">
|
||||
{section.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block px-2 py-1.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-md text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 transition-colors dark:text-neutral-400 dark:hover:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
|
||||
@@ -15,6 +15,7 @@ export const navigation: NavSection[] = [
|
||||
{ name: "Introduction", href: "/" },
|
||||
{ name: "Installation", href: "/installation" },
|
||||
{ name: "Quick Start", href: "/quick-start" },
|
||||
{ name: "Skills", href: "/skills" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -35,6 +36,8 @@ export const navigation: NavSection[] = [
|
||||
{ name: "Streaming", href: "/streaming" },
|
||||
{ name: "Profiler", href: "/profiler" },
|
||||
{ name: "iOS Simulator", href: "/ios" },
|
||||
{ name: "Security", href: "/security" },
|
||||
{ name: "Native Mode (Experimental)", href: "/native-mode" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/**
|
||||
* Converts raw MDX content to clean Markdown suitable for AI agents.
|
||||
*
|
||||
* Transformations:
|
||||
* - Remove `export` statements (metadata, etc.)
|
||||
* - Remove `import` statements
|
||||
* - Strip standalone JSX divs with className attributes
|
||||
* - Pass everything else through as-is (already valid Markdown)
|
||||
* Strips export/import statements and standalone JSX divs with className
|
||||
* attributes, passing everything else through as valid Markdown.
|
||||
*/
|
||||
export function mdxToCleanMarkdown(raw: string): string {
|
||||
const lines = raw.split("\n");
|
||||
@@ -16,12 +13,10 @@ export function mdxToCleanMarkdown(raw: string): string {
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip export and import statements
|
||||
if (trimmed.startsWith("export ") || trimmed.startsWith("import ")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track JSX blocks (like callout divs) and skip them
|
||||
if (
|
||||
!inJsxBlock &&
|
||||
trimmed.startsWith("<div ") &&
|
||||
@@ -33,7 +28,6 @@ export function mdxToCleanMarkdown(raw: string): string {
|
||||
}
|
||||
|
||||
if (inJsxBlock) {
|
||||
// Count opening/closing div tags to handle nesting
|
||||
const opens = (line.match(/<div[\s>]/g) || []).length;
|
||||
const closes = (line.match(/<\/div>/g) || []).length;
|
||||
jsxDepth += opens - closes;
|
||||
@@ -47,7 +41,6 @@ export function mdxToCleanMarkdown(raw: string): string {
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
// Clean up leading blank lines
|
||||
let result = out.join("\n");
|
||||
result = result.replace(/^\n+/, "\n").trim();
|
||||
return result;
|
||||
|
||||
@@ -2,6 +2,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"": "Headless Browser\nAutomation for AI",
|
||||
installation: "Installation",
|
||||
"quick-start": "Quick Start",
|
||||
skills: "Skills",
|
||||
commands: "Commands",
|
||||
configuration: "Configuration",
|
||||
selectors: "Selectors",
|
||||
@@ -12,6 +13,8 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
streaming: "Streaming",
|
||||
profiler: "Profiler",
|
||||
ios: "iOS Simulator",
|
||||
security: "Security",
|
||||
"native-mode": "Native Mode (Experimental)",
|
||||
changelog: "Changelog",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
(() => {
|
||||
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
const RESPONSE_TYPE = 'AB_TAB_GROUP_RESPONSE';
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
if (!data || data.type !== REQUEST_TYPE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
type: REQUEST_TYPE,
|
||||
nonce: data.nonce,
|
||||
session: data.session,
|
||||
groupTitle: data.groupTitle,
|
||||
pluginId: data.pluginId,
|
||||
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
chrome.runtime.sendMessage(request, (response) => {
|
||||
const lastError = chrome.runtime.lastError;
|
||||
if (lastError) {
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: false,
|
||||
error: lastError.message,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = response && typeof response === 'object' ? response : { ok: false };
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: payload.ok === true,
|
||||
extensionId:
|
||||
typeof payload.extensionId === 'string' && payload.extensionId.length > 0
|
||||
? payload.extensionId
|
||||
: chrome.runtime.id,
|
||||
groupId: typeof payload.groupId === 'number' ? payload.groupId : undefined,
|
||||
windowId: typeof payload.windowId === 'number' ? payload.windowId : undefined,
|
||||
color: typeof payload.color === 'string' ? payload.color : undefined,
|
||||
collapsed: payload.collapsed === true,
|
||||
policy:
|
||||
payload.policy && typeof payload.policy === 'object'
|
||||
? {
|
||||
enforced: payload.policy.enforced === true,
|
||||
blocked: payload.policy.blocked === true,
|
||||
reason:
|
||||
typeof payload.policy.reason === 'string' ? payload.policy.reason : undefined,
|
||||
}
|
||||
: undefined,
|
||||
riskHints: Array.isArray(payload.riskHints) ? payload.riskHints : undefined,
|
||||
error: typeof payload.error === 'string' ? payload.error : undefined,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.2.0",
|
||||
"description": "Session-aware tab grouping and coordination for CDP-driven agent-browser workflows.",
|
||||
"permissions": ["tabs", "tabGroups", "downloads", "storage", "sidePanel", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "agent-browser-stealth"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"run_at": "document_start",
|
||||
"match_about_blank": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
const PANEL_GET_STATE = 'AB_PANEL_GET_STATE';
|
||||
const PANEL_CLOSE_OTHER_TABS = 'AB_PANEL_CLOSE_OTHER_SESSION_TABS';
|
||||
const PANEL_FOCUS_SESSION = 'AB_PANEL_FOCUS_SESSION';
|
||||
const PANEL_CLEAN_EMPTY_GROUPS = 'AB_PANEL_CLEAN_EMPTY_GROUPS';
|
||||
const PANEL_SET_POLICY = 'AB_PANEL_SET_POLICY';
|
||||
const PANEL_SET_OPTIONS = 'AB_PANEL_SET_OPTIONS';
|
||||
|
||||
const DEFAULT_GROUP_TITLE = 'Agent Browser Stealth';
|
||||
const DOWNLOAD_ARCHIVE_ROOT = 'agent-browser-stealth';
|
||||
const STORAGE_POLICY_KEY = 'abSessionPoliciesV1';
|
||||
const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1';
|
||||
const CLEANUP_ALARM_NAME = 'ab-clean-empty-groups';
|
||||
const GROUP_COLORS = ['blue', 'green', 'pink', 'orange', 'purple', 'cyan', 'red', 'yellow'];
|
||||
const RISKY_TLDS = new Set(['zip', 'mov', 'click', 'top', 'gq', 'tk', 'country']);
|
||||
const RISKY_HOST_KEYWORDS = ['secure-login', 'account-verify', 'wallet-verify', 'airdrop-claim'];
|
||||
|
||||
const DEFAULT_EXTENSION_OPTIONS = {
|
||||
strictWindowIsolation: true,
|
||||
suppressCrossWindowActivation: true,
|
||||
autoCleanEmptyGroups: true,
|
||||
};
|
||||
|
||||
const sessionGroupCache = new Map();
|
||||
const sessionWindowMap = new Map();
|
||||
const tabSessionMap = new Map();
|
||||
const tabMetaById = new Map();
|
||||
const downloadEvents = [];
|
||||
const sessionPolicies = new Map();
|
||||
let extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS };
|
||||
|
||||
let bootstrapPromise = bootstrapState();
|
||||
|
||||
function normalizeSession(session) {
|
||||
if (typeof session !== 'string') return 'default';
|
||||
const trimmed = session.trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 64) : 'default';
|
||||
}
|
||||
|
||||
function normalizeGroupTitle(title) {
|
||||
if (typeof title !== 'string') return DEFAULT_GROUP_TITLE;
|
||||
const trimmed = title.trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 80) : DEFAULT_GROUP_TITLE;
|
||||
}
|
||||
|
||||
function normalizeAllowedDomains(domains) {
|
||||
if (!Array.isArray(domains)) return [];
|
||||
return domains
|
||||
.map((item) => (typeof item === 'string' ? item.trim().toLowerCase() : ''))
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 256);
|
||||
}
|
||||
|
||||
function parseHostname(rawUrl) {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return null;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
return parsed.hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function domainMatches(hostname, pattern) {
|
||||
if (!hostname || !pattern) return false;
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.slice(2);
|
||||
return hostname === suffix || hostname.endsWith(`.${suffix}`);
|
||||
}
|
||||
if (pattern.startsWith('.')) {
|
||||
const suffix = pattern.slice(1);
|
||||
return hostname === suffix || hostname.endsWith(pattern);
|
||||
}
|
||||
return hostname === pattern || hostname.endsWith(`.${pattern}`);
|
||||
}
|
||||
|
||||
function isDomainAllowed(hostname, patterns) {
|
||||
if (!hostname) return true;
|
||||
if (!patterns || patterns.length === 0) return true;
|
||||
return patterns.some((pattern) => domainMatches(hostname, pattern));
|
||||
}
|
||||
|
||||
function collectRiskHints(rawUrl, allowedDomains) {
|
||||
const hints = [];
|
||||
const hostname = parseHostname(rawUrl);
|
||||
if (!hostname) return hints;
|
||||
|
||||
if (allowedDomains.length > 0 && !isDomainAllowed(hostname, allowedDomains)) {
|
||||
hints.push(`domain-not-allowed:${hostname}`);
|
||||
}
|
||||
|
||||
const tld = hostname.split('.').pop();
|
||||
if (tld && RISKY_TLDS.has(tld)) {
|
||||
hints.push(`high-risk-tld:.${tld}`);
|
||||
}
|
||||
|
||||
for (const keyword of RISKY_HOST_KEYWORDS) {
|
||||
if (hostname.includes(keyword)) {
|
||||
hints.push(`suspicious-host-keyword:${keyword}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(hints)].slice(0, 10);
|
||||
}
|
||||
|
||||
function cacheKey(windowId, session) {
|
||||
return `${windowId}:${session}`;
|
||||
}
|
||||
|
||||
function sanitizeSegment(input, fallback = 'default') {
|
||||
const raw = typeof input === 'string' ? input : '';
|
||||
const cleaned = raw
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/\.+/g, '.')
|
||||
.trim();
|
||||
if (!cleaned) return fallback;
|
||||
return cleaned.slice(0, 80);
|
||||
}
|
||||
|
||||
function sanitizeFilename(filename, fallback = 'download.bin') {
|
||||
const name = typeof filename === 'string' ? filename.split('/').pop() : '';
|
||||
return sanitizeSegment(name, fallback);
|
||||
}
|
||||
|
||||
function pickColorForSession(session) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < session.length; i += 1) {
|
||||
hash = (hash * 31 + session.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return GROUP_COLORS[hash % GROUP_COLORS.length];
|
||||
}
|
||||
|
||||
function shouldCollapseGroup(session) {
|
||||
return session !== 'default';
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
try {
|
||||
const result = await chrome.storage.local.get([STORAGE_POLICY_KEY]);
|
||||
const entries = result?.[STORAGE_POLICY_KEY];
|
||||
if (!entries || typeof entries !== 'object') return;
|
||||
|
||||
for (const [session, domains] of Object.entries(entries)) {
|
||||
const normalizedSession = normalizeSession(session);
|
||||
sessionPolicies.set(normalizedSession, normalizeAllowedDomains(domains));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage load failures.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { ...DEFAULT_EXTENSION_OPTIONS };
|
||||
}
|
||||
return {
|
||||
strictWindowIsolation: raw.strictWindowIsolation !== false,
|
||||
suppressCrossWindowActivation: raw.suppressCrossWindowActivation !== false,
|
||||
autoCleanEmptyGroups: raw.autoCleanEmptyGroups !== false,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try {
|
||||
const result = await chrome.storage.local.get([STORAGE_OPTIONS_KEY]);
|
||||
extensionOptions = normalizeOptions(result?.[STORAGE_OPTIONS_KEY]);
|
||||
} catch {
|
||||
extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS };
|
||||
}
|
||||
}
|
||||
|
||||
async function persistOptions() {
|
||||
await chrome.storage.local.set({ [STORAGE_OPTIONS_KEY]: extensionOptions });
|
||||
}
|
||||
|
||||
async function setExtensionOptions(nextOptions) {
|
||||
extensionOptions = {
|
||||
...extensionOptions,
|
||||
...normalizeOptions(nextOptions),
|
||||
};
|
||||
await persistOptions();
|
||||
await syncCleanupAlarm();
|
||||
return extensionOptions;
|
||||
}
|
||||
|
||||
async function bootstrapState() {
|
||||
await loadPolicies();
|
||||
await loadOptions();
|
||||
await syncCleanupAlarm();
|
||||
}
|
||||
|
||||
async function persistPolicies() {
|
||||
const serialized = {};
|
||||
for (const [session, domains] of sessionPolicies.entries()) {
|
||||
serialized[session] = [...domains];
|
||||
}
|
||||
await chrome.storage.local.set({ [STORAGE_POLICY_KEY]: serialized });
|
||||
}
|
||||
|
||||
async function setSessionPolicy(session, allowedDomains) {
|
||||
const normalizedSession = normalizeSession(session);
|
||||
const normalizedDomains = normalizeAllowedDomains(allowedDomains);
|
||||
sessionPolicies.set(normalizedSession, normalizedDomains);
|
||||
await persistPolicies();
|
||||
}
|
||||
|
||||
function getSessionPolicy(session) {
|
||||
const normalizedSession = normalizeSession(session);
|
||||
return sessionPolicies.get(normalizedSession) ?? [];
|
||||
}
|
||||
|
||||
function updateTabMeta(tab) {
|
||||
if (!tab || typeof tab.id !== 'number') return;
|
||||
tabMetaById.set(tab.id, {
|
||||
id: tab.id,
|
||||
windowId: typeof tab.windowId === 'number' ? tab.windowId : -1,
|
||||
url: typeof tab.url === 'string' ? tab.url : '',
|
||||
title: typeof tab.title === 'string' ? tab.title : '',
|
||||
groupId: typeof tab.groupId === 'number' ? tab.groupId : -1,
|
||||
active: tab.active === true,
|
||||
lastSeenAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function pruneDownloadEvents() {
|
||||
const maxSize = 100;
|
||||
if (downloadEvents.length > maxSize) {
|
||||
downloadEvents.splice(0, downloadEvents.length - maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
function recordDownloadEvent(event) {
|
||||
downloadEvents.push({ ...event, timestamp: Date.now() });
|
||||
pruneDownloadEvents();
|
||||
}
|
||||
|
||||
function removeWindowCaches(windowId) {
|
||||
for (const key of [...sessionGroupCache.keys()]) {
|
||||
if (key.startsWith(`${windowId}:`)) {
|
||||
sessionGroupCache.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [session, mappedWindowId] of [...sessionWindowMap.entries()]) {
|
||||
if (mappedWindowId === windowId) {
|
||||
sessionWindowMap.delete(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSessionWindow(tabId, currentWindowId, session) {
|
||||
if (!extensionOptions.strictWindowIsolation) {
|
||||
sessionWindowMap.set(session, currentWindowId);
|
||||
return currentWindowId;
|
||||
}
|
||||
|
||||
let targetWindowId = sessionWindowMap.get(session);
|
||||
|
||||
if (typeof targetWindowId === 'number') {
|
||||
try {
|
||||
await chrome.windows.get(targetWindowId);
|
||||
} catch {
|
||||
sessionWindowMap.delete(session);
|
||||
targetWindowId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof targetWindowId !== 'number') {
|
||||
sessionWindowMap.set(session, currentWindowId);
|
||||
return currentWindowId;
|
||||
}
|
||||
|
||||
if (targetWindowId === currentWindowId) {
|
||||
return targetWindowId;
|
||||
}
|
||||
|
||||
await chrome.tabs.move(tabId, { windowId: targetWindowId, index: -1 });
|
||||
await chrome.tabs.update(tabId, { active: false }).catch(() => {});
|
||||
return targetWindowId;
|
||||
}
|
||||
|
||||
async function findExistingGroup(windowId, groupTitle) {
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const checked = new Set();
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (typeof tab.groupId !== 'number' || tab.groupId < 0 || checked.has(tab.groupId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
checked.add(tab.groupId);
|
||||
try {
|
||||
const group = await chrome.tabGroups.get(tab.groupId);
|
||||
if (group.title === groupTitle) {
|
||||
return tab.groupId;
|
||||
}
|
||||
} catch {
|
||||
// Ignore stale group references.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureSessionGroup(tabId, windowId, session, groupTitle) {
|
||||
const targetWindowId = await ensureSessionWindow(tabId, windowId, session);
|
||||
const key = cacheKey(targetWindowId, session);
|
||||
let groupId = sessionGroupCache.get(key);
|
||||
|
||||
if (typeof groupId === 'number') {
|
||||
try {
|
||||
await chrome.tabGroups.get(groupId);
|
||||
} catch {
|
||||
groupId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof groupId !== 'number') {
|
||||
const existing = await findExistingGroup(targetWindowId, groupTitle);
|
||||
if (typeof existing === 'number') {
|
||||
groupId = existing;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof groupId === 'number') {
|
||||
await chrome.tabs.group({ groupId, tabIds: [tabId] });
|
||||
} else {
|
||||
groupId = await chrome.tabs.group({
|
||||
tabIds: [tabId],
|
||||
createProperties: { windowId: targetWindowId },
|
||||
});
|
||||
}
|
||||
|
||||
const color = pickColorForSession(session);
|
||||
const collapsed = shouldCollapseGroup(session);
|
||||
await chrome.tabGroups.update(groupId, {
|
||||
title: groupTitle,
|
||||
color,
|
||||
collapsed,
|
||||
});
|
||||
|
||||
sessionGroupCache.set(key, groupId);
|
||||
sessionWindowMap.set(session, targetWindowId);
|
||||
|
||||
return {
|
||||
groupId,
|
||||
windowId: targetWindowId,
|
||||
color,
|
||||
collapsed,
|
||||
};
|
||||
}
|
||||
|
||||
async function applySessionDomainFallback(tabId, session) {
|
||||
const allowedDomains = getSessionPolicy(session);
|
||||
if (allowedDomains.length === 0) {
|
||||
return { enforced: false, blocked: false };
|
||||
}
|
||||
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch {
|
||||
return { enforced: true, blocked: false };
|
||||
}
|
||||
|
||||
const hostname = parseHostname(tab.url);
|
||||
if (!hostname) {
|
||||
return { enforced: true, blocked: false };
|
||||
}
|
||||
|
||||
if (isDomainAllowed(hostname, allowedDomains)) {
|
||||
return { enforced: true, blocked: false };
|
||||
}
|
||||
|
||||
await chrome.tabs.update(tabId, { url: 'about:blank' }).catch(() => {});
|
||||
return {
|
||||
enforced: true,
|
||||
blocked: true,
|
||||
reason: `${hostname} is not in allowed domains`,
|
||||
};
|
||||
}
|
||||
|
||||
function getManagedSessionForTab(tabId) {
|
||||
if (typeof tabId !== 'number') return undefined;
|
||||
return tabSessionMap.get(tabId);
|
||||
}
|
||||
|
||||
function collectSessionTabIds(session) {
|
||||
const result = [];
|
||||
for (const [tabId, tabSession] of tabSessionMap.entries()) {
|
||||
if (tabSession === session) {
|
||||
result.push(tabId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function closeOtherSessionTabs(session) {
|
||||
const normalized = normalizeSession(session);
|
||||
const closeIds = [];
|
||||
|
||||
for (const [tabId, tabSession] of tabSessionMap.entries()) {
|
||||
if (tabSession !== normalized) {
|
||||
closeIds.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
if (closeIds.length > 0) {
|
||||
await chrome.tabs.remove(closeIds);
|
||||
}
|
||||
|
||||
return { closed: closeIds.length };
|
||||
}
|
||||
|
||||
async function focusSession(session) {
|
||||
const normalized = normalizeSession(session);
|
||||
const tabIds = collectSessionTabIds(normalized);
|
||||
if (tabIds.length === 0) {
|
||||
return { focused: false };
|
||||
}
|
||||
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabIds[0]);
|
||||
} catch {
|
||||
return { focused: false };
|
||||
}
|
||||
|
||||
if (typeof tab.windowId === 'number') {
|
||||
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {});
|
||||
}
|
||||
await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
|
||||
return { focused: true, tabId: tab.id };
|
||||
}
|
||||
|
||||
async function cleanEmptyGroups() {
|
||||
let removedGroups = 0;
|
||||
let removedWindows = 0;
|
||||
|
||||
for (const [key, groupId] of [...sessionGroupCache.entries()]) {
|
||||
const [windowIdRaw] = key.split(':');
|
||||
const windowId = Number(windowIdRaw);
|
||||
|
||||
let groupExists = true;
|
||||
try {
|
||||
await chrome.tabGroups.get(groupId);
|
||||
} catch {
|
||||
groupExists = false;
|
||||
}
|
||||
|
||||
if (!groupExists) {
|
||||
sessionGroupCache.delete(key);
|
||||
removedGroups += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tabs = await chrome.tabs.query({ windowId }).catch(() => []);
|
||||
const hasMembers = tabs.some((tab) => tab.groupId === groupId);
|
||||
if (!hasMembers) {
|
||||
sessionGroupCache.delete(key);
|
||||
removedGroups += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [session, windowId] of [...sessionWindowMap.entries()]) {
|
||||
try {
|
||||
await chrome.windows.get(windowId);
|
||||
} catch {
|
||||
sessionWindowMap.delete(session);
|
||||
removedWindows += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { removedGroups, removedWindows };
|
||||
}
|
||||
|
||||
async function syncCleanupAlarm() {
|
||||
try {
|
||||
await chrome.alarms.clear(CLEANUP_ALARM_NAME);
|
||||
if (extensionOptions.autoCleanEmptyGroups) {
|
||||
await chrome.alarms.create(CLEANUP_ALARM_NAME, { periodInMinutes: 1 });
|
||||
}
|
||||
} catch {
|
||||
// Ignore alarms API failures.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceSessionWindowAffinity(tabId) {
|
||||
if (!extensionOptions.suppressCrossWindowActivation) return { moved: false };
|
||||
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (!session) return { moved: false };
|
||||
if (!extensionOptions.strictWindowIsolation) return { moved: false };
|
||||
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch {
|
||||
return { moved: false };
|
||||
}
|
||||
|
||||
const mappedWindowId = sessionWindowMap.get(session);
|
||||
if (typeof mappedWindowId !== 'number' || mappedWindowId === tab.windowId) {
|
||||
if (typeof tab.windowId === 'number') {
|
||||
sessionWindowMap.set(session, tab.windowId);
|
||||
}
|
||||
return { moved: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.tabs.move(tabId, { windowId: mappedWindowId, index: -1 });
|
||||
await chrome.tabs.update(tabId, { active: false }).catch(() => {});
|
||||
return { moved: true, toWindowId: mappedWindowId };
|
||||
} catch {
|
||||
return { moved: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRiskBadge(tabId) {
|
||||
let text = '';
|
||||
let title = 'agent-browser-stealth';
|
||||
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (session) {
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch {
|
||||
tab = undefined;
|
||||
}
|
||||
|
||||
if (tab) {
|
||||
const hints = collectRiskHints(tab.url, getSessionPolicy(session));
|
||||
if (hints.length > 0) {
|
||||
text = '!';
|
||||
title = `Risk hints (${hints.length}): ${hints.join(', ')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await chrome.action.setBadgeText({ text }).catch(() => {});
|
||||
await chrome.action.setBadgeBackgroundColor({ color: '#dc2626' }).catch(() => {});
|
||||
await chrome.action.setTitle({ title }).catch(() => {});
|
||||
}
|
||||
|
||||
async function buildPanelState() {
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
for (const tab of allTabs) {
|
||||
updateTabMeta(tab);
|
||||
}
|
||||
|
||||
const sessionMap = new Map();
|
||||
|
||||
for (const tab of allTabs) {
|
||||
if (typeof tab.id !== 'number') continue;
|
||||
const session = getManagedSessionForTab(tab.id);
|
||||
if (!session) continue;
|
||||
|
||||
if (!sessionMap.has(session)) {
|
||||
sessionMap.set(session, {
|
||||
session,
|
||||
windowId: sessionWindowMap.get(session) ?? tab.windowId,
|
||||
allowedDomains: getSessionPolicy(session),
|
||||
tabs: [],
|
||||
riskHints: [],
|
||||
});
|
||||
}
|
||||
|
||||
const entry = sessionMap.get(session);
|
||||
entry.tabs.push({
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
title: tab.title ?? '',
|
||||
url: tab.url ?? '',
|
||||
active: tab.active === true,
|
||||
groupId: typeof tab.groupId === 'number' ? tab.groupId : -1,
|
||||
});
|
||||
|
||||
const hints = collectRiskHints(tab.url, entry.allowedDomains);
|
||||
for (const hint of hints) {
|
||||
if (!entry.riskHints.includes(hint)) {
|
||||
entry.riskHints.push(hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = [];
|
||||
for (const sessionEntry of sessionMap.values()) {
|
||||
sessionEntry.tabs.sort((a, b) => Number(b.active) - Number(a.active));
|
||||
const key = cacheKey(sessionEntry.windowId, sessionEntry.session);
|
||||
const cachedGroupId = sessionGroupCache.get(key);
|
||||
|
||||
let group;
|
||||
if (typeof cachedGroupId === 'number') {
|
||||
try {
|
||||
const groupInfo = await chrome.tabGroups.get(cachedGroupId);
|
||||
group = {
|
||||
id: cachedGroupId,
|
||||
title: groupInfo.title,
|
||||
color: groupInfo.color,
|
||||
collapsed: groupInfo.collapsed,
|
||||
};
|
||||
} catch {
|
||||
// Group may no longer exist.
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
...sessionEntry,
|
||||
group,
|
||||
});
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => a.session.localeCompare(b.session));
|
||||
|
||||
return {
|
||||
extensionId: chrome.runtime.id,
|
||||
options: { ...extensionOptions },
|
||||
totals: {
|
||||
sessions: sessions.length,
|
||||
tabs: sessions.reduce((sum, session) => sum + session.tabs.length, 0),
|
||||
},
|
||||
sessions,
|
||||
downloads: downloadEvents.slice(-25).reverse(),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTabGroupRequest(message, sender) {
|
||||
await bootstrapPromise;
|
||||
|
||||
const tabId = sender.tab?.id;
|
||||
const windowId = sender.tab?.windowId;
|
||||
const nonce = typeof message.nonce === 'string' ? message.nonce : undefined;
|
||||
|
||||
if (typeof tabId !== 'number' || typeof windowId !== 'number') {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'missing-tab-context',
|
||||
extensionId: chrome.runtime.id,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof message.pluginId === 'string' && message.pluginId !== chrome.runtime.id) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'plugin-id-mismatch',
|
||||
extensionId: chrome.runtime.id,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
|
||||
const session = normalizeSession(message.session);
|
||||
const groupTitle = normalizeGroupTitle(message.groupTitle);
|
||||
const allowedDomains = normalizeAllowedDomains(message.allowedDomains);
|
||||
if (allowedDomains.length > 0) {
|
||||
await setSessionPolicy(session, allowedDomains);
|
||||
}
|
||||
|
||||
tabSessionMap.set(tabId, session);
|
||||
updateTabMeta(sender.tab);
|
||||
|
||||
const grouping = await ensureSessionGroup(tabId, windowId, session, groupTitle);
|
||||
const policy = await applySessionDomainFallback(tabId, session);
|
||||
const riskHints = collectRiskHints(sender.tab?.url, getSessionPolicy(session));
|
||||
if (policy.blocked && policy.reason) {
|
||||
riskHints.push(`policy-blocked:${policy.reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
extensionId: chrome.runtime.id,
|
||||
nonce,
|
||||
...grouping,
|
||||
policy,
|
||||
riskHints: [...new Set(riskHints)].slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});
|
||||
bootstrapPromise = bootstrapState();
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
bootstrapPromise = bootstrapState();
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = message.type;
|
||||
|
||||
if (type === REQUEST_TYPE) {
|
||||
handleTabGroupRequest(message, sender)
|
||||
.then((response) => sendResponse(response))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
extensionId: chrome.runtime.id,
|
||||
nonce: typeof message.nonce === 'string' ? message.nonce : undefined,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_GET_STATE) {
|
||||
bootstrapPromise
|
||||
.then(() => buildPanelState())
|
||||
.then((state) => sendResponse({ ok: true, state }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_CLOSE_OTHER_TABS) {
|
||||
closeOtherSessionTabs(message.session)
|
||||
.then((result) => sendResponse({ ok: true, result }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_FOCUS_SESSION) {
|
||||
focusSession(message.session)
|
||||
.then((result) => sendResponse({ ok: true, result }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_CLEAN_EMPTY_GROUPS) {
|
||||
cleanEmptyGroups()
|
||||
.then((result) => sendResponse({ ok: true, result }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_SET_POLICY) {
|
||||
bootstrapPromise
|
||||
.then(() => setSessionPolicy(message.session, message.allowedDomains))
|
||||
.then(() => sendResponse({ ok: true }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === PANEL_SET_OPTIONS) {
|
||||
bootstrapPromise
|
||||
.then(() => setExtensionOptions(message.options))
|
||||
.then((options) => sendResponse({ ok: true, options }))
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
sendResponse({ ok: false, error: errorMessage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
updateTabMeta(tab);
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (!session) {
|
||||
if (changeInfo.status === 'complete' && tab.active === true) {
|
||||
updateRiskBadge(tabId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof tab.windowId === 'number') {
|
||||
sessionWindowMap.set(session, tab.windowId);
|
||||
}
|
||||
|
||||
if (changeInfo.status === 'complete') {
|
||||
applySessionDomainFallback(tabId, session).catch(() => {});
|
||||
if (tab.active === true) {
|
||||
updateRiskBadge(tabId).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onActivated.addListener((activeInfo) => {
|
||||
enforceSessionWindowAffinity(activeInfo.tabId).catch(() => {});
|
||||
updateRiskBadge(activeInfo.tabId).catch(() => {});
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
tabSessionMap.delete(tabId);
|
||||
tabMetaById.delete(tabId);
|
||||
|
||||
if (removeInfo.isWindowClosing) {
|
||||
removeWindowCaches(removeInfo.windowId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session) return;
|
||||
const remaining = collectSessionTabIds(session);
|
||||
if (remaining.length === 0) {
|
||||
sessionWindowMap.delete(session);
|
||||
}
|
||||
cleanEmptyGroups().catch(() => {});
|
||||
});
|
||||
|
||||
chrome.tabs.onDetached.addListener((tabId) => {
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (!session) return;
|
||||
tabMetaById.delete(tabId);
|
||||
});
|
||||
|
||||
chrome.tabs.onAttached.addListener((tabId, attachInfo) => {
|
||||
const session = getManagedSessionForTab(tabId);
|
||||
if (!session) return;
|
||||
sessionWindowMap.set(session, attachInfo.newWindowId);
|
||||
});
|
||||
|
||||
chrome.windows.onRemoved.addListener((windowId) => {
|
||||
removeWindowCaches(windowId);
|
||||
cleanEmptyGroups().catch(() => {});
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm?.name !== CLEANUP_ALARM_NAME) return;
|
||||
cleanEmptyGroups().catch(() => {});
|
||||
});
|
||||
|
||||
chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
|
||||
const session = getManagedSessionForTab(item.tabId);
|
||||
if (!session) {
|
||||
suggest();
|
||||
return;
|
||||
}
|
||||
|
||||
const safeSession = sanitizeSegment(session, 'default');
|
||||
const safeFilename = sanitizeFilename(item.filename, `download-${item.id}.bin`);
|
||||
const filename = `${DOWNLOAD_ARCHIVE_ROOT}/${safeSession}/${safeFilename}`;
|
||||
|
||||
recordDownloadEvent({
|
||||
id: item.id,
|
||||
tabId: item.tabId,
|
||||
session,
|
||||
state: 'routing',
|
||||
filename,
|
||||
});
|
||||
|
||||
suggest({
|
||||
filename,
|
||||
conflictAction: 'uniquify',
|
||||
});
|
||||
});
|
||||
|
||||
chrome.downloads.onChanged.addListener((delta) => {
|
||||
if (!delta || typeof delta.id !== 'number') return;
|
||||
|
||||
const state = delta.state?.current;
|
||||
if (!state) return;
|
||||
|
||||
recordDownloadEvent({
|
||||
id: delta.id,
|
||||
state,
|
||||
filename: delta.filename?.current,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--item-bg: rgba(255, 255, 255, 0.03);
|
||||
--item-hover: rgba(255, 255, 255, 0.06);
|
||||
--border: #30363d;
|
||||
--border-focus: #484f58;
|
||||
--text-main: #e6edf3;
|
||||
--text-muted: #848d97;
|
||||
--accent: #2f81f7;
|
||||
--accent-soft: rgba(47, 129, 247, 0.1);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-focus); }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-main);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--item-hover);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
#refresh-btn {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#refresh-btn:hover {
|
||||
background: #4493f8;
|
||||
border-color: #4493f8;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 12px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-title h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border: 1px solid transparent;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px; /* Divider effect */
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: var(--panel);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--item-hover);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.item-url {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
background: rgba(110, 118, 129, 0.4);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#summary code {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Summary Grid */
|
||||
#summary .tags {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#summary .tag {
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>agent-browser-stealth panel</title>
|
||||
<link rel="stylesheet" href="sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>agent-browser-stealth</h1>
|
||||
<div class="actions">
|
||||
<button id="refresh-btn" type="button">Refresh</button>
|
||||
<button id="cleanup-btn" type="button">Clean Empty Groups</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="summary" class="card"></section>
|
||||
<section id="sessions" class="stack"></section>
|
||||
<section id="downloads" class="card"></section>
|
||||
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,246 @@
|
||||
const summaryEl = document.getElementById('summary');
|
||||
const sessionsEl = document.getElementById('sessions');
|
||||
const downloadsEl = document.getElementById('downloads');
|
||||
const refreshBtn = document.getElementById('refresh-btn');
|
||||
const cleanupBtn = document.getElementById('cleanup-btn');
|
||||
|
||||
async function send(message) {
|
||||
return chrome.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
function createTag(text) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tag';
|
||||
span.textContent = text;
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderSummary(state) {
|
||||
summaryEl.innerHTML = '<h3>Overview</h3>';
|
||||
const options = state.options || {};
|
||||
|
||||
const idInfo = document.createElement('div');
|
||||
idInfo.style.marginBottom = '12px';
|
||||
idInfo.style.fontSize = '11px';
|
||||
idInfo.style.color = 'var(--text-muted)';
|
||||
idInfo.innerHTML = `Extension ID: <code>${state.extensionId}</code>`;
|
||||
summaryEl.appendChild(idInfo);
|
||||
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'tags';
|
||||
tags.appendChild(createTag(`Sessions: ${state.totals.sessions}`));
|
||||
tags.appendChild(createTag(`Tabs: ${state.totals.tabs}`));
|
||||
tags.appendChild(
|
||||
createTag(`Isolation: ${options.strictWindowIsolation === false ? 'Off' : 'On'}`)
|
||||
);
|
||||
tags.appendChild(
|
||||
createTag(`Guard: ${options.suppressCrossWindowActivation === false ? 'Off' : 'On'}`)
|
||||
);
|
||||
tags.appendChild(
|
||||
createTag(`Auto-Clean: ${options.autoCleanEmptyGroups === false ? 'Off' : 'On'}`)
|
||||
);
|
||||
|
||||
const optionActions = document.createElement('div');
|
||||
optionActions.className = 'row-actions';
|
||||
|
||||
const createOptionBtn = (text, active, onClick) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = text;
|
||||
if (active) btn.style.borderColor = 'var(--accent)';
|
||||
btn.addEventListener('click', onClick);
|
||||
return btn;
|
||||
};
|
||||
|
||||
optionActions.appendChild(
|
||||
createOptionBtn('Strict Isolation', options.strictWindowIsolation !== false, async () => {
|
||||
await send({
|
||||
type: 'AB_PANEL_SET_OPTIONS',
|
||||
options: { ...options, strictWindowIsolation: options.strictWindowIsolation === false },
|
||||
});
|
||||
await refresh();
|
||||
})
|
||||
);
|
||||
|
||||
optionActions.appendChild(
|
||||
createOptionBtn('Activation Guard', options.suppressCrossWindowActivation !== false, async () => {
|
||||
await send({
|
||||
type: 'AB_PANEL_SET_OPTIONS',
|
||||
options: {
|
||||
...options,
|
||||
suppressCrossWindowActivation: options.suppressCrossWindowActivation === false,
|
||||
},
|
||||
});
|
||||
await refresh();
|
||||
})
|
||||
);
|
||||
|
||||
optionActions.appendChild(
|
||||
createOptionBtn('Auto-Clean', options.autoCleanEmptyGroups !== false, async () => {
|
||||
await send({
|
||||
type: 'AB_PANEL_SET_OPTIONS',
|
||||
options: {
|
||||
...options,
|
||||
autoCleanEmptyGroups: options.autoCleanEmptyGroups === false,
|
||||
},
|
||||
});
|
||||
await refresh();
|
||||
})
|
||||
);
|
||||
|
||||
summaryEl.appendChild(tags);
|
||||
summaryEl.appendChild(optionActions);
|
||||
}
|
||||
|
||||
function renderSessions(state) {
|
||||
sessionsEl.innerHTML = '<h3>Active Sessions</h3>';
|
||||
|
||||
if (!state.sessions || state.sessions.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card empty';
|
||||
empty.textContent = 'No active sessions monitored.';
|
||||
sessionsEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const session of state.sessions) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card';
|
||||
|
||||
const titleRow = document.createElement('div');
|
||||
titleRow.className = 'session-title';
|
||||
|
||||
const titleLeft = document.createElement('h4');
|
||||
titleLeft.textContent = session.session;
|
||||
|
||||
const focusBtn = document.createElement('button');
|
||||
focusBtn.textContent = 'Focus';
|
||||
focusBtn.addEventListener('click', async () => {
|
||||
await send({ type: 'AB_PANEL_FOCUS_SESSION', session: session.session });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
titleRow.appendChild(titleLeft);
|
||||
titleRow.appendChild(focusBtn);
|
||||
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'tags';
|
||||
tags.appendChild(createTag(`Window: ${session.windowId ?? 'N/A'}`));
|
||||
tags.appendChild(createTag(`Tabs: ${session.tabs.length}`));
|
||||
|
||||
if (session.group) {
|
||||
tags.appendChild(createTag(`G: ${session.group.title || 'Untitled'}`));
|
||||
}
|
||||
|
||||
if (session.allowedDomains && session.allowedDomains.length > 0) {
|
||||
tags.appendChild(createTag(`Allowlist: ${session.allowedDomains.length} domains`));
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'list';
|
||||
for (const tab of session.tabs.slice(0, 10)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item';
|
||||
|
||||
const t = document.createElement('div');
|
||||
t.className = 'item-title';
|
||||
if (tab.active) {
|
||||
const dot = document.createElement('span');
|
||||
dot.textContent = '●';
|
||||
dot.style.color = 'var(--success)';
|
||||
dot.style.marginRight = '6px';
|
||||
dot.style.fontSize = '10px';
|
||||
t.appendChild(dot);
|
||||
}
|
||||
t.appendChild(document.createTextNode(tab.title || '(Untitled)'));
|
||||
|
||||
const u = document.createElement('div');
|
||||
u.className = 'item-url';
|
||||
u.textContent = tab.url || 'about:blank';
|
||||
|
||||
item.appendChild(t);
|
||||
item.appendChild(u);
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
const footerActions = document.createElement('div');
|
||||
footerActions.className = 'row-actions';
|
||||
|
||||
const keepBtn = document.createElement('button');
|
||||
keepBtn.textContent = 'Isolate Session';
|
||||
keepBtn.addEventListener('click', async () => {
|
||||
await send({ type: 'AB_PANEL_CLOSE_OTHER_SESSION_TABS', session: session.session });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
const policyBtn = document.createElement('button');
|
||||
policyBtn.textContent = 'Config Policy';
|
||||
policyBtn.addEventListener('click', async () => {
|
||||
const current = (session.allowedDomains || []).join(',');
|
||||
const input = window.prompt('Allowed domains (comma-separated)', current);
|
||||
if (input === null) return;
|
||||
const allowedDomains = input
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item) => item.length > 0);
|
||||
await send({ type: 'AB_PANEL_SET_POLICY', session: session.session, allowedDomains });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
footerActions.appendChild(keepBtn);
|
||||
footerActions.appendChild(policyBtn);
|
||||
|
||||
card.appendChild(titleRow);
|
||||
card.appendChild(tags);
|
||||
card.appendChild(list);
|
||||
card.appendChild(footerActions);
|
||||
sessionsEl.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDownloads(state) {
|
||||
downloadsEl.innerHTML = '<h3>Recent Downloads</h3>';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'list';
|
||||
|
||||
const entries = state.downloads || [];
|
||||
if (entries.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = 'No download events yet.';
|
||||
list.appendChild(empty);
|
||||
} else {
|
||||
for (const entry of entries.slice(0, 8)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item';
|
||||
item.innerHTML = `<div class="item-title">#${entry.id} · ${entry.state || 'updated'}</div><div class="item-url">${entry.filename || ''}</div>`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
downloadsEl.appendChild(list);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const response = await send({ type: 'AB_PANEL_GET_STATE' });
|
||||
if (!response || response.ok !== true || !response.state) {
|
||||
summaryEl.textContent = response?.error || 'Failed to load extension state.';
|
||||
sessionsEl.innerHTML = '';
|
||||
downloadsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
renderSummary(response.state);
|
||||
renderSessions(response.state);
|
||||
renderDownloads(response.state);
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener('click', refresh);
|
||||
cleanupBtn.addEventListener('click', async () => {
|
||||
await send({ type: 'AB_PANEL_CLEAN_EMPTY_GROUPS' });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
+13
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.14.0-fork.4",
|
||||
"version": "0.16.1-fork.3",
|
||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
@@ -12,20 +12,22 @@
|
||||
],
|
||||
"bin": {
|
||||
"agent-browser-stealth": "./bin/agent-browser.js",
|
||||
"agent-browser": "./bin/agent-browser.js"
|
||||
"agent-browser": "./bin/agent-browser.js",
|
||||
"abs": "./bin/agent-browser.js"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||
"native:clean": "cargo clean --manifest-path cli/Cargo.toml -p agent-browser-stealth",
|
||||
"build": "tsc",
|
||||
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:native": "npm run version:sync && npm run native:clean && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:macos": "npm run version:sync && npm run native:clean && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish",
|
||||
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm run verify:bundled-binaries && npm run verify:native-version && npm publish",
|
||||
"start": "node dist/daemon.js",
|
||||
"dev": "tsx src/daemon.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -34,13 +36,18 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
|
||||
"check:daemon-pid-recovery": "node scripts/check-daemon-pid-recovery.js",
|
||||
"check:stealth-regression": "node scripts/check-stealth-regression.js",
|
||||
"check:turnstile-testkey": "pnpm exec tsx scripts/check-turnstile-testkey.ts",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"verify:native-version": "node scripts/verify-native-version.js",
|
||||
"verify:bundled-binaries": "node scripts/verify-bundled-binaries.js",
|
||||
"clawhub:sync": "bash scripts/clawhub-sync.sh",
|
||||
"sync:upstream": "bash scripts/sync-upstream.sh",
|
||||
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
|
||||
"changeset": "changeset",
|
||||
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
|
||||
"ci:publish": "pnpm run version:sync && pnpm run build && changeset publish"
|
||||
"ci:publish": "pnpm run version:sync && pnpm run build && pnpm run build:native && pnpm run verify:bundled-binaries && pnpm run verify:native-version && changeset publish"
|
||||
},
|
||||
"keywords": [
|
||||
"browser",
|
||||
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Regression check for daemon liveness when <session>.pid is missing.
|
||||
*
|
||||
* What it verifies:
|
||||
* 1) A daemon session is reachable.
|
||||
* 2) Deleting <session>.pid does not break the next command.
|
||||
* 3) The session socket is not recreated (inode unchanged on Unix),
|
||||
* meaning we reused the live daemon instead of tearing it down.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-daemon-pid-recovery.js
|
||||
* node scripts/check-daemon-pid-recovery.js --session default
|
||||
* node scripts/check-daemon-pid-recovery.js --binary ./bin/agent-browser-darwin-arm64
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path, { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getArgValue = (name, fallback) => {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1 || index + 1 >= args.length) return fallback;
|
||||
return args[index + 1];
|
||||
};
|
||||
|
||||
function resolveSocketDir() {
|
||||
if (process.env.AGENT_BROWSER_SOCKET_DIR && process.env.AGENT_BROWSER_SOCKET_DIR.length > 0) {
|
||||
return process.env.AGENT_BROWSER_SOCKET_DIR;
|
||||
}
|
||||
if (process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0) {
|
||||
return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser');
|
||||
}
|
||||
return path.join(os.homedir(), '.agent-browser');
|
||||
}
|
||||
|
||||
function resolveDefaultBinary() {
|
||||
const osKey = os.platform() === 'win32' ? 'win32' : os.platform() === 'darwin' ? 'darwin' : 'linux';
|
||||
const archKey = os.arch() === 'arm64' ? 'arm64' : 'x64';
|
||||
const ext = os.platform() === 'win32' ? '.exe' : '';
|
||||
|
||||
const candidates = [
|
||||
join(rootDir, 'bin', `agent-browser-${osKey}-${archKey}${ext}`),
|
||||
join(rootDir, 'cli', 'target', 'release', `agent-browser${ext}`),
|
||||
join(rootDir, 'bin', `agent-browser-local${ext}`),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function runCommand(binary, commandArgs, allowFailure = false) {
|
||||
const result = spawnSync(binary, commandArgs, {
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
const stderr = (result.stderr || '').trim();
|
||||
const stdout = (result.stdout || '').trim();
|
||||
throw new Error(
|
||||
`Command failed: ${binary} ${commandArgs.join(' ')}\n${stderr || stdout || `exit code ${result.status}`}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const session = getArgValue('--session', 'default');
|
||||
const binary = getArgValue('--binary', resolveDefaultBinary());
|
||||
const socketDir = resolveSocketDir();
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
const pidPath = join(socketDir, `${session}.pid`);
|
||||
const socketPath = isWindows ? null : join(socketDir, `${session}.sock`);
|
||||
const portPath = isWindows ? join(socketDir, `${session}.port`) : null;
|
||||
|
||||
if (!fs.existsSync(binary)) {
|
||||
throw new Error(`Binary not found: ${binary}`);
|
||||
}
|
||||
|
||||
// Ensure daemon/session is live before we simulate pid loss.
|
||||
runCommand(binary, ['--session', session, 'get', 'url']);
|
||||
|
||||
let socketInodeBefore = null;
|
||||
if (!isWindows) {
|
||||
if (!socketPath || !fs.existsSync(socketPath)) {
|
||||
throw new Error(`Socket file not found: ${socketPath}`);
|
||||
}
|
||||
socketInodeBefore = fs.statSync(socketPath).ino;
|
||||
}
|
||||
|
||||
const pidExistedBefore = fs.existsSync(pidPath);
|
||||
if (pidExistedBefore) {
|
||||
fs.unlinkSync(pidPath);
|
||||
}
|
||||
|
||||
// This is the critical step: should still work even though pid file is gone.
|
||||
const second = runCommand(binary, ['--session', session, 'get', 'title']);
|
||||
const secondOutput = (second.stdout || '').trim();
|
||||
|
||||
let socketInodeAfter = null;
|
||||
let socketUnchanged = true;
|
||||
if (!isWindows) {
|
||||
if (!socketPath || !fs.existsSync(socketPath)) {
|
||||
throw new Error(`Socket file missing after pid removal: ${socketPath}`);
|
||||
}
|
||||
socketInodeAfter = fs.statSync(socketPath).ino;
|
||||
socketUnchanged = socketInodeBefore === socketInodeAfter;
|
||||
} else if (portPath && !fs.existsSync(portPath)) {
|
||||
throw new Error(`Port file missing after pid removal: ${portPath}`);
|
||||
}
|
||||
|
||||
const pidExistsAfter = fs.existsSync(pidPath);
|
||||
const passed = socketUnchanged;
|
||||
|
||||
const report = {
|
||||
passed,
|
||||
session,
|
||||
binary,
|
||||
socketDir,
|
||||
pidPath,
|
||||
pidExistedBefore,
|
||||
pidExistsAfter,
|
||||
socketPath,
|
||||
socketInodeBefore,
|
||||
socketInodeAfter,
|
||||
socketUnchanged,
|
||||
secondCommandOutput: secondOutput,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!passed) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* End-to-end stealth regression check across key anti-bot targets.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-stealth-regression.js
|
||||
* node scripts/check-stealth-regression.js --binary ./cli/target/release/agent-browser
|
||||
* node scripts/check-stealth-regression.js --session-name stealth-regression
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getArgValue = (name, fallback) => {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1 || index + 1 >= args.length) return fallback;
|
||||
return args[index + 1];
|
||||
};
|
||||
|
||||
const sessionName = getArgValue('--session-name', 'stealth-regression');
|
||||
const screenshotDir = getArgValue('--screenshot-dir', join('/tmp', 'agent-browser-stealth-regression'));
|
||||
const binaryArg = getArgValue('--binary', '');
|
||||
|
||||
const candidates = [
|
||||
binaryArg,
|
||||
join(rootDir, 'cli', 'target', 'release', 'agent-browser'),
|
||||
join(rootDir, 'bin', 'agent-browser.js'),
|
||||
'agent-browser-stealth',
|
||||
'agent-browser',
|
||||
].filter(Boolean);
|
||||
|
||||
function tryResolveBinary() {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.includes('/') && !existsSync(candidate)) continue;
|
||||
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
|
||||
if (probe.status === 0) return candidate;
|
||||
}
|
||||
throw new Error(
|
||||
`Unable to find a runnable agent-browser binary. Tried: ${candidates.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const binary = tryResolveBinary();
|
||||
const sessionArgs = ['--session', sessionName, '--session-name', sessionName];
|
||||
|
||||
function runBinary(commandArgs, { allowFailure = false } = {}) {
|
||||
const result = spawnSync(binary, commandArgs, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
const stderr = (result.stderr || '').trim();
|
||||
const stdout = (result.stdout || '').trim();
|
||||
throw new Error(
|
||||
`Command failed: ${binary} ${commandArgs.join(' ')}\n` +
|
||||
`${stderr || stdout || `exit code ${result.status}`}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runJson(actionArgs, options = {}) {
|
||||
const result = runBinary([...sessionArgs, '--json', ...actionArgs], options);
|
||||
const output = (result.stdout || '').trim();
|
||||
if (!output) return null;
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
throw new Error(`Expected JSON output, got:\n${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
const genericRiskScript = `(() => {
|
||||
const lowerTitle = String(document.title || '').toLowerCase();
|
||||
const lowerBody = String(document.body?.innerText || '').toLowerCase();
|
||||
const hasCloudflare =
|
||||
lowerTitle.includes('just a moment') ||
|
||||
lowerTitle.includes('performing security verification') ||
|
||||
lowerBody.includes('performing security verification') ||
|
||||
lowerBody.includes('checking your browser') ||
|
||||
lowerBody.includes('cloudflare');
|
||||
const hasCaptcha =
|
||||
lowerBody.includes('captcha') ||
|
||||
lowerBody.includes('recaptcha') ||
|
||||
lowerBody.includes('hcaptcha') ||
|
||||
lowerBody.includes('turnstile');
|
||||
return {
|
||||
title: document.title || '',
|
||||
url: location.href,
|
||||
hasCloudflare,
|
||||
hasCaptcha,
|
||||
hasTurnstile:
|
||||
!!document.querySelector('.cf-turnstile, iframe[src*="challenges.cloudflare.com"], [name="cf-turnstile-response"]'),
|
||||
bodySample: lowerBody.slice(0, 600),
|
||||
};
|
||||
})()`;
|
||||
|
||||
const sannysoftScript = `(() => {
|
||||
const normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
|
||||
const rows = Array.from(document.querySelectorAll('tr'));
|
||||
const failed = rows.filter((row) => /failed|fail/i.test(normalize(row.innerText)));
|
||||
return {
|
||||
failedCount: failed.length,
|
||||
failedRows: failed.map((row) => normalize(row.innerText)),
|
||||
navigatorWebdriver: navigator.webdriver,
|
||||
navigatorVendor: navigator.vendor,
|
||||
};
|
||||
})()`;
|
||||
|
||||
function sanitizeFileSegment(input) {
|
||||
return input.replace(/[^a-zA-Z0-9._-]+/g, '-');
|
||||
}
|
||||
|
||||
function main() {
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
const timestamp = new Date().toISOString();
|
||||
const targets = [
|
||||
'https://bot.sannysoft.com/',
|
||||
'https://chatgpt.com/',
|
||||
'https://super86.cc/login',
|
||||
];
|
||||
|
||||
runBinary([...sessionArgs, 'close'], { allowFailure: true });
|
||||
|
||||
const report = {
|
||||
binary,
|
||||
sessionName,
|
||||
timestamp,
|
||||
screenshotDir,
|
||||
doctor: null,
|
||||
targets: [],
|
||||
ok: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const doctorResp = runJson(['doctor']);
|
||||
report.doctor = doctorResp?.data ?? null;
|
||||
|
||||
for (const target of targets) {
|
||||
const entry = {
|
||||
target,
|
||||
open: null,
|
||||
risk: null,
|
||||
sannysoft: null,
|
||||
screenshot: null,
|
||||
ok: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const openResp = runJson(['open', target]);
|
||||
entry.open = openResp?.data ?? null;
|
||||
|
||||
runJson(['wait', '3000'], { allowFailure: true });
|
||||
|
||||
const riskResp = runJson(['eval', genericRiskScript]);
|
||||
entry.risk = riskResp?.data?.result ?? null;
|
||||
|
||||
if (target.includes('bot.sannysoft.com')) {
|
||||
const sannysoftResp = runJson(['eval', sannysoftScript]);
|
||||
entry.sannysoft = sannysoftResp?.data?.result ?? null;
|
||||
if ((entry.sannysoft?.failedCount ?? 1) > 0) {
|
||||
entry.ok = false;
|
||||
}
|
||||
} else if (entry.risk?.hasCloudflare || entry.risk?.hasCaptcha) {
|
||||
entry.ok = false;
|
||||
}
|
||||
|
||||
const host = sanitizeFileSegment(new URL(target).host);
|
||||
const shotPath = join(
|
||||
screenshotDir,
|
||||
`${host}-${Date.now().toString(36)}.png`
|
||||
);
|
||||
runJson(['screenshot', '--full', shotPath], { allowFailure: true });
|
||||
entry.screenshot = shotPath;
|
||||
} catch (error) {
|
||||
entry.ok = false;
|
||||
entry.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
if (!entry.ok) report.ok = false;
|
||||
report.targets.push(entry);
|
||||
}
|
||||
} finally {
|
||||
runBinary([...sessionArgs, 'close'], { allowFailure: true });
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Deterministic Turnstile check using Cloudflare official testing sitekey.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm run check:turnstile-testkey
|
||||
* pnpm run check:turnstile-testkey -- --headed
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { BrowserManager } from '../src/browser.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const headed = args.includes('--headed');
|
||||
const waitMsRaw = args.includes('--wait-ms')
|
||||
? args[args.indexOf('--wait-ms') + 1]
|
||||
: undefined;
|
||||
const waitMs = Number.isFinite(Number(waitMsRaw)) ? Number(waitMsRaw) : 9000;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>agent-browser turnstile testkey</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Turnstile Testkey Probe</h1>
|
||||
<div class="cf-turnstile" data-sitekey="1x00000000000000000000AA" data-callback="onTurnstileToken"></div>
|
||||
<script>
|
||||
window.__turnstileToken = '';
|
||||
function onTurnstileToken(token) {
|
||||
window.__turnstileToken = token || '';
|
||||
}
|
||||
</script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function createServer(): Promise<http.Server> {
|
||||
const server = http.createServer((_, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(html);
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const server = await createServer();
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
await closeServer(server);
|
||||
throw new Error('Unable to start local HTTP server for Turnstile probe');
|
||||
}
|
||||
|
||||
const localUrl = `http://127.0.0.1:${address.port}/`;
|
||||
const browser = new BrowserManager();
|
||||
|
||||
try {
|
||||
await browser.launch({
|
||||
id: 'turnstile-testkey',
|
||||
action: 'launch',
|
||||
browser: 'chromium',
|
||||
stealth: true,
|
||||
headless: !headed,
|
||||
});
|
||||
|
||||
const page = browser.getPage();
|
||||
await page.goto(localUrl, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const hidden = document.querySelector('input[name="cf-turnstile-response"]') as
|
||||
| HTMLInputElement
|
||||
| null;
|
||||
const hiddenValue = hidden?.value || '';
|
||||
const callbackValue =
|
||||
typeof (window as any).__turnstileToken === 'string'
|
||||
? (window as any).__turnstileToken
|
||||
: '';
|
||||
const token = hiddenValue || callbackValue || '';
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title || '',
|
||||
tokenLength: token.length,
|
||||
tokenSample: token.slice(0, 40),
|
||||
isDummyToken: token.includes('DUMMY'),
|
||||
hiddenFieldLength: hiddenValue.length,
|
||||
callbackLength: callbackValue.length,
|
||||
widgetCount: document.querySelectorAll('.cf-turnstile').length,
|
||||
};
|
||||
});
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
headed,
|
||||
waitMs,
|
||||
ok: result.isDummyToken,
|
||||
result,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(result.isDummyToken ? 0 : 1);
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
await closeServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
+21
-2
@@ -161,7 +161,19 @@ function showPlaywrightReminder() {
|
||||
* Fix npm's bin entry on global installs to use the native binary directly.
|
||||
* This provides zero-overhead CLI execution for global installs.
|
||||
*/
|
||||
function isPnpmGlobalInstall() {
|
||||
const ua = process.env.npm_config_user_agent || '';
|
||||
return ua.includes('pnpm/');
|
||||
}
|
||||
|
||||
async function fixGlobalInstallBin() {
|
||||
// pnpm already manages global shims in its own bin dir.
|
||||
// Rewriting links via `npm prefix -g` can create stale links in unrelated paths
|
||||
// (e.g. /opt/homebrew/bin), which then shadow pnpm's up-to-date shims.
|
||||
if (isPnpmGlobalInstall()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform() === 'win32') {
|
||||
await fixWindowsShims();
|
||||
} else {
|
||||
@@ -220,17 +232,24 @@ async function fixUnixSymlink() {
|
||||
* We overwrite them to invoke the native .exe directly.
|
||||
*/
|
||||
async function fixWindowsShims() {
|
||||
// Check if this is a global install by looking for npm's global prefix
|
||||
let npmBinDir;
|
||||
try {
|
||||
npmBinDir = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return; // Not a global install or npm not available
|
||||
return;
|
||||
}
|
||||
|
||||
// Path to native binary relative to npm prefix
|
||||
const packagePath = packageName.replace(/\//g, '\\');
|
||||
const relativeBinaryPath = `node_modules\\${packagePath}\\bin\\${binaryName}`;
|
||||
const absoluteBinaryPath = join(npmBinDir, relativeBinaryPath);
|
||||
|
||||
// npm may create shims after lifecycle scripts, and binary may be absent
|
||||
// when running with JS fallback; skip rewriting in those cases.
|
||||
if (!existsSync(absoluteBinaryPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let optimized = false;
|
||||
|
||||
for (const commandName of binCommands) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Verifies that all bundled platform binaries are present and embed the
|
||||
* package.json version string. This catches stale binary bundles where the
|
||||
* package version is bumped but one or more binaries were not rebuilt.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8"));
|
||||
const expectedVersion = String(pkg.version || "").trim();
|
||||
|
||||
if (!expectedVersion) {
|
||||
console.error("Error: package.json version is empty");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const expectedBinaries = [
|
||||
"agent-browser-linux-x64",
|
||||
"agent-browser-linux-arm64",
|
||||
"agent-browser-win32-x64.exe",
|
||||
"agent-browser-darwin-x64",
|
||||
"agent-browser-darwin-arm64",
|
||||
];
|
||||
|
||||
const minSizeBytes = 100_000;
|
||||
const versionBytes = Buffer.from(expectedVersion, "utf8");
|
||||
|
||||
let errors = 0;
|
||||
|
||||
for (const name of expectedBinaries) {
|
||||
const binaryPath = join(rootDir, "bin", name);
|
||||
if (!existsSync(binaryPath)) {
|
||||
console.error(`ERROR: missing binary: bin/${name}`);
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const size = statSync(binaryPath).size;
|
||||
if (size < minSizeBytes) {
|
||||
console.error(
|
||||
`ERROR: binary too small: bin/${name} (${size} bytes, expected >= ${minSizeBytes})`
|
||||
);
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = readFileSync(binaryPath);
|
||||
if (!bytes.includes(versionBytes)) {
|
||||
console.error(
|
||||
`ERROR: stale binary version: bin/${name} does not contain "${expectedVersion}"`
|
||||
);
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`OK: bin/${name} matches version ${expectedVersion}`);
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
console.error(`\nFound ${errors} binary validation issue(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nAll bundled binaries match package.json version ${expectedVersion}.`);
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Verifies that the bundled native binary version matches package.json version.
|
||||
* This prevents publishing npm tarballs where package version and native binary
|
||||
* version drift (e.g. package is fork.8 but binary still reports fork.7).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { arch, platform } from 'os';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = join(__dirname, '..');
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
|
||||
const expectedVersion = pkg.version;
|
||||
|
||||
const ext = platform() === 'win32' ? '.exe' : '';
|
||||
const platformBinary = join(projectRoot, 'bin', `agent-browser-${platform()}-${arch()}${ext}`);
|
||||
|
||||
if (!existsSync(platformBinary)) {
|
||||
console.error(`Error: native binary not found for current platform: ${platformBinary}`);
|
||||
console.error('Run `pnpm run build:native` before publishing.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let versionOutput = '';
|
||||
try {
|
||||
versionOutput = execFileSync(platformBinary, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Error: failed to execute native binary --version: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!versionOutput.includes(expectedVersion)) {
|
||||
console.error(`Version mismatch: package.json=${expectedVersion}, native='${versionOutput}'.`);
|
||||
console.error('Run `pnpm run build:native` and retry publishing.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ Native binary version matches package.json (${expectedVersion})`);
|
||||
@@ -18,12 +18,12 @@ Use this skill when the task needs web automation and anti-bot stability.
|
||||
## Install and baseline
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser-stealth
|
||||
pnpm add -g agent-browser-stealth
|
||||
agent-browser install
|
||||
agent-browser --version
|
||||
```
|
||||
|
||||
If default CDP mode is used in your environment, ensure a browser is available at `localhost:9333`, or pass `--cdp` / `--auto-connect` explicitly.
|
||||
If default CDP mode is used in your environment, the CLI first tries `localhost:9333` and then auto-discovery. You can still pass `--cdp` / `--auto-connect` explicitly when needed.
|
||||
|
||||
## Standard execution workflow
|
||||
|
||||
|
||||
+181
-20
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: agent-browser
|
||||
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
|
||||
allowed-tools: Bash(npx agent-browser-stealth:*), Bash(npx agent-browser:*), Bash(agent-browser:*)
|
||||
allowed-tools: Bash(npx agent-browser-stealth:*), Bash(npx agent-browser:*), Bash(agent-browser:*), Bash(abs:*)
|
||||
---
|
||||
|
||||
# Browser Automation with agent-browser
|
||||
|
||||
Install package: `npm install -g agent-browser-stealth` (CLI command remains `agent-browser` for compatibility).
|
||||
Install package: `pnpm add -g agent-browser-stealth` (CLI commands: `agent-browser`, `agent-browser-stealth`, and short alias `abs`). If global install is unavailable in your environment, use `pnpm dlx agent-browser-stealth <command>` for one-off runs.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
@@ -51,6 +51,8 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser --risk-mode block open <url> # Block if verification/captcha interstitial is detected
|
||||
agent-browser doctor # Diagnose CDP + sourceURL + tab-group plugin health
|
||||
agent-browser close # Close browser
|
||||
agent-browser --version # Show CLI version (fork builds include upstream/fork)
|
||||
|
||||
@@ -88,6 +90,7 @@ agent-browser wait 2000-5000 # Random wait between 2-5 seconds
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
agent-browser --download-path ./downloads open <url> # Set default download directory
|
||||
agent-browser --tab-group "My Agent Group" open <url> # Override default tab-group base title
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
@@ -119,6 +122,22 @@ agent-browser click @e5
|
||||
agent-browser wait --load networkidle
|
||||
```
|
||||
|
||||
### Authentication with Auth Vault (Recommended)
|
||||
|
||||
```bash
|
||||
# Save credentials once (encrypted with AGENT_BROWSER_ENCRYPTION_KEY)
|
||||
# Recommended: pipe password via stdin to avoid shell history exposure
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
|
||||
# Login using saved profile (LLM never sees password)
|
||||
agent-browser auth login github
|
||||
|
||||
# List/show/delete profiles
|
||||
agent-browser auth list
|
||||
agent-browser auth show github
|
||||
agent-browser auth delete github
|
||||
```
|
||||
|
||||
### Authentication with State Persistence
|
||||
|
||||
```bash
|
||||
@@ -136,10 +155,25 @@ agent-browser state load auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
### Cookie Injection for Auth Callbacks
|
||||
|
||||
```bash
|
||||
# Before navigation: set by URL
|
||||
agent-browser cookies set session_id "abc123" --url https://app.example.com/api/auth/sso/callback
|
||||
|
||||
# Explicit domain/path pair (must be provided together)
|
||||
agent-browser cookies set auth_token "xyz789" --domain .example.com --path /api
|
||||
|
||||
# Or navigate first and rely on current URL
|
||||
agent-browser open https://app.example.com/api/auth/sso/callback
|
||||
agent-browser cookies set callback_token "token123"
|
||||
```
|
||||
|
||||
### Session Persistence
|
||||
|
||||
```bash
|
||||
# Auto-save/restore cookies and localStorage across browser restarts
|
||||
# If --session-name is omitted, it defaults to --session (or "default")
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
# ... login flow ...
|
||||
agent-browser close # State auto-saved to ~/.agent-browser/sessions/
|
||||
@@ -185,7 +219,11 @@ agent-browser session list
|
||||
|
||||
### Connect to Existing Chrome
|
||||
|
||||
By default in this fork, commands without `--cdp` require an existing browser at `localhost:9333`. If CDP is unavailable, the command fails fast (no automatic local browser launch).
|
||||
By default in this fork, commands without `--cdp` auto-attach to your existing browser with this order:
|
||||
|
||||
1. Try CDP at `localhost:9333`
|
||||
2. If unavailable, fall back to `--auto-connect`-style discovery
|
||||
3. If both fail, exit with guidance (no automatic managed local browser launch on this path)
|
||||
|
||||
```bash
|
||||
# Auto-discover running Chrome with remote debugging enabled
|
||||
@@ -194,6 +232,18 @@ agent-browser --auto-connect snapshot
|
||||
|
||||
# Or with explicit CDP port
|
||||
agent-browser --cdp 9222 snapshot
|
||||
|
||||
# Debug auto-attach behavior
|
||||
agent-browser --debug snapshot
|
||||
|
||||
# Diagnose CDP + sourceURL + plugin handshake status
|
||||
agent-browser doctor
|
||||
|
||||
# Avoid `open` timeout on challenge-heavy pages
|
||||
agent-browser --wait-until domcontentloaded open https://example.com
|
||||
|
||||
# Deterministic Turnstile smoke check (official test key)
|
||||
pnpm run check:turnstile-testkey
|
||||
```
|
||||
|
||||
### Color Scheme (Dark Mode)
|
||||
@@ -209,6 +259,37 @@ AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com
|
||||
agent-browser set media dark
|
||||
```
|
||||
|
||||
### Tab Grouping
|
||||
|
||||
```bash
|
||||
# CDP mode groups tabs when tab-group extension is installed
|
||||
agent-browser open https://example.com
|
||||
|
||||
# Override the default group title
|
||||
agent-browser --tab-group "My Agent Group" open https://example.com
|
||||
|
||||
# Or via environment variable
|
||||
AGENT_BROWSER_TAB_GROUP="My Agent Group" agent-browser open https://example.com
|
||||
|
||||
# Override expected extension ID if needed
|
||||
AGENT_BROWSER_TAB_GROUP_PLUGIN_ID="<extension-id>" agent-browser open https://example.com
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Works in CDP mode via extension handshake.
|
||||
- Extension package name in Chrome: `agent-browser-stealth`.
|
||||
- Extension installed and reachable: tabs are grouped by session.
|
||||
- Extension missing/unavailable: silent no-op (no warning/error unless debug mode).
|
||||
- Default titles:
|
||||
- `default` session: `Agent Browser Stealth`
|
||||
- non-default session: `Agent Browser Stealth • <session>`
|
||||
- Additional extension-side capabilities:
|
||||
- Session window isolation + deterministic group colors.
|
||||
- Side panel controls: Focus / Keep Only This / Clean Empty Groups + isolation/auto-clean toggles.
|
||||
- Session allowlist policy editing and fallback blocking (`about:blank`).
|
||||
- Download auto-routing to `agent-browser-stealth/<session>/...`.
|
||||
|
||||
### Visual Browser (Debugging)
|
||||
|
||||
```bash
|
||||
@@ -232,7 +313,7 @@ agent-browser screenshot output.png
|
||||
|
||||
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
|
||||
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
|
||||
- Use existing browser sessions (default CDP `localhost:9333`) or pass `--cdp` explicitly
|
||||
- Use existing browser sessions (default attach path: CDP `localhost:9333` then auto-discovery) or pass `--cdp` explicitly
|
||||
|
||||
### Stealth Mode (Always On)
|
||||
|
||||
@@ -240,7 +321,7 @@ Stealth is always active -- no flags needed. All sessions automatically apply an
|
||||
|
||||
Chromium launches in managed mode use Chrome channel by default for a genuine browser binary fingerprint.
|
||||
|
||||
For best results against strong bot detection, use `--headed` and `--session-name`.
|
||||
For best results against strong bot detection, use `--headed` and keep one stable `--session-name`.
|
||||
|
||||
### Auto Region Detection
|
||||
|
||||
@@ -250,7 +331,19 @@ Override: `AGENT_BROWSER_LOCALE`, `AGENT_BROWSER_TIMEZONE` env vars.
|
||||
|
||||
### Captcha Detection & Auto-Retry
|
||||
|
||||
When a navigation lands on a captcha/verification page, the browser automatically retries up to 2 times with randomized backoff (3-7s). If detection persists, a warning is shown suggesting `--headed` mode or `--session-name` persistence.
|
||||
When a navigation lands on a captcha/verification page, behavior is controlled by `--risk-mode` (or `AGENT_BROWSER_RISK_MODE`):
|
||||
|
||||
- `warn` (default): wait for auto-clear first, then retry up to 2 times with randomized backoff (3-7s), then return warning plus structured `riskSignals`
|
||||
- `block`: fail fast once a risk interstitial is detected
|
||||
- `off`: disable this detection/retry path
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
agent-browser --risk-mode warn open https://example.com
|
||||
agent-browser --risk-mode block open https://example.com
|
||||
AGENT_BROWSER_RISK_MODE=off agent-browser open https://example.com
|
||||
```
|
||||
|
||||
### iOS Simulator (Mobile Safari)
|
||||
|
||||
@@ -278,6 +371,57 @@ agent-browser -p ios close
|
||||
|
||||
**Real devices:** Works with physical iOS devices if pre-configured. Use `--device "<UDID>"` where UDID is from `xcrun xctrace list devices`.
|
||||
|
||||
## Security
|
||||
|
||||
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output.
|
||||
|
||||
### Content Boundaries (Recommended for AI Agents)
|
||||
|
||||
Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
|
||||
agent-browser snapshot
|
||||
# Output:
|
||||
# --- AGENT_BROWSER_PAGE_CONTENT nonce=<hex> origin=https://example.com ---
|
||||
# [accessibility tree]
|
||||
# --- END_AGENT_BROWSER_PAGE_CONTENT nonce=<hex> ---
|
||||
```
|
||||
|
||||
### Domain Allowlist
|
||||
|
||||
Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
|
||||
agent-browser open https://example.com # OK
|
||||
agent-browser open https://malicious.com # Blocked
|
||||
```
|
||||
|
||||
### Action Policy
|
||||
|
||||
Use a policy file to gate destructive actions:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_ACTION_POLICY=./policy.json
|
||||
```
|
||||
|
||||
Example `policy.json`:
|
||||
|
||||
```json
|
||||
{ "default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"] }
|
||||
```
|
||||
|
||||
Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies.
|
||||
|
||||
### Output Limits
|
||||
|
||||
Prevent context flooding from large pages:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_MAX_OUTPUT=50000
|
||||
```
|
||||
|
||||
## Diffing (Verifying Changes)
|
||||
|
||||
Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session.
|
||||
@@ -338,7 +482,7 @@ agent-browser automatically humanizes interactions to avoid behavioral detection
|
||||
- **Random wait ranges**: `wait 2000-5000` pauses for a random duration in that range
|
||||
- **Bezier curve mouse**: Before every `click`, the mouse moves along a natural-looking curve
|
||||
|
||||
These behaviors are always active. For sensitive sites, combine with `--headed` and `--session-name` for best results.
|
||||
These behaviors are always active. For sensitive sites, combine with `--headed` and a stable `--session-name` for best results.
|
||||
|
||||
## Session Management and Cleanup
|
||||
|
||||
@@ -390,6 +534,7 @@ agent-browser click @e2 # Click using ref from annotated screenshot
|
||||
```
|
||||
|
||||
Use annotated screenshots when:
|
||||
|
||||
- The page has unlabeled icon buttons or visual-only elements
|
||||
- You need to verify visual layout or styling
|
||||
- Canvas or chart elements are present (invisible to text snapshots)
|
||||
@@ -432,6 +577,7 @@ agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map
|
||||
**Why this matters:** When the shell processes your command, inner double quotes, `!` characters (history expansion), backticks, and `$()` can all corrupt the JavaScript before it reaches agent-browser. The `--stdin` and `-b` flags bypass shell interpretation entirely.
|
||||
|
||||
**Rules of thumb:**
|
||||
|
||||
- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine
|
||||
- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'`
|
||||
- Programmatic/generated scripts -> use `eval -b` with base64
|
||||
@@ -451,23 +597,38 @@ Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.
|
||||
|
||||
## Deep-Dive Documentation
|
||||
|
||||
| Reference | When to Use |
|
||||
|-----------|-------------|
|
||||
| [references/commands.md](references/commands.md) | Full command reference with all options |
|
||||
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
|
||||
| Reference | When to Use |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| [references/commands.md](references/commands.md) | Full command reference with all options |
|
||||
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
|
||||
| [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping |
|
||||
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
|
||||
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
|
||||
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
|
||||
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
|
||||
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
|
||||
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
|
||||
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
|
||||
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
|
||||
|
||||
## Experimental: Native Mode
|
||||
|
||||
agent-browser has an experimental native Rust daemon that communicates with Chrome directly via CDP, bypassing Node.js and Playwright entirely. It is opt-in and not recommended for production use yet.
|
||||
|
||||
```bash
|
||||
# Enable via flag
|
||||
agent-browser --native open example.com
|
||||
|
||||
# Enable via environment variable (avoids passing --native every time)
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session.
|
||||
|
||||
## Ready-to-Use Templates
|
||||
|
||||
| Template | Description |
|
||||
|----------|-------------|
|
||||
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
|
||||
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
|
||||
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
|
||||
| Template | Description |
|
||||
| ------------------------------------------------------------------------ | ----------------------------------- |
|
||||
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
|
||||
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
|
||||
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
|
||||
|
||||
```bash
|
||||
./templates/form-automation.sh https://example.com/form
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
# Purpose: Login once, save state, reuse for subsequent runs
|
||||
# Usage: ./authenticated-session.sh <login-url> [state-file]
|
||||
#
|
||||
# RECOMMENDED: Use the auth vault instead of this template:
|
||||
# echo "<pass>" | agent-browser auth save myapp --url <login-url> --username <user> --password-stdin
|
||||
# agent-browser auth login myapp
|
||||
# The auth vault stores credentials securely and the LLM never sees passwords.
|
||||
#
|
||||
# Environment variables:
|
||||
# APP_USERNAME - Login username/email
|
||||
# APP_PASSWORD - Login password
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user