Compare commits
269
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caad12de81 | ||
|
|
51c7ee0e2c | ||
|
|
193467fdb7 | ||
|
|
a821006cc0 | ||
|
|
4a55e3407b | ||
|
|
4d57c3e69d | ||
|
|
0a36666587 | ||
|
|
2a44b515ee | ||
|
|
13ed01b3bd | ||
|
|
8561a755ef | ||
|
|
89595836c6 | ||
|
|
7b4b124e7f | ||
|
|
b2b6356d63 | ||
|
|
e6ba1eb8c9 | ||
|
|
1f4b6b9d7a | ||
|
|
6c93480d0d | ||
|
|
cc9da7aff7 | ||
|
|
3c942e2874 | ||
|
|
40fdb4284d | ||
|
|
f8bc8b368a | ||
|
|
fbcab375b0 | ||
|
|
8d78fcbbb3 | ||
|
|
312db04e5e | ||
|
|
369f48752a | ||
|
|
6dd53449e8 | ||
|
|
da7fef3fef | ||
|
|
43d9c40bc4 | ||
|
|
dc26ff7667 | ||
|
|
747a3772e1 | ||
|
|
bbad2de627 | ||
|
|
0f0f300d40 | ||
|
|
db215a1467 | ||
|
|
a95bc0f75a | ||
|
|
995a47fdb0 | ||
|
|
f9174513c2 | ||
|
|
63f03b8e06 | ||
|
|
699a461646 | ||
|
|
89a8ceccf7 | ||
|
|
67b5ee1600 | ||
|
|
eb64ca497a | ||
|
|
8c6fc35450 | ||
|
|
7d2cd726ec | ||
|
|
5ac01fa743 | ||
|
|
2fb766fc78 | ||
|
|
0865851293 | ||
|
|
a0981979ca | ||
|
|
cd1f255129 | ||
|
|
23a117c5c2 | ||
|
|
32ffd8f3c4 | ||
|
|
780edb2c45 | ||
|
|
3150acd574 | ||
|
|
b5b84051e4 | ||
|
|
859c8aaf94 | ||
|
|
3a3317b048 | ||
|
|
f806b666ba | ||
|
|
a7a59c94f3 | ||
|
|
ce1f1f5f81 | ||
|
|
be30bc902d | ||
|
|
1391f00404 | ||
|
|
c5020f2b89 | ||
|
|
9b1961af93 | ||
|
|
ceaee00952 | ||
|
|
5c5c0d8081 | ||
|
|
b48c3e9dd2 | ||
|
|
9c0955ca99 | ||
|
|
d374e413be | ||
|
|
fb1e860b4e | ||
|
|
84c6e6fd30 | ||
|
|
7e5baa6d77 | ||
|
|
59b6e5a034 | ||
|
|
39e54113e6 | ||
|
|
aed466b347 | ||
|
|
8d14ecb92b | ||
|
|
6daad22ada | ||
|
|
9837b9c1aa | ||
|
|
af800f8403 | ||
|
|
421f8fab82 | ||
|
|
2b3c5c26cd | ||
|
|
2d37967b7e | ||
|
|
06b3b94493 | ||
|
|
0749ad667e | ||
|
|
fa563b59b9 | ||
|
|
757626f27c | ||
|
|
755fa50400 | ||
|
|
fdc09c95f4 | ||
|
|
486e1b341f | ||
|
|
5b5dffe0a2 | ||
|
|
4be4605543 | ||
|
|
d03f6431ea | ||
|
|
1e7619d59d | ||
|
|
8cfba1752d | ||
|
|
b8a5fc7101 | ||
|
|
8fc1d000f7 | ||
|
|
c6de80b95e | ||
|
|
1cd90078b4 | ||
|
|
f8eb38c7f1 | ||
|
|
60f3afcf61 | ||
|
|
f51e955d99 | ||
|
|
59ea02cc8e | ||
|
|
8dd012f4fd | ||
|
|
663e10355a | ||
|
|
7734bb2702 | ||
|
|
a865dd56e0 | ||
|
|
9a8d9f439b | ||
|
|
664789f5c6 | ||
|
|
c0d4cf6a93 | ||
|
|
48a265057b | ||
|
|
0705b4ddac | ||
|
|
811e66f99e | ||
|
|
42c4c56c9a | ||
|
|
0883813cd3 | ||
|
|
8163f6cdca | ||
|
|
eda956b754 | ||
|
|
d866ee2022 | ||
|
|
ebfabe0e62 | ||
|
|
5fa239676b | ||
|
|
285eab46df | ||
|
|
5b74604a5c | ||
|
|
4b5fc78f71 | ||
|
|
c092ffd82b | ||
|
|
8ac7fe916e | ||
|
|
1fd8e9d09a | ||
|
|
a3d966244e | ||
|
|
8348b77800 | ||
|
|
609f32c986 | ||
|
|
79f464d47a | ||
|
|
51d9ab4d49 | ||
|
|
6636ac0e74 | ||
|
|
bc94eaf94f | ||
|
|
77a27fe6a3 | ||
|
|
daf7263385 | ||
|
|
0745db44a2 | ||
|
|
19ba9048a0 | ||
|
|
e78cc05cec | ||
|
|
25a152652a | ||
|
|
5ea508a917 | ||
|
|
02d1a7ad7c | ||
|
|
c23ce448bd | ||
|
|
fa91c22e50 | ||
|
|
c07f43e242 | ||
|
|
5f8e993602 | ||
|
|
fc091d294d | ||
|
|
c4f0f22ae9 | ||
|
|
23a8b19de1 | ||
|
|
e2ebde261c | ||
|
|
06842ea7c5 | ||
|
|
a773863214 | ||
|
|
f962c7a4c4 | ||
|
|
17d2785ca0 | ||
|
|
d1ba208a50 | ||
|
|
e365909d4f | ||
|
|
d4b9004a6d | ||
|
|
529b8acfbe | ||
|
|
fe3c1ddb48 | ||
|
|
944fa01247 | ||
|
|
da45d00b2a | ||
|
|
fbdae9b6ae | ||
|
|
388f19e1bb | ||
|
|
c4a40033e0 | ||
|
|
1fa7949542 | ||
|
|
974d735af5 | ||
|
|
bd05917f20 | ||
|
|
44ce24fb48 | ||
|
|
67986adffc | ||
|
|
790123f9af | ||
|
|
99c732c188 | ||
|
|
c7ad5ff66a | ||
|
|
f8482f3533 | ||
|
|
bdfcc4ee2d | ||
|
|
235fa88dc6 | ||
|
|
8e43469c8b | ||
|
|
d4b948c1d4 | ||
|
|
d40fd4009d | ||
|
|
056024b447 | ||
|
|
3a2e2796c8 | ||
|
|
f426860c04 | ||
|
|
ea9d456341 | ||
|
|
e02bc1a10c | ||
|
|
56bb92bfe1 | ||
|
|
087600e50e | ||
|
|
a673a77c4e | ||
|
|
1129a3e7fc | ||
|
|
640d259130 | ||
|
|
1327856889 | ||
|
|
a3dcf3fe60 | ||
|
|
870876b5c1 | ||
|
|
b9a24df40f | ||
|
|
fb7185d860 | ||
|
|
a66c5960f2 | ||
|
|
d019c09bbc | ||
|
|
d4f7fbc718 | ||
|
|
2fb2a51c82 | ||
|
|
c562ef5bb7 | ||
|
|
01172eaa44 | ||
|
|
942b8cd8ee | ||
|
|
315d191606 | ||
|
|
f2d4089284 | ||
|
|
cc82dd137c | ||
|
|
18c3112dbd | ||
|
|
89f9c97ac2 | ||
|
|
db3d23d496 | ||
|
|
78c9aef3c9 | ||
|
|
417428463b | ||
|
|
def2fd90fa | ||
|
|
d678058206 | ||
|
|
c2794232ee | ||
|
|
f6c83e410b | ||
|
|
9f41545216 | ||
|
|
82386b1c60 | ||
|
|
c309535691 | ||
|
|
d0651f14bc | ||
|
|
5bf9fedd58 | ||
|
|
c0a525c9e4 | ||
|
|
cc3c70dc86 | ||
|
|
3649787268 | ||
|
|
00a0e0707e | ||
|
|
94cd888ecb | ||
|
|
644a4f5b63 | ||
|
|
a0bd0c2f0f | ||
|
|
2bab729f26 | ||
|
|
94521e7a8c | ||
|
|
d9387aae58 | ||
|
|
aba2353112 | ||
|
|
68cebe5192 | ||
|
|
f262ff1bf3 | ||
|
|
788ad0e61f | ||
|
|
b7e7a2548e | ||
|
|
492830accb | ||
|
|
7acde7e29a | ||
|
|
0da54c7038 | ||
|
|
36c2e06f89 | ||
|
|
8f6ad817f1 | ||
|
|
de5ea1d8cf | ||
|
|
139dd0ec5a | ||
|
|
794a77e26e | ||
|
|
7d2c8957ac | ||
|
|
eaa968e229 | ||
|
|
7edc5d596c | ||
|
|
01ac5574d4 | ||
|
|
e5fd26eb9e | ||
|
|
a493d02c66 | ||
|
|
c4180c8cb1 | ||
|
|
56260f68b0 | ||
|
|
324a9e4e0c | ||
|
|
7f42eed031 | ||
|
|
c10981413f | ||
|
|
05018b309a | ||
|
|
9d0454d229 | ||
|
|
51f5fa484c | ||
|
|
857c0b25df | ||
|
|
62241b50e9 | ||
|
|
c6a33b6338 | ||
|
|
d97e2016f5 | ||
|
|
6aea316c82 | ||
|
|
c7fa10cb1b | ||
|
|
b304a4188c | ||
|
|
e912f541f2 | ||
|
|
7238b7da4c | ||
|
|
79d8dfe34c | ||
|
|
14ec5b5ffa | ||
|
|
79b05877a8 | ||
|
|
7bd8ce937b | ||
|
|
b455a58aa2 | ||
|
|
b59dc4c82c | ||
|
|
2e38882664 | ||
|
|
bc1e917e87 | ||
|
|
c0e2b80f8c | ||
|
|
f319195974 | ||
|
|
77f2caa1bc |
@@ -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)
|
||||
@@ -1,23 +0,0 @@
|
||||
# Changesets
|
||||
|
||||
This project uses [Changesets](https://github.com/changesets/changesets) for versioning and changelog generation.
|
||||
|
||||
## Adding a changeset
|
||||
|
||||
When you make a change that should be released, run:
|
||||
|
||||
```bash
|
||||
pnpm changeset
|
||||
```
|
||||
|
||||
This will prompt you to:
|
||||
1. Select the type of change (patch, minor, major)
|
||||
2. Write a summary of your changes
|
||||
|
||||
The changeset file will be committed with your PR.
|
||||
|
||||
## Release process
|
||||
|
||||
When changesets are merged to `main`, the release workflow will:
|
||||
1. Create a "Version Packages" PR that updates version numbers and changelogs
|
||||
2. When that PR is merged, packages are automatically published to npm
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "agent-browser",
|
||||
"description": "Headless browser automation for AI agents",
|
||||
"description": "Browser automation for AI agents",
|
||||
"owner": {
|
||||
"name": "Vercel",
|
||||
"email": "support@vercel.com"
|
||||
|
||||
+70
-137
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
version-sync:
|
||||
@@ -17,51 +18,39 @@ jobs:
|
||||
- name: Check version sync
|
||||
run: node scripts/check-version-sync.js
|
||||
|
||||
typescript:
|
||||
name: TypeScript (Node ${{ matrix.node-version }})
|
||||
rust:
|
||||
name: Rust
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
version: 9
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
workspaces: cli
|
||||
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
run: cargo fmt --manifest-path cli/Cargo.toml -- --check
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Clippy check
|
||||
run: cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
|
||||
rust:
|
||||
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
|
||||
@@ -78,72 +67,61 @@ 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
|
||||
native-e2e:
|
||||
name: Native E2E Tests
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: rust
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
workspaces: cli
|
||||
|
||||
- name: Install Chrome
|
||||
run: |
|
||||
cargo run --manifest-path cli/Cargo.toml -- install --with-deps
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1
|
||||
|
||||
windows-integration:
|
||||
name: Windows Integration Test
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest
|
||||
needs: rust-cross
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
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
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build
|
||||
|
||||
- name: Copy CLI binary to bin directory
|
||||
run: |
|
||||
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
|
||||
@@ -161,53 +139,28 @@ jobs:
|
||||
shell: pwsh
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Verify Chromium was installed
|
||||
- name: Test daemon lifecycle (open, snapshot, close)
|
||||
run: |
|
||||
$playwrightPath = "$env:LOCALAPPDATA\ms-playwright"
|
||||
if (Test-Path $playwrightPath) {
|
||||
Write-Host "Playwright browsers installed at: $playwrightPath"
|
||||
Get-ChildItem $playwrightPath -Recurse -Depth 2 | Select-Object -First 20
|
||||
} else {
|
||||
Write-Error "Playwright browsers not found!"
|
||||
exit 1
|
||||
}
|
||||
$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
|
||||
|
||||
serverless-chromium:
|
||||
name: Serverless Chromium (@sparticuz/chromium)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install @sparticuz/chromium
|
||||
run: pnpm add -D @sparticuz/chromium
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build
|
||||
|
||||
- name: Run serverless integration test
|
||||
run: pnpm exec vitest run test/serverless.test.ts
|
||||
timeout-minutes: 5
|
||||
|
||||
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:
|
||||
@@ -225,44 +178,24 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
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 }}
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build
|
||||
|
||||
- name: Copy CLI binary to bin directory (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: cp cli/target/${{ matrix.target }}/release/agent-browser bin/${{ matrix.binary }}
|
||||
@@ -289,7 +222,7 @@ jobs:
|
||||
echo "ERROR: Symlink should point to native binary, not JS wrapper"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Symlink correctly points to native binary"
|
||||
echo "Symlink correctly points to native binary"
|
||||
shell: bash
|
||||
|
||||
- name: Verify shim points to native binary (Windows)
|
||||
@@ -304,5 +237,5 @@ jobs:
|
||||
echo "ERROR: Shim should point to native .exe, not JS wrapper"
|
||||
exit 1
|
||||
}
|
||||
echo "✓ Shim correctly points to native binary"
|
||||
echo "Shim correctly points to native binary"
|
||||
shell: pwsh
|
||||
|
||||
@@ -10,13 +10,40 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
# Build native binaries for all platforms first
|
||||
check-release:
|
||||
name: Check for new version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_release: ${{ steps.check.outputs.should_release }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compare package.json version to npm
|
||||
id: check
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Local version: $LOCAL_VERSION"
|
||||
|
||||
NPM_VERSION=$(npm view agent-browser version 2>/dev/null || echo "0.0.0")
|
||||
echo "npm version: $NPM_VERSION"
|
||||
|
||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
||||
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version unchanged, skipping release"
|
||||
echo "should_release=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: check-release
|
||||
if: needs.check-release.outputs.should_release == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -32,6 +59,16 @@ jobs:
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-arm64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-x64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl ARM64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-arm64
|
||||
use_zigbuild: true
|
||||
- name: Windows x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
@@ -98,18 +135,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
|
||||
@@ -136,19 +165,13 @@ jobs:
|
||||
path: artifacts/${{ matrix.binary }}
|
||||
retention-days: 7
|
||||
|
||||
# Create release PR or publish to npm (with binaries)
|
||||
release:
|
||||
name: Release
|
||||
needs: build-binaries
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [check-release, build-binaries]
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -162,7 +185,7 @@ jobs:
|
||||
cache: pnpm
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install Dependencies
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download all binary artifacts
|
||||
@@ -184,11 +207,13 @@ jobs:
|
||||
EXPECTED_BINARIES=(
|
||||
"agent-browser-linux-x64"
|
||||
"agent-browser-linux-arm64"
|
||||
"agent-browser-linux-musl-x64"
|
||||
"agent-browser-linux-musl-arm64"
|
||||
"agent-browser-win32-x64.exe"
|
||||
"agent-browser-darwin-x64"
|
||||
"agent-browser-darwin-arm64"
|
||||
)
|
||||
MIN_SIZE=100000 # Binaries should be at least 100KB
|
||||
MIN_SIZE=100000
|
||||
ERRORS=0
|
||||
for binary in "${EXPECTED_BINARIES[@]}"; do
|
||||
if [ ! -f "bin/$binary" ]; then
|
||||
@@ -208,30 +233,20 @@ jobs:
|
||||
echo "Error: $ERRORS binary issues found"
|
||||
exit 1
|
||||
fi
|
||||
echo "All 5 platform binaries present and valid"
|
||||
echo "All 7 platform binaries present and valid"
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm ci:version
|
||||
publish: pnpm ci:publish
|
||||
title: 'chore: version packages'
|
||||
commit: 'chore: version packages'
|
||||
- name: Publish to npm
|
||||
run: pnpm publish --no-git-checks
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
|
||||
|
||||
# Create GitHub release with binaries after npm publish
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
needs: release
|
||||
if: needs.release.outputs.published == 'true'
|
||||
needs: [check-release, publish]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -249,28 +264,59 @@ jobs:
|
||||
- name: Verify binaries exist
|
||||
run: |
|
||||
BINARY_COUNT=$(ls bin/agent-browser-* 2>/dev/null | wc -l)
|
||||
if [ "$BINARY_COUNT" -lt 5 ]; then
|
||||
echo "Error: Expected 5 binaries, found $BINARY_COUNT"
|
||||
if [ "$BINARY_COUNT" -lt 7 ]; then
|
||||
echo "Error: Expected 7 binaries, found $BINARY_COUNT"
|
||||
ls -la bin/
|
||||
exit 1
|
||||
fi
|
||||
echo "Found $BINARY_COUNT binaries"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build dashboard
|
||||
run: pnpm --filter dashboard build
|
||||
|
||||
- name: Create dashboard.zip
|
||||
run: cd packages/dashboard/out && zip -r ../../../dashboard.zip .
|
||||
|
||||
- name: Extract changelog entry
|
||||
run: |
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
LINES=$(wc -l < /tmp/release-notes.md | tr -d ' ')
|
||||
if [ "$LINES" -lt 2 ]; then
|
||||
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted release notes for $VERSION ($LINES lines)"
|
||||
|
||||
- name: Create GitHub Release
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
TAG="v$VERSION"
|
||||
|
||||
# Check if release already exists
|
||||
|
||||
if gh release view "$TAG" &>/dev/null; then
|
||||
echo "Release $TAG already exists, uploading binaries..."
|
||||
gh release upload "$TAG" bin/agent-browser-* --clobber
|
||||
echo "Release $TAG already exists, uploading assets..."
|
||||
gh release upload "$TAG" bin/agent-browser-* dashboard.zip --clobber
|
||||
else
|
||||
echo "Creating release $TAG..."
|
||||
gh release create "$TAG" \
|
||||
--title "$TAG" \
|
||||
--generate-notes \
|
||||
bin/agent-browser-*
|
||||
--notes-file /tmp/release-notes.md \
|
||||
bin/agent-browser-* dashboard.zip
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+13
@@ -6,6 +6,7 @@ dist/
|
||||
|
||||
# Native binaries (keep the launcher scripts)
|
||||
bin/agent-browser-*
|
||||
bin/.install-method
|
||||
!bin/agent-browser
|
||||
!bin/agent-browser.cmd
|
||||
|
||||
@@ -27,10 +28,15 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
# Test artifacts
|
||||
*.png
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.webm
|
||||
test/e2e/.dogfood-output/
|
||||
|
||||
# Package manager
|
||||
package-lock.json
|
||||
@@ -40,6 +46,9 @@ yarn.lock
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Windows debug instance config
|
||||
scripts/windows-debug/.instance
|
||||
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
|
||||
@@ -51,3 +60,7 @@ docs/package-lock.json
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
# next
|
||||
.next/
|
||||
out/
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pnpm lint-staged
|
||||
node scripts/sync-version.js
|
||||
git add cli/Cargo.toml cli/Cargo.lock
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
if [ "${SKIP_CLAWHUB_SYNC:-0}" = "1" ]; then
|
||||
echo "Skipping ClawHub sync (SKIP_CLAWHUB_SYNC=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pnpm run clawhub:sync || {
|
||||
echo "ClawHub sync failed. Push continues. Run 'pnpm run clawhub:sync' manually after fixing login/network."
|
||||
}
|
||||
@@ -9,6 +9,7 @@ This project uses **pnpm**. Always use `pnpm` instead of `npm` or `yarn` for ins
|
||||
## Code Style
|
||||
|
||||
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable.
|
||||
- In documentation and markdown, never use double hyphens (`--`) as a dash. Use an emdash (—) sparingly when needed. Prefer rewriting the sentence to avoid dashes entirely.
|
||||
- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes.
|
||||
- CLI flags must always use kebab-case (e.g., `--auto-connect`, `--allow-file-access`). Never use camelCase for flags (e.g., `--autoConnect` is wrong).
|
||||
|
||||
@@ -16,16 +17,174 @@ This project uses **pnpm**. Always use `pnpm` instead of `npm` or `yarn` for ins
|
||||
|
||||
When adding or changing user-facing features (new flags, commands, behaviors, environment variables, etc.), update **all** of the following:
|
||||
|
||||
1. `cli/src/output.rs` -- `--help` output (flags list, examples, environment variables)
|
||||
2. `README.md` -- Options table, relevant feature sections, examples
|
||||
3. `skills/agent-browser/SKILL.md` -- so AI agents know about the feature
|
||||
4. `docs/src/app/` -- the Next.js docs site (MDX pages)
|
||||
1. `cli/src/output.rs` — `--help` output (flags list, examples, environment variables)
|
||||
2. `README.md` — Options table, relevant feature sections, examples
|
||||
3. `skills/agent-browser/SKILL.md` — so AI agents know about the feature
|
||||
4. `docs/src/app/` — the Next.js docs site (MDX pages)
|
||||
5. Inline doc comments in the relevant source files
|
||||
|
||||
This applies to changes that either human users or AI agents would need to know about. Do not skip any of these locations.
|
||||
|
||||
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.
|
||||
|
||||
## Dashboard (packages/dashboard)
|
||||
|
||||
- Never use native browser dialogs (`alert`, `confirm`, `prompt`). Use shadcn/ui components (`Dialog`, `AlertDialog`, etc.) instead.
|
||||
- Use param-case (kebab-case) for all file and folder names (e.g., `session-tree.tsx`, not `SessionTree.tsx`). The `ui/` directory follows shadcn conventions which already uses param-case.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are manual, single-PR affairs. There is no changesets automation. The maintainer controls the changelog voice and format.
|
||||
|
||||
To prepare a release:
|
||||
|
||||
1. Create a branch (e.g. `prepare-v0.24.0`)
|
||||
2. Bump `version` in `package.json`
|
||||
3. Run `pnpm version:sync` to update `cli/Cargo.toml`, `cli/Cargo.lock`, and `packages/dashboard/package.json`
|
||||
4. Write the changelog entry in `CHANGELOG.md` at the top, under a new `## <version>` heading, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
|
||||
5. Add a matching entry to `docs/src/app/changelog/page.mdx` at the top (below the `# Changelog` heading)
|
||||
6. Open a PR and merge to `main`
|
||||
|
||||
When the PR merges, CI compares `package.json` version to what's on npm. If it differs, it builds all 7 platform binaries, publishes to npm, and creates the GitHub release automatically. The GitHub release body is extracted from the content between the `<!-- release:start -->` and `<!-- release:end -->` markers in `CHANGELOG.md`.
|
||||
|
||||
### Writing the changelog
|
||||
|
||||
Review the git log since the last release and write the entry in `CHANGELOG.md`. Follow the existing format and voice. Group changes under `### New Features`, `### Bug Fixes`, `### Improvements`, etc. Bold the feature/fix name, then describe it concisely. Reference PR numbers in parentheses.
|
||||
|
||||
Wrap the release notes (everything between the `## <version>` heading and the previous version) in markers so CI can extract them for the GitHub release:
|
||||
|
||||
```markdown
|
||||
## 0.24.0
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **Foo command** - Added `foo` command for bar (#1234)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **baz** not working when qux is enabled (#1235)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @somecontributor
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.23.3
|
||||
```
|
||||
|
||||
Include a `### Contributors` section listing the GitHub usernames (with `@` prefix) of everyone who contributed to the release. Check the git log between the previous tag and HEAD to find them.
|
||||
|
||||
Do not prefix entries with commit hashes. Do not use the changesets `### Patch Changes` / `### Minor Changes` headings. Use descriptive section names instead.
|
||||
|
||||
### Docs changelog
|
||||
|
||||
The docs changelog at `docs/src/app/changelog/page.mdx` mirrors `CHANGELOG.md` but uses a slightly different format. Each entry uses:
|
||||
|
||||
- A `v` prefix on the version (e.g. `## v0.24.0`)
|
||||
- A date line with the full date: `<p className="text-[#888] text-sm">March 30, 2026</p>`
|
||||
- A `---` separator between entries
|
||||
|
||||
Match the existing style in that file.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Windows Debugging
|
||||
|
||||
A remote Windows Server 2022 EC2 instance is available for debugging Windows-specific issues. It uses AWS Systems Manager (SSM) with no SSH or open ports. Commands run via `aws ssm send-command` and return stdout/stderr.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The instance must be provisioned first (one-time, by a human):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/provision.sh
|
||||
```
|
||||
|
||||
Requires: AWS CLI v2 configured with `ec2:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `ssm:SendCommand`, `ssm:GetCommandInvocation` permissions and a default VPC.
|
||||
|
||||
### Usage
|
||||
|
||||
Start the instance (if stopped):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/start.sh
|
||||
```
|
||||
|
||||
Run a command on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "<powershell-command>"
|
||||
```
|
||||
|
||||
Sync the current git branch and rebuild:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/sync.sh
|
||||
```
|
||||
|
||||
Stop the instance when done (avoids cost):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/stop.sh
|
||||
```
|
||||
|
||||
### Common Workflows
|
||||
|
||||
Run unit tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test --manifest-path cli\Cargo.toml"
|
||||
```
|
||||
|
||||
Run e2e tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test e2e --manifest-path cli\Cargo.toml -- --ignored --test-threads=1"
|
||||
```
|
||||
|
||||
Check bootstrap progress (first boot only):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "Get-Content C:\bootstrap.log"
|
||||
```
|
||||
|
||||
The repo lives at `C:\agent-browser` on the instance. Rust, Git, and Chrome are pre-installed. The `run.sh` wrapper automatically adds cargo and git to PATH.
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
## Source Code Reference
|
||||
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
# agent-browser
|
||||
|
||||
## 0.14.0-fork.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix CLI typing delay parsing so `--delay` is treated as an option instead of typed text.
|
||||
- Add `--delay <ms>` parsing for `type` and `keyboard type`
|
||||
- Support `--` to type literal `--delay` text
|
||||
- Add regression tests for parsing and delay behavior
|
||||
- Update CLI help, README, skills, and docs command references
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b7665e5: - Added `keyboard` command for raw keyboard input -- type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector.
|
||||
- Added `--color-scheme` flag and `AGENT_BROWSER_COLOR_SCHEME` env var for persistent dark/light mode preference across browser sessions.
|
||||
- Fixed IPC EAGAIN errors (os error 35/11) by adding backpressure-aware socket writes, command serialization, and lowering the 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.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ebd8717: Added new diff commands for comparing snapshots, screenshots, and URLs between page states. You can now 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.
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 69ffad0: Add annotated screenshots with the new --annotate flag, which overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. This enables multimodal AI models to reason about visual layout while using the same @eN refs for subsequent interactions. The flag can also be set via the AGENT_BROWSER_ANNOTATE environment variable.
|
||||
|
||||
## 0.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c6fc7df: Added documentation for command chaining with && across README, CLI help output, docs, and skill files, explaining how to efficiently chain multiple agent-browser commands in a single shell invocation since the browser persists via a background daemon.
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5dc40b4: Added configuration file support with automatic loading from user and project directories, new profiler commands for Chrome DevTools profiling, computed styles getter, browser extension loading, storage state management, and iOS device emulation. Expanded click command with new-tab option, improved find command with additional actions and filtering options, and enhanced CDP connection to accept WebSocket URLs. Documentation has been significantly expanded with new sections for configuration, profiling, and proxy support.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1112a16: Added session persistence with automatic save/restore of cookies and localStorage across browser restarts using --session-name flag, with optional AES-256-GCM encryption for saved state data. New state management commands allow listing, showing, renaming, clearing, and cleaning up old session files. Also added --new-tab option for click commands to open links in new tabs.
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 323b6cd: Fix all Clippy lint warnings in the Rust CLI: remove redundant import, use `.first()` instead of `.get(0)`, use `.copied()` instead of `.map(|s| *s)`, use `.contains()` instead of `.iter().any()`, use `then_some` instead of lazy `then`, and simplify redundant match guards.
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d03e238: Added support for custom executable path in CLI browser launch options. Documentation site received UI improvements including a new chat component with sheet-based interface and updated dependencies.
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 76d23db: Documentation site migrated to MDX for improved content authoring, added AI-powered docs chat feature, and updated README with Homebrew installation instructions for macOS users.
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ae34945: Added --allow-file-access flag to enable opening and interacting with local file:// URLs (PDFs, HTML files) by passing Chromium flags that allow JavaScript access to local files. Added -C/--cursor flag for snapshots to include cursor-interactive elements like divs with onclick handlers or cursor:pointer styles, which is useful for modern web apps using custom clickable elements.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9d021bd: Add iOS Simulator and real device support for mobile Safari testing via Appium. New CLI commands include `device list` to show available simulators, `tap` and `swipe` for touch interactions, and the `--device` flag to specify which iOS device to use. Configure with `-p ios` provider flag or `AGENT_BROWSER_PROVIDER=ios` environment variable.
|
||||
|
||||
## 0.8.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 17dba8f: Add --stdin flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
|
||||
- daeede4: Add --stdin flag for the eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts. Also fix binary permission issues on macOS/Linux when postinstall scripts don't run (e.g., with bun).
|
||||
|
||||
## 0.8.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0dc36f2: Add --stdin flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
|
||||
|
||||
## 0.8.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2771588: Added base64 encoding support for the eval command with -b/--base64 flag to avoid shell escaping issues when executing JavaScript. Updated documentation with AI agent setup instructions and reorganized the docs structure by consolidating agent mode content into the installation page.
|
||||
|
||||
## 0.8.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d24f753: Fixed browser launch options not being passed correctly when using persistent profiles, ensuring args, userAgent, proxy, and ignoreHTTPSErrors settings now work properly. Added pre-flight checks for socket path length limits and directory write permissions to provide clearer error messages when daemon startup fails. Improved error handling to properly exit with failure status when browser launch fails.
|
||||
|
||||
## 0.8.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d75350a: Improved daemon connection reliability by adding automatic retry logic for transient errors like connection resets, broken pipes, and temporary resource unavailability. The CLI now cleans up stale socket and PID files before starting a new daemon, and includes better detection of daemon responsiveness to handle race conditions during shutdown.
|
||||
|
||||
## 0.8.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cb2f8c3: Fixed version synchronization to automatically update Cargo.lock alongside Cargo.toml during releases, and made the CLI binary executable. This ensures the Rust CLI version stays in sync with the npm package version.
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 759302e: Fixed "Daemon not found" error when running through AI agents (e.g., Claude Code) by resolving symlinks in the executable path. Previously, npm global bin symlinks weren't being resolved correctly, causing intermittent daemon discovery failures.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4116a8a: Replaced shell-based CLI wrappers with a cross-platform Node.js wrapper to enable npx support on Windows. Added postinstall logic to patch npm's bin entry on global installs, allowing the native binary to be invoked directly with zero overhead. Added CI tests to verify global installation works correctly across all platforms.
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7e6336f: Fixed the Windows CMD wrapper to use the native binary directly instead of routing through Node.js, improving startup performance and reliability. Added retry logic to the CI install command to handle transient failures during browser installation.
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8eec634: Improved release workflow to validate binary file sizes and ensure binaries are executable after npm install. Updated documentation site with a new mobile navigation system and added v0.8.0 changelog entries. Reformatted CHANGELOG.md for better readability.
|
||||
|
||||
## v0.8.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Kernel cloud browser provider** - Connect to Kernel (https://kernel.sh) for remote browser infrastructure via `-p kernel` flag or `AGENT_BROWSER_PROVIDER=kernel`. Supports stealth mode, persistent profiles, and automatic profile find-or-create.
|
||||
- **Ignore HTTPS certificate errors** - New `--ignore-https-errors` flag for working with self-signed certificates and development environments
|
||||
- **Enhanced cookie management** - Extended `cookies set` command with `--url`, `--domain`, `--path`, `--httpOnly`, `--secure`, `--sameSite`, and `--expires` flags for setting cookies before page load
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed tab list command not recognizing new pages opened via clicks or `target="_blank"` links (#275)
|
||||
- Fixed `check` command hanging indefinitely (#272)
|
||||
- Fixed `set device` not applying deviceScaleFactor - HiDPI screenshots now work correctly (#270)
|
||||
- Fixed state load and profile persistence not working in v0.7.6 (#268)
|
||||
- Screenshots now save to temp directory when no path is provided (#247)
|
||||
|
||||
### Security
|
||||
|
||||
- Daemon and stream server now reject cross-origin connections (#274)
|
||||
|
||||
## 0.7.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a4d0c26: Allow null values for the screenshot selector field. Previously, passing a null selector would fail validation, but now it is properly handled as an optional value.
|
||||
|
||||
## 0.7.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8c2a6ec: Fix GitHub release workflow to handle existing releases. If a release already exists, binaries are uploaded to it instead of failing.
|
||||
|
||||
## 0.7.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 957b5e5: Fix binary permissions on install. npm doesn't preserve execute bits, so postinstall now ensures the native binary is executable.
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 161d8f5: Fix native binary distribution in npm package. Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now correctly included when publishing.
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6afede2: Fix native binary distribution in npm package
|
||||
|
||||
Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix native binary distribution in npm package. Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 316e649: ## New Features
|
||||
- **Cloud browser providers** - Connect to Browserbase or Browser Use for remote browser infrastructure via `-p` flag or `AGENT_BROWSER_PROVIDER` env var
|
||||
- **Persistent browser profiles** - Store cookies, localStorage, and login sessions across browser restarts with `--profile`
|
||||
- **Remote CDP WebSocket URLs** - Connect to remote browser services via WebSocket URL (e.g., `--cdp "wss://..."`)
|
||||
- **Download commands** - New `download` command and `wait --download` for file downloads with ref support
|
||||
- **Browser launch configuration** - New `--args`, `--user-agent`, and `--proxy-bypass` flags for fine-grained browser control
|
||||
- **Enhanced skills** - Hierarchical structure with references and templates for Claude Code
|
||||
|
||||
## Bug Fixes
|
||||
- Screenshot command now supports refs and has improved error messages
|
||||
- WebSocket URLs work in `connect` command
|
||||
- Fixed socket file location (uses `~/.agent-browser` instead of TMPDIR)
|
||||
- Windows binary path fix (.exe extension)
|
||||
- State load and path-based actions now show correct output messages
|
||||
|
||||
## Documentation
|
||||
- Added Claude Code marketplace plugin installation instructions
|
||||
- Updated skill documentation with references and templates
|
||||
- Improved error documentation
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
/Users/leo/github.com/agent-browser/cli/target/release/agent-browser: /Users/leo/github.com/agent-browser/cli/build.rs /Users/leo/github.com/agent-browser/cli/cdp-protocol/browser_protocol.json /Users/leo/github.com/agent-browser/cli/cdp-protocol/js_protocol.json /Users/leo/github.com/agent-browser/cli/src/color.rs /Users/leo/github.com/agent-browser/cli/src/commands.rs /Users/leo/github.com/agent-browser/cli/src/connection.rs /Users/leo/github.com/agent-browser/cli/src/flags.rs /Users/leo/github.com/agent-browser/cli/src/install.rs /Users/leo/github.com/agent-browser/cli/src/main.rs /Users/leo/github.com/agent-browser/cli/src/output.rs /Users/leo/github.com/agent-browser/cli/src/validation.rs
|
||||
+13
-2
@@ -8,7 +8,7 @@
|
||||
* binary directly (zero overhead).
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { existsSync, accessSync, chmodSync, constants } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -16,6 +16,17 @@ import { platform, arch } from 'os';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Detect if the system uses musl libc (e.g. Alpine Linux)
|
||||
function isMusl() {
|
||||
if (platform() !== 'linux') return false;
|
||||
try {
|
||||
const result = execSync('ldd --version 2>&1 || true', { encoding: 'utf8' });
|
||||
return result.toLowerCase().includes('musl');
|
||||
} catch {
|
||||
return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1');
|
||||
}
|
||||
}
|
||||
|
||||
// Map Node.js platform/arch to binary naming convention
|
||||
function getBinaryName() {
|
||||
const os = platform();
|
||||
@@ -27,7 +38,7 @@ function getBinaryName() {
|
||||
osKey = 'darwin';
|
||||
break;
|
||||
case 'linux':
|
||||
osKey = 'linux';
|
||||
osKey = isMusl() ? 'linux-musl' : 'linux';
|
||||
break;
|
||||
case 'win32':
|
||||
osKey = 'win32';
|
||||
|
||||
Generated
+2705
-9
File diff suppressed because it is too large
Load Diff
+36
-6
@@ -1,23 +1,44 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.14.0-fork.3"
|
||||
version = "0.24.0-fork.1"
|
||||
edition = "2021"
|
||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/leeguooooo/agent-browser-stealth"
|
||||
homepage = "https://github.com/leeguooooo/agent-browser-stealth"
|
||||
readme = "../README.md"
|
||||
keywords = ["browser", "automation", "ai", "cdp", "chrome"]
|
||||
categories = ["command-line-utilities", "web-programming"]
|
||||
|
||||
[[bin]]
|
||||
name = "agent-browser"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "agent-browser-stealth"
|
||||
path = "src/main_stealth.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex-lite = "0.1"
|
||||
dirs = "5.0"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
|
||||
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"
|
||||
socket2 = "0.6"
|
||||
similar = "2"
|
||||
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
chrono = "0.4"
|
||||
urlencoding = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -25,8 +46,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).is_some_and(|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)
|
||||
.is_some_and(|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().is_some_and(|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
+901
-195
File diff suppressed because it is too large
Load Diff
+239
-254
@@ -26,6 +26,8 @@ pub struct Response {
|
||||
pub success: bool,
|
||||
pub data: Option<Value>,
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -120,6 +122,8 @@ fn get_pid_path(session: &str) -> PathBuf {
|
||||
fn cleanup_stale_files(session: &str) {
|
||||
let pid_path = get_pid_path(session);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let stream_path = get_socket_dir().join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -140,7 +144,7 @@ fn get_port_path(session: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
pub fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i32 = 0;
|
||||
for c in session.chars() {
|
||||
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
|
||||
@@ -150,37 +154,19 @@ 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
|
||||
}
|
||||
|
||||
/// Read the actual daemon port from the `.port` file written by the daemon.
|
||||
/// Falls back to the hash-derived port if the file does not exist or is
|
||||
/// unreadable (e.g. daemon has not started yet).
|
||||
#[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()
|
||||
pub fn resolve_port(session: &str) -> u16 {
|
||||
let port_path = get_port_path(session);
|
||||
fs::read_to_string(&port_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u16>().ok())
|
||||
.unwrap_or_else(|| get_port_for_session(session))
|
||||
}
|
||||
|
||||
fn daemon_ready(session: &str) -> bool {
|
||||
pub fn daemon_ready(session: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
@@ -188,7 +174,7 @@ fn daemon_ready(session: &str) -> bool {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port = get_port_for_session(session);
|
||||
let port = resolve_port(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(50),
|
||||
@@ -203,26 +189,132 @@ pub struct DaemonResult {
|
||||
pub already_running: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn ensure_daemon(
|
||||
session: &str,
|
||||
headed: bool,
|
||||
executable_path: Option<&str>,
|
||||
extensions: &[String],
|
||||
args: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
proxy: Option<&str>,
|
||||
proxy_bypass: Option<&str>,
|
||||
ignore_https_errors: bool,
|
||||
allow_file_access: bool,
|
||||
state: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
device: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
debug: bool,
|
||||
) -> Result<DaemonResult, String> {
|
||||
// Check if daemon is running AND responsive
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
/// Options forwarded to the daemon process as environment variables.
|
||||
/// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side
|
||||
/// UX concern (prompting the user on stdin) and not a daemon configuration.
|
||||
/// The daemon only needs `confirm_actions` to gate action categories.
|
||||
pub struct DaemonOptions<'a> {
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub executable_path: Option<&'a str>,
|
||||
pub extensions: &'a [String],
|
||||
pub args: Option<&'a str>,
|
||||
pub user_agent: Option<&'a str>,
|
||||
pub proxy: Option<&'a str>,
|
||||
pub proxy_bypass: Option<&'a str>,
|
||||
pub proxy_username: Option<&'a str>,
|
||||
pub proxy_password: Option<&'a str>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub profile: Option<&'a str>,
|
||||
pub state: Option<&'a str>,
|
||||
pub provider: Option<&'a str>,
|
||||
pub device: Option<&'a str>,
|
||||
pub session_name: Option<&'a str>,
|
||||
pub download_path: Option<&'a str>,
|
||||
pub allowed_domains: Option<&'a [String]>,
|
||||
pub action_policy: Option<&'a str>,
|
||||
pub confirm_actions: Option<&'a str>,
|
||||
pub engine: Option<&'a str>,
|
||||
pub auto_connect: bool,
|
||||
pub force_launch: bool,
|
||||
pub idle_timeout: Option<&'a str>,
|
||||
pub cdp: Option<&'a str>,
|
||||
pub no_auto_dialog: bool,
|
||||
}
|
||||
|
||||
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
if opts.headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
if opts.debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
if let Some(path) = opts.executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
if !opts.extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(","));
|
||||
}
|
||||
if let Some(a) = opts.args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
if let Some(ua) = opts.user_agent {
|
||||
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
|
||||
}
|
||||
if let Some(p) = opts.proxy {
|
||||
cmd.env("AGENT_BROWSER_PROXY", p);
|
||||
}
|
||||
if let Some(pb) = opts.proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
if let Some(pu) = opts.proxy_username {
|
||||
cmd.env("AGENT_BROWSER_PROXY_USERNAME", pu);
|
||||
}
|
||||
if let Some(pp) = opts.proxy_password {
|
||||
cmd.env("AGENT_BROWSER_PROXY_PASSWORD", pp);
|
||||
}
|
||||
if opts.ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
if opts.allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
if let Some(prof) = opts.profile {
|
||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||
}
|
||||
if let Some(st) = opts.state {
|
||||
cmd.env("AGENT_BROWSER_STATE", st);
|
||||
}
|
||||
if let Some(p) = opts.provider {
|
||||
cmd.env("AGENT_BROWSER_PROVIDER", p);
|
||||
}
|
||||
if let Some(d) = opts.device {
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
if let Some(sn) = opts.session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(dp) = opts.download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
if let Some(ad) = opts.allowed_domains {
|
||||
cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(","));
|
||||
}
|
||||
if let Some(ap) = opts.action_policy {
|
||||
cmd.env("AGENT_BROWSER_ACTION_POLICY", ap);
|
||||
}
|
||||
if let Some(ca) = opts.confirm_actions {
|
||||
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
|
||||
}
|
||||
if let Some(engine) = opts.engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
if opts.auto_connect {
|
||||
cmd.env("AGENT_BROWSER_AUTO_CONNECT", "1");
|
||||
}
|
||||
if opts.force_launch {
|
||||
cmd.env("AGENT_BROWSER_FORCE_LAUNCH", "1");
|
||||
}
|
||||
if let Some(idle) = opts.idle_timeout {
|
||||
cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle);
|
||||
}
|
||||
if let Some(cdp) = opts.cdp {
|
||||
cmd.env("AGENT_BROWSER_CDP", cdp);
|
||||
}
|
||||
if opts.no_auto_dialog {
|
||||
cmd.env("AGENT_BROWSER_NO_AUTO_DIALOG", "1");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
|
||||
// Socket connectivity is the sole liveness check — no PID check — so
|
||||
// callers in a different PID namespace (e.g. unshare) can still reuse
|
||||
// an existing daemon they can reach over the socket.
|
||||
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)
|
||||
@@ -276,189 +368,54 @@ pub fn ensure_daemon(
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
||||
// Canonicalize to resolve symlinks (e.g., npm global bin symlink -> actual binary)
|
||||
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
|
||||
let exe_dir = exe_path.parent().unwrap();
|
||||
|
||||
let mut daemon_paths = vec![
|
||||
exe_dir.join("daemon.js"),
|
||||
exe_dir.join("../dist/daemon.js"),
|
||||
PathBuf::from("dist/daemon.js"),
|
||||
];
|
||||
#[allow(unused_assignments)]
|
||||
let mut daemon_child: Option<std::process::Child> = None;
|
||||
|
||||
// Check AGENT_BROWSER_HOME environment variable
|
||||
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
|
||||
let home_path = PathBuf::from(&home);
|
||||
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
|
||||
daemon_paths.insert(1, home_path.join("daemon.js"));
|
||||
}
|
||||
|
||||
let daemon_path = daemon_paths
|
||||
.iter()
|
||||
.find(|p| p.exists())
|
||||
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
|
||||
|
||||
// Spawn daemon as a fully detached background process
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
if let Some(path) = executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
|
||||
if !extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
|
||||
}
|
||||
|
||||
if let Some(a) = args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
|
||||
if let Some(ua) = user_agent {
|
||||
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
|
||||
}
|
||||
|
||||
if let Some(p) = proxy {
|
||||
cmd.env("AGENT_BROWSER_PROXY", p);
|
||||
}
|
||||
|
||||
if let Some(pb) = proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
|
||||
if ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
|
||||
if allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
|
||||
if let Some(st) = state {
|
||||
cmd.env("AGENT_BROWSER_STATE", st);
|
||||
}
|
||||
|
||||
if let Some(p) = provider {
|
||||
cmd.env("AGENT_BROWSER_PROVIDER", p);
|
||||
}
|
||||
|
||||
if let Some(d) = device {
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
|
||||
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||
if debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
// Create new session (detach from terminal)
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
cmd.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
daemon_child = Some(
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
|
||||
// and automatically quotes arguments containing spaces.
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
if let Some(path) = executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
|
||||
if !extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
|
||||
}
|
||||
|
||||
if let Some(a) = args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
|
||||
if let Some(ua) = user_agent {
|
||||
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
|
||||
}
|
||||
|
||||
if let Some(p) = proxy {
|
||||
cmd.env("AGENT_BROWSER_PROXY", p);
|
||||
}
|
||||
|
||||
if let Some(pb) = proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
|
||||
if ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
|
||||
if allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
|
||||
if let Some(st) = state {
|
||||
cmd.env("AGENT_BROWSER_STATE", st);
|
||||
}
|
||||
|
||||
if let Some(p) = provider {
|
||||
cmd.env("AGENT_BROWSER_PROVIDER", p);
|
||||
}
|
||||
|
||||
if let Some(d) = device {
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
|
||||
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||
if debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
cmd.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
daemon_child = Some(
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
@@ -467,13 +424,47 @@ pub fn ensure_daemon(
|
||||
already_running: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Detect early daemon exit and surface the real error from stderr
|
||||
if let Some(ref mut child) = daemon_child {
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
let mut stderr_output = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let _ = stderr.read_to_string(&mut stderr_output);
|
||||
}
|
||||
let stderr_trimmed = stderr_output.trim();
|
||||
if !stderr_trimmed.is_empty() {
|
||||
let msg = if stderr_trimmed.len() > 500 {
|
||||
let mut end = 500;
|
||||
while !stderr_trimmed.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&stderr_trimmed[..end]
|
||||
} else {
|
||||
stderr_trimmed
|
||||
};
|
||||
return Err(format!("Daemon process exited during startup:\n{}", msg));
|
||||
}
|
||||
return Err(
|
||||
"Daemon process exited during startup with no error output. \
|
||||
Re-run with --debug for more details."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Daemon failed to start (socket: {})",
|
||||
#[cfg(unix)]
|
||||
let endpoint_info = format!(
|
||||
"socket: {}",
|
||||
get_socket_dir().join(format!("{}.sock", session)).display()
|
||||
))
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let endpoint_info = format!("port: 127.0.0.1:{}", resolve_port(session));
|
||||
|
||||
Err(format!("Daemon failed to start ({})", endpoint_info))
|
||||
}
|
||||
|
||||
fn connect(session: &str) -> Result<Connection, String> {
|
||||
@@ -486,7 +477,7 @@ fn connect(session: &str) -> Result<Connection, String> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port = get_port_for_session(session);
|
||||
let port = resolve_port(session);
|
||||
TcpStream::connect(format!("127.0.0.1:{}", port))
|
||||
.map(Connection::Tcp)
|
||||
.map_err(|e| format!("Failed to connect: {}", e))
|
||||
@@ -544,6 +535,8 @@ fn is_transient_error(error: &str) -> bool {
|
||||
|| error.contains("os error 2") // No such file or directory (socket gone)
|
||||
|| error.contains("os error 61") // Connection refused (macOS)
|
||||
|| error.contains("os error 111") // Connection refused (Linux)
|
||||
|| error.contains("os error 10061") // Connection refused (Windows)
|
||||
|| error.contains("os error 10054") // Connection reset by peer (Windows)
|
||||
}
|
||||
|
||||
fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
@@ -571,45 +564,14 @@ fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
// Mutex to prevent parallel tests from interfering with env vars
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard that locks env mutex and restores env vars on drop
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_explicit_override() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path"));
|
||||
}
|
||||
@@ -618,8 +580,8 @@ mod tests {
|
||||
fn test_get_socket_dir_ignores_empty_socket_dir() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
@@ -630,8 +592,8 @@ mod tests {
|
||||
fn test_get_socket_dir_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
|
||||
env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
|
||||
assert_eq!(
|
||||
get_socket_dir(),
|
||||
@@ -643,8 +605,8 @@ mod tests {
|
||||
fn test_get_socket_dir_ignores_empty_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
env::set_var("XDG_RUNTIME_DIR", "");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.set("XDG_RUNTIME_DIR", "");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
@@ -655,8 +617,8 @@ mod tests {
|
||||
fn test_get_socket_dir_home_fallback() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let result = get_socket_dir();
|
||||
assert!(result.to_string_lossy().ends_with(".agent-browser"));
|
||||
@@ -750,6 +712,20 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_refused_windows() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: No connection could be made because the target machine actively refused it. (os error 10061)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset_windows() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to send: An existing connection was forcibly closed by the remote host. (os error 10054)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_non_transient() {
|
||||
// These should NOT be considered transient
|
||||
@@ -758,4 +734,13 @@ mod tests {
|
||||
assert!(!is_transient_error("Permission denied"));
|
||||
assert!(!is_transient_error("Daemon not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn test_get_port_for_session() {
|
||||
assert_eq!(get_port_for_session("default"), 50838);
|
||||
assert_eq!(get_port_for_session("my-session"), 63105);
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
}
|
||||
|
||||
+429
-56
@@ -8,17 +8,59 @@ const CONFIG_DIR: &str = ".agent-browser";
|
||||
const CONFIG_FILENAME: &str = "config.json";
|
||||
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
|
||||
|
||||
/// Parse idle timeout from user-friendly format.
|
||||
/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds.
|
||||
fn parse_idle_timeout(s: &str) -> Result<String, String> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Err("Empty idle timeout".to_string());
|
||||
}
|
||||
|
||||
// If the value ends with a unit suffix, convert it to milliseconds.
|
||||
if s.chars().last().is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
let (num_str, unit) = s.split_at(s.len() - 1);
|
||||
let num: u64 = num_str.parse().map_err(|_| "Invalid number")?;
|
||||
|
||||
let ms = match unit {
|
||||
"s" => num * 1000,
|
||||
"m" => num * 60 * 1000,
|
||||
"h" => num * 60 * 60 * 1000,
|
||||
_ => return Err("Invalid idle timeout unit (use s, m, h, or raw ms)".to_string()),
|
||||
};
|
||||
return Ok(ms.to_string());
|
||||
}
|
||||
|
||||
// Pure numbers are already expressed in milliseconds.
|
||||
s.parse::<u64>().map_err(|_| "Invalid idle timeout")?;
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn parse_idle_timeout_value(value: Option<String>, source: &str) -> Option<String> {
|
||||
value.and_then(|raw| match parse_idle_timeout(&raw) {
|
||||
Ok(ms) => Some(ms),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} invalid idle timeout from {}: {}",
|
||||
color::warning_indicator(),
|
||||
source,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
pub headed: Option<bool>,
|
||||
pub json: Option<bool>,
|
||||
pub full: Option<bool>,
|
||||
pub debug: Option<bool>,
|
||||
pub session: Option<String>,
|
||||
pub session_name: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
pub extensions: Option<Vec<String>>,
|
||||
pub profile: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub proxy_bypass: Option<String>,
|
||||
@@ -33,6 +75,19 @@ pub struct Config {
|
||||
pub headers: Option<String>,
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub content_boundaries: Option<bool>,
|
||||
pub max_output: Option<usize>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub action_policy: Option<String>,
|
||||
pub confirm_actions: Option<String>,
|
||||
pub confirm_interactive: Option<bool>,
|
||||
pub engine: Option<String>,
|
||||
pub screenshot_dir: Option<String>,
|
||||
pub screenshot_quality: Option<u32>,
|
||||
pub screenshot_format: Option<String>,
|
||||
pub idle_timeout: Option<String>,
|
||||
pub no_auto_dialog: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -40,7 +95,6 @@ impl Config {
|
||||
Config {
|
||||
headed: other.headed.or(self.headed),
|
||||
json: other.json.or(self.json),
|
||||
full: other.full.or(self.full),
|
||||
debug: other.debug.or(self.debug),
|
||||
session: other.session.or(self.session),
|
||||
session_name: other.session_name.or(self.session_name),
|
||||
@@ -52,6 +106,7 @@ impl Config {
|
||||
}
|
||||
(a, b) => b.or(a),
|
||||
},
|
||||
profile: other.profile.or(self.profile),
|
||||
state: other.state.or(self.state),
|
||||
proxy: other.proxy.or(self.proxy),
|
||||
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
|
||||
@@ -66,6 +121,19 @@ impl Config {
|
||||
headers: other.headers.or(self.headers),
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
download_path: other.download_path.or(self.download_path),
|
||||
content_boundaries: other.content_boundaries.or(self.content_boundaries),
|
||||
max_output: other.max_output.or(self.max_output),
|
||||
allowed_domains: other.allowed_domains.or(self.allowed_domains),
|
||||
action_policy: other.action_policy.or(self.action_policy),
|
||||
confirm_actions: other.confirm_actions.or(self.confirm_actions),
|
||||
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
|
||||
engine: other.engine.or(self.engine),
|
||||
screenshot_dir: other.screenshot_dir.or(self.screenshot_dir),
|
||||
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
|
||||
screenshot_format: other.screenshot_format.or(self.screenshot_format),
|
||||
idle_timeout: other.idle_timeout.or(self.idle_timeout),
|
||||
no_auto_dialog: other.no_auto_dialog.or(self.no_auto_dialog),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +141,13 @@ impl Config {
|
||||
fn read_config_file(path: &Path) -> Option<Config> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
match serde_json::from_str::<Config>(&content) {
|
||||
Ok(config) => Some(config),
|
||||
Ok(mut config) => {
|
||||
config.idle_timeout = parse_idle_timeout_value(
|
||||
config.idle_timeout.take(),
|
||||
&format!("config file {}", path.display()),
|
||||
);
|
||||
Some(config)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} invalid config file {}: {}",
|
||||
@@ -112,6 +186,11 @@ fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
||||
/// Extract --config <path> from args before full flag parsing.
|
||||
/// Returns `Some(Some(path))` if --config <path> found, `Some(None)` if --config
|
||||
/// was the last arg with no value, `None` if --config not present.
|
||||
///
|
||||
/// Only flags that consume a following argument need to be listed here.
|
||||
/// Boolean flags (--content-boundaries, --confirm-interactive, etc.) are
|
||||
/// intentionally absent -- they don't take a value, so they can't cause
|
||||
/// the next argument to be mis-consumed.
|
||||
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
const FLAGS_WITH_VALUE: &[&str] = &[
|
||||
"--session",
|
||||
@@ -130,7 +209,16 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--device",
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--channel",
|
||||
"--download-path",
|
||||
"--max-output",
|
||||
"--allowed-domains",
|
||||
"--action-policy",
|
||||
"--confirm-actions",
|
||||
"--engine",
|
||||
"--screenshot-dir",
|
||||
"--screenshot-quality",
|
||||
"--screenshot-format",
|
||||
"--idle-timeout",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -179,7 +267,6 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
|
||||
|
||||
pub struct Flags {
|
||||
pub json: bool,
|
||||
pub full: bool,
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub session: String,
|
||||
@@ -187,6 +274,7 @@ pub struct Flags {
|
||||
pub executable_path: Option<String>,
|
||||
pub cdp: Option<String>,
|
||||
pub extensions: Vec<String>,
|
||||
pub profile: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub proxy_bypass: Option<String>,
|
||||
@@ -197,14 +285,29 @@ pub struct Flags {
|
||||
pub allow_file_access: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub force_launch: bool,
|
||||
pub session_name: Option<String>,
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub content_boundaries: bool,
|
||||
pub max_output: Option<usize>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub action_policy: Option<String>,
|
||||
pub confirm_actions: Option<String>,
|
||||
pub confirm_interactive: bool,
|
||||
pub engine: Option<String>,
|
||||
pub screenshot_dir: Option<String>,
|
||||
pub screenshot_quality: Option<u32>,
|
||||
pub screenshot_format: Option<String>,
|
||||
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
|
||||
pub no_auto_dialog: bool,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
pub cli_executable_path: bool,
|
||||
pub cli_extensions: bool,
|
||||
pub cli_profile: bool,
|
||||
pub cli_state: bool,
|
||||
pub cli_args: bool,
|
||||
pub cli_user_agent: bool,
|
||||
@@ -212,6 +315,8 @@ pub struct Flags {
|
||||
pub cli_proxy_bypass: bool,
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_headed: bool,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -238,11 +343,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
|
||||
let mut flags = Flags {
|
||||
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
|
||||
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
|
||||
headed: match env::var("AGENT_BROWSER_HEADED") {
|
||||
Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""),
|
||||
Err(_) => config.headed.unwrap_or(true),
|
||||
},
|
||||
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
|
||||
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|
||||
session: env::var("AGENT_BROWSER_SESSION")
|
||||
.ok()
|
||||
@@ -254,11 +355,22 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
.or(config.executable_path),
|
||||
cdp: config.cdp,
|
||||
extensions,
|
||||
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
|
||||
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
|
||||
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
|
||||
proxy: env::var("AGENT_BROWSER_PROXY")
|
||||
.ok()
|
||||
.or(config.proxy)
|
||||
.or_else(|| env::var("HTTP_PROXY").ok())
|
||||
.or_else(|| env::var("http_proxy").ok())
|
||||
.or_else(|| env::var("HTTPS_PROXY").ok())
|
||||
.or_else(|| env::var("https_proxy").ok())
|
||||
.or_else(|| env::var("ALL_PROXY").ok())
|
||||
.or_else(|| env::var("all_proxy").ok()),
|
||||
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
|
||||
.ok()
|
||||
.or(config.proxy_bypass),
|
||||
.or(config.proxy_bypass)
|
||||
.or_else(|| env::var("NO_PROXY").ok())
|
||||
.or_else(|| env::var("no_proxy").ok()),
|
||||
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
|
||||
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
|
||||
.ok()
|
||||
@@ -269,8 +381,11 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
||||
|| config.allow_file_access.unwrap_or(false),
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(false),
|
||||
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
|
||||
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(true)),
|
||||
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH")
|
||||
|| env::var("CI").is_ok(),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||
.ok()
|
||||
.or(config.session_name),
|
||||
@@ -278,8 +393,54 @@ 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()
|
||||
.or(config.download_path),
|
||||
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|
||||
|| config.content_boundaries.unwrap_or(false),
|
||||
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or(config.max_output),
|
||||
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|d| d.trim().to_lowercase())
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.or(config.allowed_domains),
|
||||
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY")
|
||||
.ok()
|
||||
.or(config.action_policy),
|
||||
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS")
|
||||
.ok()
|
||||
.or(config.confirm_actions),
|
||||
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|
||||
|| config.confirm_interactive.unwrap_or(false),
|
||||
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
|
||||
screenshot_dir: env::var("AGENT_BROWSER_SCREENSHOT_DIR")
|
||||
.ok()
|
||||
.or(config.screenshot_dir),
|
||||
screenshot_quality: env::var("AGENT_BROWSER_SCREENSHOT_QUALITY")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or(config.screenshot_quality),
|
||||
screenshot_format: env::var("AGENT_BROWSER_SCREENSHOT_FORMAT")
|
||||
.ok()
|
||||
.or(config.screenshot_format)
|
||||
.filter(|s| s == "png" || s == "jpeg"),
|
||||
idle_timeout: parse_idle_timeout_value(
|
||||
env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS").ok(),
|
||||
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
|
||||
)
|
||||
.or(config.idle_timeout),
|
||||
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|
||||
|| config.no_auto_dialog.unwrap_or(false),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_profile: false,
|
||||
cli_state: false,
|
||||
cli_args: false,
|
||||
cli_user_agent: false,
|
||||
@@ -287,6 +448,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_proxy_bypass: false,
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_headed: false,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -299,16 +462,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--full" | "-f" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.full = val;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--headed" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.headed = val;
|
||||
flags.cli_headed = true;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
@@ -326,6 +483,19 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--idle-timeout" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
match parse_idle_timeout(s) {
|
||||
Ok(ms) => flags.idle_timeout = Some(ms),
|
||||
Err(e) => eprintln!(
|
||||
"{} Invalid --idle-timeout: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--headers" => {
|
||||
if let Some(h) = args.get(i + 1) {
|
||||
flags.headers = Some(h.clone());
|
||||
@@ -352,6 +522,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--profile" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.profile = Some(s.clone());
|
||||
flags.cli_profile = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--state" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.state = Some(s.clone());
|
||||
@@ -417,10 +594,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
"--auto-connect" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.auto_connect = val;
|
||||
if !val {
|
||||
flags.force_launch = true;
|
||||
}
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--launch" | "--new" => {
|
||||
flags.force_launch = true;
|
||||
flags.auto_connect = false;
|
||||
}
|
||||
"--session-name" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session_name = Some(s.clone());
|
||||
@@ -441,6 +625,107 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--download-path" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.download_path = Some(s.clone());
|
||||
flags.cli_download_path = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--content-boundaries" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.content_boundaries = val;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--max-output" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
if let Ok(n) = s.parse::<usize>() {
|
||||
flags.max_output = Some(n);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--allowed-domains" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.allowed_domains = Some(
|
||||
s.split(',')
|
||||
.map(|d| d.trim().to_lowercase())
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect(),
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--action-policy" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.action_policy = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--confirm-actions" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.confirm_actions = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--confirm-interactive" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.confirm_interactive = val;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--engine" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.engine = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--screenshot-dir" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.screenshot_dir = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--screenshot-quality" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
if let Ok(n) = s.parse::<u32>() {
|
||||
if n <= 100 {
|
||||
flags.screenshot_quality = Some(n);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} --screenshot-quality must be 0-100, got {}",
|
||||
color::warning_indicator(),
|
||||
n
|
||||
);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--screenshot-format" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
if s == "png" || s == "jpeg" {
|
||||
flags.screenshot_format = Some(s.clone());
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} --screenshot-format must be png or jpeg, got '{}'",
|
||||
color::warning_indicator(),
|
||||
s
|
||||
);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--no-auto-dialog" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.no_auto_dialog = val;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--config" => {
|
||||
// Already handled by load_config(); skip the value
|
||||
i += 1;
|
||||
@@ -459,13 +744,17 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
// Boolean flags that optionally take true/false
|
||||
const GLOBAL_BOOL_FLAGS: &[&str] = &[
|
||||
"--json",
|
||||
"--full",
|
||||
"--headed",
|
||||
"--debug",
|
||||
"--ignore-https-errors",
|
||||
"--allow-file-access",
|
||||
"--auto-connect",
|
||||
"--launch",
|
||||
"--new",
|
||||
"--annotate",
|
||||
"--content-boundaries",
|
||||
"--confirm-interactive",
|
||||
"--no-auto-dialog",
|
||||
];
|
||||
// Global flags that always take a value (need to skip the next arg too)
|
||||
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
|
||||
@@ -474,6 +763,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--executable-path",
|
||||
"--cdp",
|
||||
"--extension",
|
||||
"--profile",
|
||||
"--state",
|
||||
"--proxy",
|
||||
"--proxy-bypass",
|
||||
@@ -484,7 +774,17 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--device",
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--download-path",
|
||||
"--max-output",
|
||||
"--allowed-domains",
|
||||
"--action-policy",
|
||||
"--confirm-actions",
|
||||
"--config",
|
||||
"--engine",
|
||||
"--screenshot-dir",
|
||||
"--screenshot-quality",
|
||||
"--screenshot-format",
|
||||
"--idle-timeout",
|
||||
];
|
||||
|
||||
let mut i = 0;
|
||||
@@ -500,7 +800,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) || arg == "-f" {
|
||||
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) {
|
||||
if let Some(v) = args.get(i + 1) {
|
||||
if matches!(v.as_str(), "true" | "false") {
|
||||
i += 1;
|
||||
@@ -529,6 +829,36 @@ mod tests {
|
||||
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_raw_ms() {
|
||||
assert_eq!(parse_idle_timeout("10").unwrap(), "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_seconds() {
|
||||
assert_eq!(parse_idle_timeout("10s").unwrap(), "10000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_minutes() {
|
||||
assert_eq!(parse_idle_timeout("3m").unwrap(), "180000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_hours() {
|
||||
assert_eq!(parse_idle_timeout("1h").unwrap(), "3600000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_rejects_capital_m() {
|
||||
assert!(parse_idle_timeout("10M").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_rejects_unknown_unit() {
|
||||
assert!(parse_idle_timeout("10x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_headers_flag_with_spaces() {
|
||||
// Headers JSON is passed as a single quoted argument in shell
|
||||
@@ -624,6 +954,18 @@ mod tests {
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_idle_timeout_before_command() {
|
||||
let cleaned = clean_args(&args("--idle-timeout 10s open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_flag_converts_to_ms() {
|
||||
let flags = parse_flags(&args("--idle-timeout 10s open example.com"));
|
||||
assert_eq!(flags.idle_timeout.as_deref(), Some("10000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_flags_with_session_and_executable_path() {
|
||||
let flags = parse_flags(&args(
|
||||
@@ -655,6 +997,12 @@ mod tests {
|
||||
assert!(flags.cli_extensions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_profile_tracking() {
|
||||
let flags = parse_flags(&args("--profile /path/to/profile snapshot"));
|
||||
assert!(flags.cli_profile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_annotate_tracking() {
|
||||
let flags = parse_flags(&args("--annotate screenshot"));
|
||||
@@ -668,12 +1016,26 @@ mod tests {
|
||||
assert!(!flags.cli_annotate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_download_path_tracking() {
|
||||
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
|
||||
assert!(flags.cli_download_path);
|
||||
assert_eq!(flags.download_path, Some("/tmp/dl".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_download_path_not_set_without_flag() {
|
||||
let flags = parse_flags(&args("snapshot"));
|
||||
assert!(!flags.cli_download_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_multiple_flags_tracking() {
|
||||
let flags = parse_flags(&args(
|
||||
"--executable-path /chrome --proxy http://proxy snapshot",
|
||||
"--executable-path /chrome --profile /profile --proxy http://proxy snapshot",
|
||||
));
|
||||
assert!(flags.cli_executable_path);
|
||||
assert!(flags.cli_profile);
|
||||
assert!(flags.cli_proxy);
|
||||
assert!(!flags.cli_extensions);
|
||||
assert!(!flags.cli_state);
|
||||
@@ -686,12 +1048,12 @@ mod tests {
|
||||
let json = r#"{
|
||||
"headed": true,
|
||||
"json": true,
|
||||
"full": true,
|
||||
"debug": true,
|
||||
"session": "test-session",
|
||||
"sessionName": "my-app",
|
||||
"executablePath": "/usr/bin/chromium",
|
||||
"extensions": ["/ext1", "/ext2"],
|
||||
"profile": "/tmp/profile",
|
||||
"state": "/tmp/state.json",
|
||||
"proxy": "http://proxy:8080",
|
||||
"proxyBypass": "localhost",
|
||||
@@ -708,7 +1070,6 @@ mod tests {
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
assert_eq!(config.json, Some(true));
|
||||
assert_eq!(config.full, Some(true));
|
||||
assert_eq!(config.debug, Some(true));
|
||||
assert_eq!(config.session.as_deref(), Some("test-session"));
|
||||
assert_eq!(config.session_name.as_deref(), Some("my-app"));
|
||||
@@ -717,6 +1078,7 @@ mod tests {
|
||||
config.extensions,
|
||||
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
|
||||
);
|
||||
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
|
||||
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
|
||||
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
|
||||
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
|
||||
@@ -762,6 +1124,7 @@ mod tests {
|
||||
let user = Config {
|
||||
headed: Some(true),
|
||||
proxy: Some("http://user-proxy:8080".to_string()),
|
||||
profile: Some("/user/profile".to_string()),
|
||||
..Config::default()
|
||||
};
|
||||
let project = Config {
|
||||
@@ -772,6 +1135,7 @@ mod tests {
|
||||
let merged = user.merge(project);
|
||||
assert_eq!(merged.headed, Some(true)); // kept from user
|
||||
assert_eq!(merged.proxy.as_deref(), Some("http://project-proxy:9090")); // overridden by project
|
||||
assert_eq!(merged.profile.as_deref(), Some("/user/profile")); // kept from user
|
||||
assert_eq!(merged.debug, Some(true)); // added by project
|
||||
}
|
||||
|
||||
@@ -805,6 +1169,22 @@ mod tests {
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_from_file_parses_idle_timeout() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join("ab-test-idle-timeout-config");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let config_path = dir.join("test-config.json");
|
||||
let mut f = fs::File::create(&config_path).unwrap();
|
||||
writeln!(f, r#"{{"idleTimeout": "10s"}}"#).unwrap();
|
||||
|
||||
let config = read_config_file(&config_path).unwrap();
|
||||
assert_eq!(config.idle_timeout.as_deref(), Some("10000"));
|
||||
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_missing_file_returns_none() {
|
||||
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
|
||||
@@ -965,36 +1345,6 @@ mod tests {
|
||||
assert!(!flags.auto_connect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--full open example.com"));
|
||||
assert!(flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_false() {
|
||||
let flags = parse_flags(&args("--full false open example.com"));
|
||||
assert!(!flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_short_flag() {
|
||||
let flags = parse_flags(&args("-f open example.com"));
|
||||
assert!(flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_full_with_value() {
|
||||
let cleaned = clean_args(&args("--full false open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_short_full() {
|
||||
let cleaned = clean_args(&args("-f open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_bool_flag_with_value() {
|
||||
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
|
||||
@@ -1051,4 +1401,27 @@ mod tests {
|
||||
let merged = user.merge(project);
|
||||
assert_eq!(merged.extensions, Some(vec!["/ext2".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_auto_dialog_flag() {
|
||||
let flags = parse_flags(&args("open example.com --no-auto-dialog"));
|
||||
assert!(flags.no_auto_dialog);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_auto_dialog_default_false() {
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert!(!flags.no_auto_dialog);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_no_auto_dialog() {
|
||||
let input: Vec<String> = vec![
|
||||
"open".to_string(),
|
||||
"example.com".to_string(),
|
||||
"--no-auto-dialog".to_string(),
|
||||
];
|
||||
let clean = clean_args(&input);
|
||||
assert_eq!(clean, vec!["open", "example.com"]);
|
||||
}
|
||||
}
|
||||
|
||||
+757
-157
@@ -1,167 +1,416 @@
|
||||
use crate::color;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{exit, Command, Stdio};
|
||||
|
||||
const LAST_KNOWN_GOOD_URL: &str =
|
||||
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
||||
|
||||
pub fn get_browsers_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".agent-browser")
|
||||
.join("browsers")
|
||||
}
|
||||
|
||||
pub fn find_installed_chrome() -> Option<PathBuf> {
|
||||
let browsers_dir = get_browsers_dir();
|
||||
let debug = std::env::var("AGENT_BROWSER_DEBUG").is_ok();
|
||||
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] home_dir={:?} browsers_dir={}",
|
||||
dirs::home_dir(),
|
||||
browsers_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !browsers_dir.exists() {
|
||||
if debug {
|
||||
let _ = writeln!(io::stderr(), "[chrome-search] browsers_dir does not exist");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&browsers_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"Warning: cannot read Chrome cache directory {}: {}",
|
||||
browsers_dir.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut versions: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let matches = e
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.starts_with("chrome-"));
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] entry {:?} matches={}",
|
||||
e.file_name(),
|
||||
matches
|
||||
);
|
||||
}
|
||||
matches
|
||||
})
|
||||
.collect();
|
||||
|
||||
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||||
|
||||
for entry in versions {
|
||||
let dir = entry.path();
|
||||
if let Some(bin) = chrome_binary_in_dir(&dir) {
|
||||
let exists = bin.exists();
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] candidate {} exists={}",
|
||||
bin.display(),
|
||||
exists
|
||||
);
|
||||
}
|
||||
if exists {
|
||||
return Some(bin);
|
||||
}
|
||||
} else if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] no binary found in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if debug {
|
||||
let _ = writeln!(io::stderr(), "[chrome-search] no installed Chrome found");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app =
|
||||
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
||||
if app.exists() {
|
||||
return Some(app);
|
||||
}
|
||||
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
let inner_x64 = dir.join(
|
||||
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
);
|
||||
if inner_x64.exists() {
|
||||
return Some(inner_x64);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let bin = dir.join("chrome");
|
||||
if bin.exists() {
|
||||
return Some(bin);
|
||||
}
|
||||
let inner = dir.join("chrome-linux64/chrome");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bin = dir.join("chrome.exe");
|
||||
if bin.exists() {
|
||||
return Some(bin);
|
||||
}
|
||||
let inner = dir.join("chrome-win64/chrome.exe");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_key() -> &'static str {
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
"mac-arm64"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
"mac-x64"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
"linux64"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
"win64"
|
||||
}
|
||||
#[cfg(not(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "macos", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "windows", target_arch = "x86_64"),
|
||||
)))]
|
||||
{
|
||||
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
|
||||
// can still be used for other commands like `connect`. The install path
|
||||
// guards against this at runtime before calling platform_key().
|
||||
panic!("Unsupported platform for Chrome for Testing download")
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_download_url() -> Result<(String, String), String> {
|
||||
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse version info: {}", e))?;
|
||||
|
||||
let channel = body
|
||||
.get("channels")
|
||||
.and_then(|c| c.get("Stable"))
|
||||
.ok_or("No Stable channel found in version info")?;
|
||||
|
||||
let version = channel
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No version string found")?
|
||||
.to_string();
|
||||
|
||||
let platform = platform_key();
|
||||
|
||||
let url = channel
|
||||
.get("downloads")
|
||||
.and_then(|d| d.get("chrome"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find_map(|entry| {
|
||||
if entry.get("platform")?.as_str()? == platform {
|
||||
Some(entry.get("url")?.as_str()?.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
|
||||
|
||||
Ok((version, url))
|
||||
}
|
||||
|
||||
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("Download failed: {}", e))?;
|
||||
|
||||
let total = resp.content_length();
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = resp;
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut last_pct: u64 = 0;
|
||||
|
||||
loop {
|
||||
let chunk = stream
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| format!("Download error: {}", e))?;
|
||||
match chunk {
|
||||
Some(data) => {
|
||||
downloaded += data.len() as u64;
|
||||
bytes.extend_from_slice(&data);
|
||||
|
||||
if let Some(total) = total {
|
||||
let pct = (downloaded * 100) / total;
|
||||
if pct >= last_pct + 5 {
|
||||
last_pct = pct;
|
||||
let mb = downloaded as f64 / 1_048_576.0;
|
||||
let total_mb = total as f64 / 1_048_576.0;
|
||||
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
|
||||
let _ = io::stderr().flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
|
||||
let cursor = io::Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
||||
|
||||
let enclosed = match file.enclosed_name() {
|
||||
Some(name) => name.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let raw_name = enclosed.to_string_lossy().to_string();
|
||||
// Strip the top-level "chrome-<platform>/" directory from zip entries.
|
||||
// On Windows, enclosed_name() normalizes paths to backslashes, so we
|
||||
// must split on either separator.
|
||||
let rel_path = raw_name
|
||||
.strip_prefix("chrome-")
|
||||
.and_then(|s| s.find(['/', '\\']).map(|i| &s[i + 1..]))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(raw_name.clone());
|
||||
|
||||
if rel_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = dest.join(&rel_path);
|
||||
|
||||
// Defense-in-depth: ensure the resolved path is inside dest
|
||||
if !out_path.starts_with(dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
format!("Failed to create parent dir {}: {}", parent.display(), e)
|
||||
})?;
|
||||
}
|
||||
let mut out_file = fs::File::create(&out_path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||
io::copy(&mut file, &mut out_file)
|
||||
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_install(with_deps: bool) {
|
||||
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
||||
eprintln!(
|
||||
"{} Chrome for Testing does not provide Linux ARM64 builds.",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" Install Chromium from your system package manager instead:");
|
||||
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
|
||||
eprintln!(" sudo dnf install chromium # Fedora");
|
||||
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
|
||||
if is_linux {
|
||||
if with_deps {
|
||||
println!("{}", color::cyan("Installing system dependencies..."));
|
||||
|
||||
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
||||
let libasound = if package_exists_apt("libasound2t64") {
|
||||
"libasound2t64"
|
||||
} else {
|
||||
"libasound2"
|
||||
};
|
||||
|
||||
(
|
||||
"apt-get",
|
||||
vec![
|
||||
"libxcb-shm0",
|
||||
"libx11-xcb1",
|
||||
"libx11-6",
|
||||
"libxcb1",
|
||||
"libxext6",
|
||||
"libxrandr2",
|
||||
"libxcomposite1",
|
||||
"libxcursor1",
|
||||
"libxdamage1",
|
||||
"libxfixes3",
|
||||
"libxi6",
|
||||
"libgtk-3-0",
|
||||
"libpangocairo-1.0-0",
|
||||
"libpango-1.0-0",
|
||||
"libatk1.0-0",
|
||||
"libcairo-gobject2",
|
||||
"libcairo2",
|
||||
"libgdk-pixbuf-2.0-0",
|
||||
"libxrender1",
|
||||
libasound,
|
||||
"libfreetype6",
|
||||
"libfontconfig1",
|
||||
"libdbus-1-3",
|
||||
"libnss3",
|
||||
"libnspr4",
|
||||
"libatk-bridge2.0-0",
|
||||
"libdrm2",
|
||||
"libxkbcommon0",
|
||||
"libatspi2.0-0",
|
||||
"libcups2",
|
||||
"libxshmfence1",
|
||||
"libgbm1",
|
||||
],
|
||||
)
|
||||
} else if which_exists("dnf") {
|
||||
(
|
||||
"dnf",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
"libxcb",
|
||||
"libX11-xcb",
|
||||
"libX11",
|
||||
"libXext",
|
||||
"libXcursor",
|
||||
"libXfixes",
|
||||
"libXi",
|
||||
"gtk3",
|
||||
"cairo-gobject",
|
||||
],
|
||||
)
|
||||
} else if which_exists("yum") {
|
||||
(
|
||||
"yum",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} No supported package manager found (apt-get, dnf, or yum)",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(1);
|
||||
};
|
||||
|
||||
let install_cmd = match pkg_mgr {
|
||||
"apt-get" => {
|
||||
format!(
|
||||
"sudo apt-get update && sudo apt-get install -y {}",
|
||||
deps.join(" ")
|
||||
)
|
||||
}
|
||||
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
|
||||
};
|
||||
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("{} System dependencies installed", color::success_indicator())
|
||||
}
|
||||
Ok(_) => eprintln!(
|
||||
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
||||
color::warning_indicator()
|
||||
),
|
||||
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
|
||||
}
|
||||
install_linux_deps();
|
||||
} else {
|
||||
println!(
|
||||
"{} Linux detected. If browser fails to launch, run:",
|
||||
color::warning_indicator()
|
||||
);
|
||||
println!(" agent-browser install --with-deps");
|
||||
println!(" or: npx playwright install-deps chromium");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", color::cyan("Installing Chromium browser..."));
|
||||
println!("{}", color::cyan("Installing Chrome..."));
|
||||
|
||||
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
|
||||
// and Command::new() doesn't resolve .cmd files the way the shell does.
|
||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
||||
#[cfg(windows)]
|
||||
let status = Command::new("cmd")
|
||||
.args(["/c", "npx playwright install chromium"])
|
||||
.status();
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let status = Command::new("npx")
|
||||
.args(["playwright", "install", "chromium"])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!(
|
||||
"{} Chromium installed successfully",
|
||||
color::success_indicator()
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
});
|
||||
|
||||
let (version, url) = match rt.block_on(fetch_download_url()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let dest = get_browsers_dir().join(format!("chrome-{}", version));
|
||||
|
||||
if let Some(bin) = chrome_binary_in_dir(&dest) {
|
||||
if bin.exists() {
|
||||
println!(
|
||||
"{} Chrome {} is already installed",
|
||||
color::success_indicator(),
|
||||
version
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Downloading Chrome {} for {}", version, platform_key());
|
||||
println!(" {}", url);
|
||||
|
||||
let bytes = match rt.block_on(download_bytes(&url)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match extract_zip(bytes, &dest) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"{} Chrome {} installed successfully",
|
||||
color::success_indicator(),
|
||||
version
|
||||
);
|
||||
println!(" Location: {}", dest.display());
|
||||
|
||||
if is_linux && !with_deps {
|
||||
println!();
|
||||
println!(
|
||||
@@ -171,25 +420,255 @@ pub fn run_install(with_deps: bool) {
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
eprintln!("{} Failed to install browser", color::error_indicator());
|
||||
if is_linux {
|
||||
println!(
|
||||
"{} Try installing system dependencies first:",
|
||||
color::yellow("Tip:")
|
||||
);
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
|
||||
eprintln!("Make sure Node.js is installed and npx is in your PATH");
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_install_status(status: io::Result<std::process::ExitStatus>) {
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!(
|
||||
"{} System dependencies installed",
|
||||
color::success_indicator()
|
||||
)
|
||||
}
|
||||
Ok(_) => eprintln!(
|
||||
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
||||
color::warning_indicator()
|
||||
),
|
||||
Err(e) => eprintln!(
|
||||
"{} Could not run install command: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_linux_deps() {
|
||||
println!("{}", color::cyan("Installing system dependencies..."));
|
||||
|
||||
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
||||
// On Ubuntu 24.04+, many libraries were renamed with a t64 suffix as
|
||||
// part of the 64-bit time_t transition. Using the old names can cause
|
||||
// apt to propose removing hundreds of system packages to resolve
|
||||
// conflicts. We check for the t64 variant first to avoid this.
|
||||
let apt_deps: Vec<&str> = vec![
|
||||
("libxcb-shm0", None),
|
||||
("libx11-xcb1", None),
|
||||
("libx11-6", None),
|
||||
("libxcb1", None),
|
||||
("libxext6", None),
|
||||
("libxrandr2", None),
|
||||
("libxcomposite1", None),
|
||||
("libxcursor1", None),
|
||||
("libxdamage1", None),
|
||||
("libxfixes3", None),
|
||||
("libxi6", None),
|
||||
("libgtk-3-0", Some("libgtk-3-0t64")),
|
||||
("libpangocairo-1.0-0", Some("libpangocairo-1.0-0t64")),
|
||||
("libpango-1.0-0", Some("libpango-1.0-0t64")),
|
||||
("libatk1.0-0", Some("libatk1.0-0t64")),
|
||||
("libcairo-gobject2", Some("libcairo-gobject2t64")),
|
||||
("libcairo2", Some("libcairo2t64")),
|
||||
("libgdk-pixbuf-2.0-0", Some("libgdk-pixbuf-2.0-0t64")),
|
||||
("libxrender1", None),
|
||||
("libasound2", Some("libasound2t64")),
|
||||
("libfreetype6", None),
|
||||
("libfontconfig1", None),
|
||||
("libdbus-1-3", Some("libdbus-1-3t64")),
|
||||
("libnss3", None),
|
||||
("libnspr4", None),
|
||||
("libatk-bridge2.0-0", Some("libatk-bridge2.0-0t64")),
|
||||
("libdrm2", None),
|
||||
("libxkbcommon0", None),
|
||||
("libatspi2.0-0", Some("libatspi2.0-0t64")),
|
||||
("libcups2", Some("libcups2t64")),
|
||||
("libxshmfence1", None),
|
||||
("libgbm1", None),
|
||||
// Fonts: without actual font files, pages render with missing glyphs
|
||||
// (tofu). This is especially visible for CJK and emoji characters.
|
||||
("fonts-noto-color-emoji", None),
|
||||
("fonts-noto-cjk", None),
|
||||
("fonts-freefont-ttf", None),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(base, t64_variant)| {
|
||||
if let Some(t64) = t64_variant {
|
||||
if package_exists_apt(t64) {
|
||||
return t64;
|
||||
}
|
||||
}
|
||||
base
|
||||
})
|
||||
.collect();
|
||||
|
||||
("apt-get", apt_deps)
|
||||
} else if which_exists("dnf") {
|
||||
(
|
||||
"dnf",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
"libxcb",
|
||||
"libX11-xcb",
|
||||
"libX11",
|
||||
"libXext",
|
||||
"libXcursor",
|
||||
"libXfixes",
|
||||
"libXi",
|
||||
"gtk3",
|
||||
"cairo-gobject",
|
||||
// Fonts
|
||||
"google-noto-cjk-fonts",
|
||||
"google-noto-emoji-color-fonts",
|
||||
"liberation-fonts",
|
||||
],
|
||||
)
|
||||
} else if which_exists("yum") {
|
||||
(
|
||||
"yum",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
// Fonts
|
||||
"google-noto-cjk-fonts",
|
||||
"liberation-fonts",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} No supported package manager found (apt-get, dnf, or yum)",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(1);
|
||||
};
|
||||
|
||||
if pkg_mgr == "apt-get" {
|
||||
// Run apt-get update first
|
||||
println!("Running: sudo apt-get update");
|
||||
let update_status = Command::new("sudo").args(["apt-get", "update"]).status();
|
||||
|
||||
match update_status {
|
||||
Ok(s) if !s.success() => {
|
||||
eprintln!(
|
||||
"{} apt-get update failed. Continuing with existing package lists.",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not run apt-get update: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Simulate the install first to detect if apt would remove any
|
||||
// packages. This prevents the catastrophic scenario where installing
|
||||
// these libraries triggers removal of hundreds of system packages
|
||||
// due to dependency conflicts (e.g. on Ubuntu 24.04 with the
|
||||
// t64 transition).
|
||||
println!("Checking for conflicts...");
|
||||
let sim_output = Command::new("sudo")
|
||||
.args(["apt-get", "install", "--simulate"])
|
||||
.args(&deps)
|
||||
.output();
|
||||
|
||||
match sim_output {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}\n{}", stdout, stderr);
|
||||
|
||||
// Count packages that would be removed
|
||||
let removals: Vec<&str> = combined
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Remv "))
|
||||
.collect();
|
||||
|
||||
if !removals.is_empty() {
|
||||
eprintln!(
|
||||
"{} Aborting: apt would remove {} package(s) to install these dependencies.",
|
||||
color::error_indicator(),
|
||||
removals.len()
|
||||
);
|
||||
eprintln!(
|
||||
" This usually means some package names have changed on your system"
|
||||
);
|
||||
eprintln!(" (e.g. Ubuntu 24.04 renamed libraries with a t64 suffix).");
|
||||
eprintln!();
|
||||
eprintln!(" Packages that would be removed:");
|
||||
for line in removals.iter().take(20) {
|
||||
eprintln!(" {}", line);
|
||||
}
|
||||
if removals.len() > 20 {
|
||||
eprintln!(" ... and {} more", removals.len() - 20);
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(" To install dependencies manually, run:");
|
||||
eprintln!(" sudo apt-get install {}", deps.join(" "));
|
||||
eprintln!();
|
||||
eprintln!(" Review the apt output carefully before confirming.");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not simulate install ({}). Proceeding with caution.",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Safe to proceed: no removals detected
|
||||
let install_cmd = format!("sudo apt-get install -y {}", deps.join(" "));
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sudo")
|
||||
.args(["apt-get", "install", "-y"])
|
||||
.args(&deps)
|
||||
.status();
|
||||
|
||||
report_install_status(status);
|
||||
} else {
|
||||
// dnf / yum path — these package managers do not remove packages
|
||||
// during install, so the simulate-first guard is not needed.
|
||||
let install_cmd = format!("sudo {} install -y {}", pkg_mgr, deps.join(" "));
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
||||
|
||||
report_install_status(status);
|
||||
}
|
||||
}
|
||||
|
||||
fn which_exists(cmd: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -223,3 +702,124 @@ fn package_exists_apt(pkg: &str) -> bool {
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn get_dashboard_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".agent-browser")
|
||||
.join("dashboard")
|
||||
}
|
||||
|
||||
const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
fn dashboard_download_url() -> String {
|
||||
format!(
|
||||
"https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip",
|
||||
DASHBOARD_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_dashboard_install() {
|
||||
println!("{}", color::cyan("Installing dashboard..."));
|
||||
|
||||
let dest = get_dashboard_dir();
|
||||
|
||||
if dest.join("index.html").exists() {
|
||||
println!(
|
||||
"{} Dashboard is already installed at {}",
|
||||
color::success_indicator(),
|
||||
dest.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let url = dashboard_download_url();
|
||||
println!(" Downloading dashboard v{}", DASHBOARD_VERSION);
|
||||
println!(" {}", url);
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
});
|
||||
|
||||
let bytes = match rt.block_on(download_bytes(&url)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
eprintln!(" The dashboard may not be available for this version yet.");
|
||||
eprintln!(" You can build it locally: cd packages/dashboard && pnpm build");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match extract_dashboard_zip(bytes, &dest) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"{} Dashboard v{} installed successfully",
|
||||
color::success_indicator(),
|
||||
DASHBOARD_VERSION
|
||||
);
|
||||
println!(" Location: {}", dest.display());
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_dashboard_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
|
||||
let cursor = io::Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
||||
|
||||
let enclosed = match file.enclosed_name() {
|
||||
Some(name) => name.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let rel_path = enclosed.to_string_lossy().to_string();
|
||||
|
||||
if rel_path.is_empty() || file.is_dir() {
|
||||
if file.is_dir() {
|
||||
let out_dir = dest.join(&rel_path);
|
||||
let _ = fs::create_dir_all(&out_dir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = dest.join(&rel_path);
|
||||
if !out_path.starts_with(dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create parent dir {}: {}", parent.display(), e))?;
|
||||
}
|
||||
let mut out_file = fs::File::create(&out_path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||
io::copy(&mut file, &mut out_file)
|
||||
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+914
-253
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
include!("main.rs");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,556 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: 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))
|
||||
}
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
const KEY_FILE_NAME: &str = ".encryption-key";
|
||||
|
||||
fn get_agent_browser_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key_file_path() -> PathBuf {
|
||||
get_agent_browser_dir().join(KEY_FILE_NAME)
|
||||
}
|
||||
|
||||
fn parse_key_hex(hex_str: &str) -> Option<Vec<u8>> {
|
||||
let hex_str = hex_str.trim();
|
||||
if hex_str.len() != 64 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = (0..32)
|
||||
.map(|i| u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).unwrap())
|
||||
.collect();
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Read the encryption key from AGENT_BROWSER_ENCRYPTION_KEY env var or
|
||||
/// ~/.agent-browser/.encryption-key file (matching the Node.js implementation).
|
||||
fn get_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key_hex) = std::env::var(ENCRYPTION_KEY_ENV) {
|
||||
return parse_key_hex(&key_hex).ok_or_else(|| {
|
||||
format!(
|
||||
"{} should be a 64-character hex string (256 bits). Generate one with: openssl rand -hex 32",
|
||||
ENCRYPTION_KEY_ENV
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
if key_file.exists() {
|
||||
let hex = fs::read_to_string(&key_file)
|
||||
.map_err(|e| format!("Failed to read encryption key file: {}", e))?;
|
||||
return parse_key_hex(&hex).ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid encryption key in {}. Expected 64-character hex string.",
|
||||
key_file.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Encryption key required. Set {} or ensure {} exists.",
|
||||
ENCRYPTION_KEY_ENV,
|
||||
key_file.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Ensure an encryption key exists, auto-generating one if needed.
|
||||
fn ensure_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key) = get_encryption_key() {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
getrandom::getrandom(&mut key).map_err(|e| format!("Failed to generate key: {}", e))?;
|
||||
let key_hex = key.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
|
||||
let dir = get_agent_browser_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
fs::write(&key_file, format!("{}\n", key_hex))
|
||||
.map_err(|e| format!("Failed to write encryption key: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&key_file, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[agent-browser] Auto-generated encryption key at {} -- back up this file or set {}",
|
||||
key_file.display(),
|
||||
ENCRYPTION_KEY_ENV
|
||||
);
|
||||
|
||||
Ok(key.to_vec())
|
||||
}
|
||||
|
||||
/// Encrypt a profile to the JSON+base64 format compatible with Node.js.
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<String, String> {
|
||||
let key = ensure_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 iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).map_err(|e| format!("Failed to generate IV: {}", e))?;
|
||||
|
||||
// aes_gcm appends the 16-byte auth tag to the ciphertext
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = json!({
|
||||
"version": 1,
|
||||
"encrypted": true,
|
||||
"iv": STANDARD.encode(iv),
|
||||
"authTag": STANDARD.encode(auth_tag),
|
||||
"data": STANDARD.encode(ciphertext),
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&payload)
|
||||
.map_err(|e| format!("Failed to serialize payload: {}", e))
|
||||
}
|
||||
|
||||
/// JSON envelope written by Node.js encryption (src/encryption.ts).
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedPayload {
|
||||
#[allow(dead_code)]
|
||||
version: u32,
|
||||
#[allow(dead_code)]
|
||||
encrypted: bool,
|
||||
iv: String,
|
||||
#[serde(rename = "authTag")]
|
||||
auth_tag: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
let text = std::str::from_utf8(data).map_err(|_| {
|
||||
"Profile is not valid UTF-8 -- it may use an older incompatible binary format".to_string()
|
||||
})?;
|
||||
|
||||
if let Ok(payload) = serde_json::from_str::<EncryptedPayload>(text) {
|
||||
let key = get_encryption_key()?;
|
||||
|
||||
let iv = STANDARD
|
||||
.decode(&payload.iv)
|
||||
.map_err(|e| format!("Invalid base64 iv: {}", e))?;
|
||||
let auth_tag = STANDARD
|
||||
.decode(&payload.auth_tag)
|
||||
.map_err(|e| format!("Invalid base64 authTag: {}", e))?;
|
||||
let ciphertext = STANDARD
|
||||
.decode(&payload.data)
|
||||
.map_err(|e| format!("Invalid base64 data: {}", e))?;
|
||||
|
||||
// aes_gcm expects ciphertext || auth_tag as input to decrypt
|
||||
let mut combined = Vec::with_capacity(ciphertext.len() + auth_tag.len());
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
combined.extend_from_slice(&auth_tag);
|
||||
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
|
||||
.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))?;
|
||||
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
|
||||
}
|
||||
|
||||
// Fallback: try as plain unencrypted JSON profile
|
||||
serde_json::from_str::<AuthProfile>(text)
|
||||
.map_err(|_| "Profile is not a valid encrypted or unencrypted payload".to_string())
|
||||
}
|
||||
|
||||
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
|
||||
let dir = get_auth_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create auth dir: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let encrypted_json = encrypt_profile(profile)?;
|
||||
let path = get_profile_path(&profile.name);
|
||||
fs::write(&path, &encrypted_json).map_err(|e| format!("Failed to write profile: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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,
|
||||
created_at: None,
|
||||
last_login_at: 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),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
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)]
|
||||
pub(crate) static AUTH_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_test_key<F: FnOnce()>(f: F) {
|
||||
let _lock = AUTH_TEST_MUTEX.lock().unwrap();
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
let test_key = "a".repeat(64);
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, &test_key) };
|
||||
f();
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
match original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
|
||||
#[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()),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
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() {
|
||||
with_test_key(|| {
|
||||
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,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted_json = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(encrypted_json.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_encryption_key_from_env() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xaa));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_valid() {
|
||||
let hex = "ab".repeat(32);
|
||||
let key = parse_key_hex(&hex).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_invalid() {
|
||||
assert!(parse_key_hex("too_short").is_none());
|
||||
assert!(parse_key_hex(&"g".repeat(64)).is_none());
|
||||
assert!(parse_key_hex("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_json_payload_format() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
let profile = AuthProfile {
|
||||
name: "json-test".to_string(),
|
||||
url: "https://example.com/login".to_string(),
|
||||
username: "admin".to_string(),
|
||||
password: "hunter2".to_string(),
|
||||
username_selector: Some("#email".to_string()),
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
|
||||
// Encrypt with aes_gcm, then manually build the JSON payload
|
||||
// to simulate what Node.js would produce
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).unwrap();
|
||||
let plaintext = serde_json::to_string(&profile).unwrap();
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = format!(
|
||||
r#"{{"version":1,"encrypted":true,"iv":"{}","authTag":"{}","data":"{}"}}"#,
|
||||
STANDARD.encode(iv),
|
||||
STANDARD.encode(auth_tag),
|
||||
STANDARD.encode(ciphertext),
|
||||
);
|
||||
|
||||
let decrypted = decrypt_profile(payload.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "json-test");
|
||||
assert_eq!(decrypted.password, "hunter2");
|
||||
assert_eq!(decrypted.username_selector, Some("#email".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_output_is_json_format() {
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "format-check".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&encrypted).unwrap();
|
||||
assert_eq!(parsed["version"], 1);
|
||||
assert_eq!(parsed["encrypted"], true);
|
||||
assert!(parsed["iv"].is_string());
|
||||
assert!(parsed["authTag"].is_string());
|
||||
assert!(parsed["data"].is_string());
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
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::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::{CdpCommand, CdpEvent, CdpMessage};
|
||||
|
||||
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
|
||||
|
||||
/// Interval between WebSocket ping frames sent to keep the connection alive
|
||||
/// through intermediate proxies (reverse proxies, load balancers, service meshes).
|
||||
const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
/// Raw incoming CDP message (text) broadcast to all subscribers.
|
||||
/// Used by the inspect proxy to forward responses and events to DevTools.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawCdpMessage {
|
||||
pub text: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
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>,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
_keepalive_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CdpClient {
|
||||
pub async fn connect(url: &str) -> Result<Self, String> {
|
||||
Self::connect_with_headers(url, None).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_headers(
|
||||
url: &str,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<Self, String> {
|
||||
let mut request = url
|
||||
.into_client_request()
|
||||
.map_err(|e| format!("Invalid WebSocket URL: {}", e))?;
|
||||
|
||||
if let Some(hdrs) = headers {
|
||||
let req_headers = request.headers_mut();
|
||||
for (key, value) in hdrs {
|
||||
if let (Ok(name), Ok(val)) = (
|
||||
key.parse::<tokio_tungstenite::tungstenite::http::header::HeaderName>(),
|
||||
value.parse::<tokio_tungstenite::tungstenite::http::header::HeaderValue>(),
|
||||
) {
|
||||
req_headers.insert(name, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ws_config = WebSocketConfig {
|
||||
max_message_size: None,
|
||||
max_frame_size: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (ws_stream, _) =
|
||||
tokio_tungstenite::connect_async_with_config(request, Some(ws_config), false)
|
||||
.await
|
||||
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
|
||||
|
||||
enable_tcp_keepalive(ws_stream.get_ref());
|
||||
|
||||
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 (raw_tx, _) = broadcast::channel(512);
|
||||
|
||||
let pending_clone = pending.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let raw_tx_clone = raw_tx.clone();
|
||||
|
||||
// Notify used to stop the keepalive task when the reader loop exits.
|
||||
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
|
||||
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
// Accept both Text and Binary frames — remote CDP proxies
|
||||
// (e.g. Browserless) may send responses as Binary frames.
|
||||
let msg = match msg {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Binary(data)) => match String::from_utf8(data) {
|
||||
Ok(text) => text,
|
||||
Err(_) => continue,
|
||||
},
|
||||
Ok(Message::Close(frame)) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let reason = frame
|
||||
.as_ref()
|
||||
.map(|f| format!("code={}, reason={}", f.code, f.reason))
|
||||
.unwrap_or_else(|| "no frame".to_string());
|
||||
let _ =
|
||||
writeln!(std::io::stderr(), "[cdp] WebSocket Close: {}", reason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(Message::Pong(_)) => continue,
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Error: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Broadcast raw message for inspect proxy subscribers before typed parse,
|
||||
// so messages with negative IDs (used by the inspect proxy) are still delivered.
|
||||
if raw_tx_clone.receiver_count() > 0 {
|
||||
let session_id = serde_json::from_str::<serde_json::Value>(&msg)
|
||||
.ok()
|
||||
.and_then(|v| v.get("sessionId")?.as_str().map(String::from));
|
||||
let _ = raw_tx_clone.send(RawCdpMessage {
|
||||
text: msg.clone(),
|
||||
session_id,
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: CdpMessage = match serde_json::from_str(&msg) {
|
||||
Ok(m) => m,
|
||||
// Expected for inspect proxy messages with negative IDs
|
||||
// (CdpMessage.id is u64); handled via raw broadcast above.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Reader loop exited (connection closed or error). Drop all pending
|
||||
// command senders so callers get an immediate channel-closed error
|
||||
// instead of waiting for the 30-second timeout.
|
||||
pending_clone.lock().await.clear();
|
||||
|
||||
// Stop the keepalive task — the connection is gone.
|
||||
let _ = cancel_tx.send(true);
|
||||
});
|
||||
|
||||
// Spawn a keepalive task that sends WebSocket Ping frames at a regular
|
||||
// interval. This prevents intermediate proxies (Envoy, nginx, OpenResty,
|
||||
// cloud load balancers) from closing idle WebSocket connections. If the
|
||||
// send fails, the connection is dead and we stop pinging.
|
||||
let keepalive_tx = ws_tx.clone();
|
||||
let keepalive_handle = tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(interval) => {}
|
||||
_ = cancel_rx.changed() => break,
|
||||
}
|
||||
let mut tx = keepalive_tx.lock().await;
|
||||
if tx.send(Message::Ping(Vec::new())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
ws_tx,
|
||||
next_id: AtomicU64::new(1),
|
||||
pending,
|
||||
event_tx,
|
||||
raw_tx,
|
||||
_reader_handle: reader_handle,
|
||||
_keepalive_handle: keepalive_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.filter(|s| !s.is_empty()).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()
|
||||
}
|
||||
|
||||
/// Subscribe to all raw incoming CDP messages (responses + events).
|
||||
/// Used by the inspect proxy to forward traffic to the DevTools frontend.
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Create a lightweight handle for the inspect WebSocket proxy.
|
||||
/// Contains only what's needed to forward messages bidirectionally.
|
||||
pub fn inspect_handle(&self) -> InspectProxyHandle {
|
||||
InspectProxyHandle {
|
||||
ws_tx: self.ws_tx.clone(),
|
||||
raw_tx: self.raw_tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Send raw JSON through the WebSocket without tracking a response.
|
||||
/// Used by the inspect proxy to forward DevTools frontend messages.
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
type WsTx = Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>;
|
||||
|
||||
/// Lightweight handle for the inspect WebSocket proxy, holding only
|
||||
/// the cloneable parts of CdpClient needed for bidirectional message forwarding.
|
||||
pub struct InspectProxyHandle {
|
||||
ws_tx: WsTx,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
}
|
||||
|
||||
impl InspectProxyHandle {
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable TCP SO_KEEPALIVE on the underlying socket of a WebSocket connection.
|
||||
/// This is best-effort: failures are silently ignored since the WebSocket-level
|
||||
/// Ping keepalive provides the primary connection liveness mechanism.
|
||||
fn enable_tcp_keepalive(stream: &tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>) {
|
||||
let tcp_stream = match stream {
|
||||
tokio_tungstenite::MaybeTlsStream::Plain(s) => s,
|
||||
tokio_tungstenite::MaybeTlsStream::Rustls(s) => s.get_ref().0,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// SockRef borrows the fd without taking ownership.
|
||||
let sock = socket2::SockRef::from(tcp_stream);
|
||||
let keepalive = socket2::TcpKeepalive::new().with_time(std::time::Duration::from_secs(30));
|
||||
|
||||
// with_interval sets TCP_KEEPINTVL — the time between probes after the
|
||||
// first keepalive probe goes unanswered. Available on most platforms
|
||||
// (Linux, macOS, Windows, FreeBSD, etc.) but not OpenBSD or Haiku.
|
||||
#[cfg(not(any(target_os = "openbsd", target_os = "haiku")))]
|
||||
let keepalive = keepalive.with_interval(std::time::Duration::from_secs(10));
|
||||
|
||||
let _ = sock.set_tcp_keepalive(&keepalive);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::BrowserVersionInfo;
|
||||
|
||||
/// Default timeout for CDP discovery HTTP requests.
|
||||
const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Discover the CDP WebSocket URL for the given host and port.
|
||||
///
|
||||
/// Tries three methods in order: `/json/version`, `/json/list`, and a direct
|
||||
/// WebSocket connection to `/devtools/browser`. The returned URL has its
|
||||
/// host/port rewritten to match the requested target.
|
||||
///
|
||||
/// An optional `query` string (without the leading `?`) is appended to the
|
||||
/// final WebSocket URL so that user-supplied URL parameters (e.g.
|
||||
/// `?mode=Hello`) are forwarded to the remote endpoint.
|
||||
pub async fn discover_cdp_url(
|
||||
host: &str,
|
||||
port: u16,
|
||||
query: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
discover_cdp_url_with_timeout(host, port, query, DEFAULT_DISCOVERY_TIMEOUT).await
|
||||
}
|
||||
|
||||
/// Like [`discover_cdp_url`] but with a custom request timeout.
|
||||
pub async fn discover_cdp_url_with_timeout(
|
||||
host: &str,
|
||||
port: u16,
|
||||
query: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
// Primary: /json/version (standard path)
|
||||
let version_err = match fetch_cdp_info(host, port, timeout).await {
|
||||
Ok(info) => {
|
||||
if let Some(ws_url) = info.web_socket_debugger_url {
|
||||
return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
|
||||
}
|
||||
format!(
|
||||
"No webSocketDebuggerUrl in /json/version at {}:{}",
|
||||
host, port
|
||||
)
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Fallback: /json/list (returns target list; look for the browser target)
|
||||
let list_err = match fetch_cdp_list(host, port, timeout).await {
|
||||
Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Final fallback: direct WebSocket at /devtools/browser.
|
||||
// Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
|
||||
// CDP over WebSocket but does not serve HTTP discovery endpoints.
|
||||
match discover_cdp_ws(host, port, timeout).await {
|
||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||
Err(ws_err) => Err(format!(
|
||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
||||
host, port, version_err, list_err, ws_err
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
|
||||
fn bracket_ipv6(host: &str) -> String {
|
||||
if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{}]", host)
|
||||
} else {
|
||||
host.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `/json/version` from the given host:port and parse the response.
|
||||
async fn fetch_cdp_info(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
) -> Result<BrowserVersionInfo, String> {
|
||||
let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
|
||||
|
||||
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
|
||||
.map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
|
||||
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
|
||||
}
|
||||
|
||||
/// Rewrite the host and port in a WebSocket URL to match the target we
|
||||
/// actually connected to. Chrome's `/json/version` always returns
|
||||
/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
|
||||
/// browser is on a remote machine or behind a port-forward.
|
||||
fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
|
||||
if let Ok(mut parsed) = url::Url::parse(ws_url) {
|
||||
let _ = parsed.set_host(Some(&bracket_ipv6(host)));
|
||||
let _ = parsed.set_port(Some(port));
|
||||
parsed.to_string()
|
||||
} else {
|
||||
ws_url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a query string to a URL, preserving any existing query parameters.
|
||||
fn append_query(url: &str, query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(q) if !q.is_empty() => {
|
||||
if let Ok(mut parsed) = url::Url::parse(url) {
|
||||
{
|
||||
let mut pairs = parsed.query_pairs_mut();
|
||||
pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
|
||||
}
|
||||
parsed.to_string()
|
||||
} else {
|
||||
// Fallback: raw string append
|
||||
if url.contains('?') {
|
||||
format!("{}&{}", url, q)
|
||||
} else {
|
||||
format!("{}?{}", url, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
|
||||
/// target with `type == "browser"`, or the first target if none has that type.
|
||||
async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
|
||||
let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
|
||||
|
||||
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to connect to /json/list at {}:{}: {}",
|
||||
host, port, e
|
||||
)
|
||||
})?;
|
||||
|
||||
let targets: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
|
||||
|
||||
// Prefer targets with type "browser", fall back to first target with a ws URL
|
||||
let browser_target = targets
|
||||
.iter()
|
||||
.find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
|
||||
|
||||
let target = browser_target.or_else(|| targets.first());
|
||||
|
||||
target
|
||||
.and_then(|t| t.get("webSocketDebuggerUrl"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
|
||||
}
|
||||
|
||||
/// Discover a CDP endpoint by connecting directly to `ws://host:port/devtools/browser`
|
||||
/// and verifying it responds to `Browser.getVersion`.
|
||||
/// Returns the WebSocket URL on success.
|
||||
async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
|
||||
let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let (mut ws_stream, _) = tokio_tungstenite::connect_async(&ws_url)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
|
||||
|
||||
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
|
||||
ws_stream
|
||||
.send(Message::Text(cmd.into()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send command: {}", e))?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdpReply {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
let mut result: Result<(), String> = Err("No valid CDP response received".to_string());
|
||||
while let Some(msg) = ws_stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if serde_json::from_str::<CdpReply>(&text).is_ok_and(|r| r.id == 1) {
|
||||
result = Ok(());
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ws_stream.close(None).await;
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
|
||||
.map(|()| ws_url)
|
||||
}
|
||||
|
||||
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
|
||||
resp.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const HTTP_404: &str =
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
|
||||
fn http_200(body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
}
|
||||
|
||||
async fn accept_http(listener: &TcpListener, response: &str) {
|
||||
let (mut s, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = s.read(&mut buf).await;
|
||||
s.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovers_ws_url_from_json_version() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_error_when_version_returns_invalid_json() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(&listener, &http_200("not-json")).await;
|
||||
// /json/list and ws fallback both fail (server closes)
|
||||
});
|
||||
|
||||
let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
|
||||
assert!(err.contains("Invalid /json/version response"));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_json_list_on_version_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
|
||||
).await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert!(ws_url.contains("/devtools/browser/abc"));
|
||||
assert!(ws_url.contains(&port.to_string()));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_ws_when_http_returns_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
// /json/version -> 404, /json/list -> 404
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
|
||||
// WebSocket handshake + respond to Browser.getVersion
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
if let Some(Ok(Message::Text(text))) = ws.next().await {
|
||||
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let id = req.get("id").unwrap();
|
||||
let reply = format!(
|
||||
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/136"}}}}"#,
|
||||
id
|
||||
);
|
||||
ws.send(Message::Text(reply)).await.unwrap();
|
||||
}
|
||||
let _ = ws.close(None).await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/devtools/browser", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_ws_host_replaces_host_and_port() {
|
||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
|
||||
assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_ws_host_handles_ipv6() {
|
||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let rewritten = rewrite_ws_host(original, "::1", 9222);
|
||||
assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_adds_params_to_url_without_query() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some("mode=Hello"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_merges_with_existing_query() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
|
||||
let result = append_query(url, Some("mode=Hello"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_noop_for_none() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, None);
|
||||
assert_eq!(result, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_noop_for_empty() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some(""));
|
||||
assert_eq!(result, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_handles_multiple_params() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some("mode=Hello&token=abc"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_preserves_query_params() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::discovery::discover_cdp_url_with_timeout;
|
||||
|
||||
const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const LIGHTPANDA_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const LIGHTPANDA_SESSION_TIMEOUT_SECS: u64 = 604800; // 1 week, the documented maximum
|
||||
const MAX_LOG_LINES: usize = 40;
|
||||
|
||||
pub struct LightpandaProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
_log_drainers: Vec<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LightpandaProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightpandaProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LightpandaLaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
fn build_lightpanda_serve_args(port: u16, proxy: Option<&str>) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--timeout".to_string(),
|
||||
LIGHTPANDA_SESSION_TIMEOUT_SECS.to_string(),
|
||||
];
|
||||
|
||||
if let Some(proxy) = proxy {
|
||||
args.push("--http_proxy".to_string());
|
||||
args.push(proxy.to_string());
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct LaunchLogBuffer {
|
||||
stdout: Arc<Mutex<VecDeque<String>>>,
|
||||
stderr: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl LaunchLogBuffer {
|
||||
fn push_stdout(&self, line: String) {
|
||||
push_bounded(&self.stdout, line);
|
||||
}
|
||||
|
||||
fn push_stderr(&self, line: String) {
|
||||
push_bounded(&self.stderr, line);
|
||||
}
|
||||
|
||||
fn snapshot_stdout(&self) -> Vec<String> {
|
||||
self.stdout
|
||||
.lock()
|
||||
.expect("stdout log buffer poisoned")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot_stderr(&self) -> Vec<String> {
|
||||
self.stderr
|
||||
.lock()
|
||||
.expect("stderr log buffer poisoned")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn push_bounded(buffer: &Mutex<VecDeque<String>>, line: String) {
|
||||
let mut guard = buffer.lock().expect("log buffer poisoned");
|
||||
if guard.len() >= MAX_LOG_LINES {
|
||||
guard.pop_front();
|
||||
}
|
||||
guard.push_back(line);
|
||||
}
|
||||
|
||||
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("lightpanda").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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let candidates = [
|
||||
home.join(".lightpanda/lightpanda"),
|
||||
home.join(".local/bin/lightpanda"),
|
||||
];
|
||||
for c in &candidates {
|
||||
if c.exists() {
|
||||
return Some(c.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn launch_lightpanda(
|
||||
options: &LightpandaLaunchOptions,
|
||||
) -> Result<LightpandaProcess, String> {
|
||||
let binary_path = match &options.executable_path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => find_lightpanda().ok_or(
|
||||
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
|
||||
)?,
|
||||
};
|
||||
|
||||
let port = match options.port {
|
||||
Some(p) => p,
|
||||
None => TcpListener::bind("127.0.0.1:0")
|
||||
.and_then(|l| l.local_addr())
|
||||
.map(|a| a.port())
|
||||
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
||||
};
|
||||
let args = build_lightpanda_serve_args(port, options.proxy.as_deref());
|
||||
|
||||
let mut child = Command::new(&binary_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
||||
|
||||
let (log_buffer, log_drainers) = start_log_drainers(&mut child)?;
|
||||
|
||||
let ws_url =
|
||||
match wait_for_lightpanda_ready(&mut child, port, &log_buffer, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LightpandaProcess {
|
||||
child,
|
||||
ws_url,
|
||||
_log_drainers: log_drainers,
|
||||
})
|
||||
}
|
||||
|
||||
fn start_log_drainers(
|
||||
child: &mut Child,
|
||||
) -> Result<(LaunchLogBuffer, Vec<std::thread::JoinHandle<()>>), String> {
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stdout".to_string()
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stderr".to_string()
|
||||
})?;
|
||||
|
||||
let logs = LaunchLogBuffer::default();
|
||||
let stdout_logs = logs.clone();
|
||||
let stderr_logs = logs.clone();
|
||||
|
||||
let stdout_handle =
|
||||
std::thread::spawn(move || drain_reader(stdout, move |line| stdout_logs.push_stdout(line)));
|
||||
let stderr_handle =
|
||||
std::thread::spawn(move || drain_reader(stderr, move |line| stderr_logs.push_stderr(line)));
|
||||
|
||||
Ok((logs, vec![stdout_handle, stderr_handle]))
|
||||
}
|
||||
|
||||
fn drain_reader<R, F>(reader: R, mut push: F)
|
||||
where
|
||||
R: std::io::Read,
|
||||
F: FnMut(String),
|
||||
{
|
||||
for line in BufReader::new(reader).lines() {
|
||||
match line {
|
||||
Ok(line) => push(line),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_lightpanda_ready(
|
||||
child: &mut Child,
|
||||
port: u16,
|
||||
logs: &LaunchLogBuffer,
|
||||
startup_timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let deadline = std::time::Instant::now() + startup_timeout;
|
||||
let mut last_probe_error = None;
|
||||
|
||||
loop {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
// Give the drainer threads a brief window to flush the last log lines
|
||||
// before we snapshot them. This is best-effort: lines written just
|
||||
// before exit may still be missing, but the most useful output (early
|
||||
// startup errors) will already be in the buffer.
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
return Err(lightpanda_launch_error(
|
||||
&format!(
|
||||
"Lightpanda exited before CDP became ready (status: {})",
|
||||
status
|
||||
),
|
||||
logs,
|
||||
last_probe_error.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
match discover_cdp_url_with_timeout("127.0.0.1", port, None, LIGHTPANDA_DISCOVERY_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(ws_url) => return Ok(ws_url),
|
||||
Err(err) => last_probe_error = Some(err),
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(lightpanda_launch_error(
|
||||
&format!(
|
||||
"Timed out after {}ms waiting for Lightpanda CDP endpoint on port {}",
|
||||
startup_timeout.as_millis(),
|
||||
port
|
||||
),
|
||||
logs,
|
||||
last_probe_error.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
tokio::time::sleep(LIGHTPANDA_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn lightpanda_launch_error(
|
||||
message: &str,
|
||||
logs: &LaunchLogBuffer,
|
||||
last_probe_error: Option<&str>,
|
||||
) -> String {
|
||||
let stdout_lines = logs.snapshot_stdout();
|
||||
let stderr_lines = logs.snapshot_stderr();
|
||||
let mut details = Vec::new();
|
||||
|
||||
if let Some(err) = last_probe_error {
|
||||
details.push(format!("Last probe error: {}", err));
|
||||
}
|
||||
|
||||
if !stderr_lines.is_empty() {
|
||||
details.push(format!(
|
||||
"Lightpanda stderr (last {} lines):\n {}",
|
||||
stderr_lines.len(),
|
||||
stderr_lines.join("\n ")
|
||||
));
|
||||
}
|
||||
|
||||
if !stdout_lines.is_empty() {
|
||||
details.push(format!(
|
||||
"Lightpanda stdout (last {} lines):\n {}",
|
||||
stdout_lines.len(),
|
||||
stdout_lines.join("\n ")
|
||||
));
|
||||
}
|
||||
|
||||
if details.is_empty() {
|
||||
format!("{} (no stdout/stderr output from Lightpanda)", message)
|
||||
} else {
|
||||
format!("{}\n{}", message, details.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener as TokioTcpListener;
|
||||
|
||||
fn unused_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) {
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = socket.read(&mut buf).await;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn waits_for_ready_without_logs() {
|
||||
let port = unused_port();
|
||||
tokio::spawn(serve_json_version_once_after_delay(
|
||||
port,
|
||||
150,
|
||||
r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#,
|
||||
));
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 5"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let ws_url = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn child_exit_surfaces_logs() {
|
||||
let port = unused_port();
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "echo boom >&2; sleep 0.1; exit 23"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let err = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Lightpanda exited before CDP became ready"));
|
||||
assert!(err.contains("boom"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn timeout_reports_last_probe_error() {
|
||||
let port = unused_port();
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 30"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let timeout = Duration::from_millis(300);
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
wait_for_lightpanda_ready(&mut child, port, &logs, timeout),
|
||||
)
|
||||
.await
|
||||
.expect("ready wait should return before outer timeout")
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Timed out after 300ms waiting for Lightpanda CDP endpoint"));
|
||||
assert!(
|
||||
err.contains("Failed to connect to CDP") || err.contains("Timeout connecting to CDP")
|
||||
);
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lightpanda_returns_none_when_missing() {
|
||||
let _ = find_lightpanda();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_no_logs() {
|
||||
let logs = LaunchLogBuffer::default();
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &logs, None);
|
||||
assert!(msg.contains("no stdout/stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_with_lines() {
|
||||
let logs = LaunchLogBuffer::default();
|
||||
logs.push_stdout("stdout line".to_string());
|
||||
logs.push_stderr("stderr line".to_string());
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &logs, Some("connect failed"));
|
||||
assert!(msg.contains("stdout line"));
|
||||
assert!(msg.contains("stderr line"));
|
||||
assert!(msg.contains("Last probe error: connect failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_options() {
|
||||
let opts = LightpandaLaunchOptions::default();
|
||||
assert!(opts.executable_path.is_none());
|
||||
assert!(opts.proxy.is_none());
|
||||
assert!(opts.port.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lightpanda_serve_args_sets_explicit_session_timeout() {
|
||||
let args = build_lightpanda_serve_args(9222, None);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"9222".to_string(),
|
||||
"--timeout".to_string(),
|
||||
"604800".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lightpanda_serve_args_with_proxy() {
|
||||
let args = build_lightpanda_serve_args(9333, Some("http://127.0.0.1:8080"));
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"9333".to_string(),
|
||||
"--timeout".to_string(),
|
||||
"604800".to_string(),
|
||||
"--http_proxy".to_string(),
|
||||
"http://127.0.0.1:8080".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod discovery;
|
||||
pub mod lightpanda;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,586 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Deserialize a value that may be either a string or an integer into a String.
|
||||
/// Lightpanda sends numeric nodeIds/childIds in AX tree responses, while Chrome
|
||||
/// sends strings. This accepts both.
|
||||
fn string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
Value::String(s) => Ok(s),
|
||||
Value::Number(n) => Ok(n.to_string()),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"expected string or integer, got {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize an optional Vec where each element may be a string or integer.
|
||||
fn opt_vec_string_or_int<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<Vec<Value>> = Option::deserialize(deserializer)?;
|
||||
match opt {
|
||||
None => Ok(None),
|
||||
Some(vec) => {
|
||||
let mut result = Vec::with_capacity(vec.len());
|
||||
for v in vec {
|
||||
match v {
|
||||
Value::String(s) => result.push(s),
|
||||
Value::Number(n) => result.push(n.to_string()),
|
||||
other => {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"expected string or integer in array, got {}",
|
||||
other
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>,
|
||||
pub preview: Option<Value>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
#[serde(deserialize_with = "string_or_int")]
|
||||
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>>,
|
||||
#[serde(default, deserialize_with = "opt_vec_string_or_int")]
|
||||
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::*;`
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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_all_cookies(client: &CdpClient, session_id: &str) -> Result<Vec<Cookie>, String> {
|
||||
let result = client
|
||||
.send_command_no_params("Network.getAllCookies", 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 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,575 @@
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::state;
|
||||
use super::stream::StreamServer;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// When debug mode is on, redirect stderr to a log file so daemon
|
||||
// output can be inspected (the daemon normally has stderr piped to its
|
||||
// parent which drops the read end after startup).
|
||||
#[cfg(unix)]
|
||||
if env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let log_path = socket_dir.join(format!("{}.log", session));
|
||||
if let Ok(file) = fs::File::create(&log_path) {
|
||||
use std::os::unix::io::IntoRawFd;
|
||||
let fd = file.into_raw_fd();
|
||||
unsafe {
|
||||
libc::dup2(fd, 2);
|
||||
libc::close(fd);
|
||||
}
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[daemon] Debug logging started for session: {}",
|
||||
session
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
// On Unix the daemon listens on a Unix domain socket; on Windows it uses
|
||||
// TCP, so there is no .sock file — only a .port file written by the server.
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
|
||||
#[cfg(unix)]
|
||||
if socket_path.exists() {
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
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 mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
|
||||
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
|
||||
let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
match StreamServer::start_without_client(preferred_port, session.to_string(), true).await {
|
||||
Ok((stream_server, client_slot)) => {
|
||||
stream_client = Some(client_slot.clone());
|
||||
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to write .stream file: {}", e);
|
||||
}
|
||||
stream_server_instance = Some(Arc::new(stream_server));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-shutdown the daemon after this many ms of inactivity (no commands received).
|
||||
// Disabled when unset or 0.
|
||||
let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|&ms| ms > 0);
|
||||
|
||||
let result = run_socket_server(
|
||||
&socket_path,
|
||||
session,
|
||||
stream_client,
|
||||
stream_server_instance,
|
||||
idle_timeout_ms,
|
||||
)
|
||||
.await;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_socket_server(
|
||||
socket_path: &PathBuf,
|
||||
session: &str,
|
||||
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
let listener =
|
||||
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
|
||||
|
||||
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||
let dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
Some(dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
||||
);
|
||||
|
||||
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
|
||||
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
|
||||
|
||||
let mut drain_interval = tokio::time::interval(Duration::from_millis(500));
|
||||
drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||
let mut sleep_pin = sleep_future.map(Box::pin);
|
||||
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let reset_tx = reset_tx.clone();
|
||||
let sf = stream_file.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = drain_interval.tick() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
if mgr.has_process_exited() {
|
||||
let _ = mgr.close().await;
|
||||
s.browser = None;
|
||||
s.screencasting = false;
|
||||
s.update_stream_client().await;
|
||||
} else {
|
||||
s.drain_cdp_events_background().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut s) = sleep_pin {
|
||||
s.as_mut().await
|
||||
} else {
|
||||
std::future::pending::<()>().await
|
||||
}
|
||||
}, if idle_timeout_ms.is_some() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
|
||||
continue;
|
||||
}
|
||||
_ = 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,
|
||||
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let preferred_port = get_port_for_session(session);
|
||||
// Try the hash-derived port first; if it is blocked (e.g. Windows Hyper-V
|
||||
// excluded port range), fall back to an OS-assigned ephemeral port.
|
||||
let listener = match TcpListener::bind(format!("127.0.0.1:{}", preferred_port)).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind TCP: {}", e))?,
|
||||
};
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local address: {}", e))?
|
||||
.port();
|
||||
|
||||
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, actual_port.to_string());
|
||||
|
||||
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||
Some(socket_dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
||||
);
|
||||
|
||||
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
|
||||
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
|
||||
|
||||
loop {
|
||||
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||
let mut sleep_pin = sleep_future.map(Box::pin);
|
||||
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let reset_tx = reset_tx.clone();
|
||||
let sf = stream_file.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut s) = sleep_pin {
|
||||
s.as_mut().await
|
||||
} else {
|
||||
std::future::pending::<()>().await
|
||||
}
|
||||
}, if idle_timeout_ms.is_some() => {
|
||||
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;
|
||||
}
|
||||
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
|
||||
continue;
|
||||
}
|
||||
_ = 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>>,
|
||||
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
|
||||
stream_file_cleanup: Option<PathBuf>,
|
||||
) 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;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref tx) = idle_reset_tx {
|
||||
let _ = tx.try_send(());
|
||||
}
|
||||
|
||||
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 {
|
||||
if let Some(ref path) = stream_file_cleanup {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
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) => {
|
||||
let _ = writeln!(std::io::stderr(), "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) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"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) => {
|
||||
let _ = writeln!(std::io::stderr(), "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 {
|
||||
let _ = writeln!(std::io::stderr(), "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: i32 = 0;
|
||||
for c in session.chars() {
|
||||
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
|
||||
}
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_port_matches_client_algorithm() {
|
||||
assert_eq!(get_port_for_session("default"), 50838);
|
||||
assert_eq!(get_port_for_session("my-session"), 63105);
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
|
||||
/// Guard against re-introducing `waitpid(-1)` in daemon code.
|
||||
///
|
||||
/// Issue #1035: a SIGCHLD handler that called `waitpid(-1, WNOHANG)` was
|
||||
/// added in v0.22.3 to reap zombie Chrome processes. This races with
|
||||
/// Rust's `Child::try_wait()` / `Child::wait()` because `waitpid(-1)`
|
||||
/// reaps *any* child, stealing the exit status before Rust can collect
|
||||
/// it. The result is ECHILD errors in `BrowserManager::has_process_exited()`
|
||||
/// and `ChromeProcess::kill()`, which can leave the daemon in a broken
|
||||
/// state or cause hangs on certain Linux configurations.
|
||||
///
|
||||
/// The fix uses the existing 500ms drain interval to call
|
||||
/// `has_process_exited()` (which delegates to `Child::try_wait()`)
|
||||
/// for targeted, race-free zombie detection.
|
||||
#[test]
|
||||
fn test_no_waitpid_minus_one_in_daemon() {
|
||||
let source = include_str!("daemon.rs");
|
||||
// Only check production code (everything before `#[cfg(test)]`)
|
||||
let production_code = source.split("#[cfg(test)]").next().unwrap_or(source);
|
||||
assert!(
|
||||
!production_code.contains("waitpid(-1"),
|
||||
"daemon.rs production code must not call waitpid(-1, ...). \
|
||||
Use Child::try_wait() via has_process_exited() instead. \
|
||||
See issue #1035."
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that `Child::try_wait()` correctly detects a crashed child
|
||||
/// without needing a global SIGCHLD handler or `waitpid(-1)`.
|
||||
/// This is what `has_process_exited()` uses in the fixed code.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_child_try_wait_detects_exit_without_sigchld_handler() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "exit 42"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
assert!(
|
||||
!status.success(),
|
||||
"child exited with code 42, should not be success"
|
||||
);
|
||||
}
|
||||
Ok(None) => panic!("try_wait() returned None but child should have exited"),
|
||||
Err(e) => panic!("try_wait() should succeed without waitpid(-1): {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `ChromeProcess::has_exited()` (which uses `Child::try_wait()`)
|
||||
/// correctly detects a killed child, the same way the drain interval does
|
||||
/// in the fixed daemon code. This ensures crash detection works without
|
||||
/// a SIGCHLD handler.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_has_exited_detects_killed_process() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 60"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
|
||||
// Process should be running
|
||||
match child.try_wait() {
|
||||
Ok(None) => {} // expected
|
||||
other => panic!("expected Ok(None) for running process, got {:?}", other),
|
||||
}
|
||||
|
||||
// Kill it (simulates Chrome crash)
|
||||
child.kill().expect("failed to kill child");
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
// try_wait should detect the exit
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {} // expected: detected the crash
|
||||
other => panic!(
|
||||
"expected Ok(Some(_)) after kill, got {:?}. \
|
||||
Crash detection via try_wait() must work for the drain \
|
||||
interval fix (issue #1035) to function correctly.",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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 {
|
||||
// Fast path: identical inputs.
|
||||
// This avoids constructing the `similar` TextDiff object and running the diff
|
||||
// iteration when agents compare a snapshot to itself (common in retry/loop
|
||||
// workloads).
|
||||
if before == after {
|
||||
let unchanged = before.lines().count();
|
||||
return SnapshotDiffResult {
|
||||
diff: String::new(),
|
||||
additions: 0,
|
||||
removals: 0,
|
||||
unchanged,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_snapshots_identical_fast_path() {
|
||||
let input = "hello\nworld\n";
|
||||
let result = diff_snapshots(input, input);
|
||||
assert!(!result.changed);
|
||||
assert_eq!(result.additions, 0);
|
||||
assert_eq!(result.removals, 0);
|
||||
assert_eq!(result.unchanged, input.lines().count());
|
||||
assert!(result.diff.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_diff_snapshots_identical_and_changed() {
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
let identical_a = (0..200)
|
||||
.map(|i| format!("line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let identical_b = identical_a.clone();
|
||||
|
||||
let changed_a = identical_a.clone();
|
||||
let changed_b = (0..200)
|
||||
.map(|i| {
|
||||
if i == 123 {
|
||||
format!("line {i} changed")
|
||||
} else {
|
||||
format!("line {i}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// Keep the iteration count high enough to measure, but low enough
|
||||
// to avoid long CI times when someone runs `--ignored`.
|
||||
let iters = 50_000usize;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut acc_changed = 0usize;
|
||||
for _ in 0..iters {
|
||||
let r = diff_snapshots(black_box(&identical_a), black_box(&identical_b));
|
||||
acc_changed ^= r.unchanged;
|
||||
}
|
||||
let identical_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut acc_changed2 = 0usize;
|
||||
for _ in 0..iters {
|
||||
let r = diff_snapshots(black_box(&changed_a), black_box(&changed_b));
|
||||
acc_changed2 ^= r.additions;
|
||||
}
|
||||
let changed_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// Prevent the compiler from optimizing everything away.
|
||||
black_box(acc_changed);
|
||||
black_box(acc_changed2);
|
||||
|
||||
println!(
|
||||
"bench_diff_snapshots_identical_and_changed: iters={iters} identical_ms={identical_ms:.2} changed_ms={changed_ms:.2}"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::cdp::client::InspectProxyHandle;
|
||||
|
||||
/// Counter for unique attach IDs so concurrent connections don't collide.
|
||||
static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000);
|
||||
|
||||
/// Lightweight HTTP + WebSocket server for `agent-browser inspect`.
|
||||
///
|
||||
/// Serves two purposes:
|
||||
/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server
|
||||
/// - WebSocket connections create a dedicated CDP session via `Target.attachToTarget` and proxy
|
||||
/// CDP messages through the daemon's existing browser-level connection, injecting/stripping
|
||||
/// `sessionId` so the DevTools frontend sees a page-level view
|
||||
pub struct InspectServer {
|
||||
port: u16,
|
||||
_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl InspectServer {
|
||||
/// Start the inspect proxy server.
|
||||
///
|
||||
/// - `proxy_handle`: lightweight handle for sending/receiving raw CDP messages
|
||||
/// - `target_id`: the CDP target ID of the page to inspect
|
||||
/// - `chrome_host_port`: the Chrome debug server address (e.g. "127.0.0.1:9222")
|
||||
pub async fn start(
|
||||
proxy_handle: InspectProxyHandle,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
) -> Result<Self, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind inspect server: {}", e))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local addr: {}", e))?
|
||||
.port();
|
||||
|
||||
let proxy = Arc::new(proxy_handle);
|
||||
|
||||
let handle = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
proxy,
|
||||
target_id,
|
||||
chrome_host_port,
|
||||
port,
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
self._handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let proxy = proxy.clone();
|
||||
let tid = target_id.clone();
|
||||
let chp = chrome_host_port.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await {
|
||||
let _ = writeln!(std::io::stderr(), "[inspect] connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
// Peek at the request line to determine routing WITHOUT consuming bytes.
|
||||
// This is critical: tokio_tungstenite::accept_async needs to read the full
|
||||
// HTTP upgrade request itself, so we must not consume anything for WS paths.
|
||||
let mut peek_buf = [0u8; 32];
|
||||
let n = stream
|
||||
.peek(&mut peek_buf)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let peek = String::from_utf8_lossy(&peek_buf[..n]);
|
||||
|
||||
if peek.starts_with("GET /ws") {
|
||||
return handle_ws_proxy(stream, proxy, target_id).await;
|
||||
}
|
||||
|
||||
if peek.starts_with("GET / ") {
|
||||
let buf_reader = BufReader::new(stream);
|
||||
return handle_http_redirect(buf_reader, chrome_host_port, proxy_port).await;
|
||||
}
|
||||
|
||||
// Unknown request -- consume and respond 404
|
||||
let mut stream = stream;
|
||||
let mut discard = [0u8; 4096];
|
||||
let _ = stream.read(&mut discard).await;
|
||||
let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MAX_HEADER_BYTES: usize = 8192;
|
||||
|
||||
async fn handle_http_redirect(
|
||||
buf_reader: BufReader<tokio::net::TcpStream>,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let mut br = buf_reader;
|
||||
let mut total_bytes = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = br.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||
total_bytes += n;
|
||||
if line == "\r\n" || line == "\n" || line.is_empty() || total_bytes > MAX_HEADER_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let location = format!(
|
||||
"http://{}/devtools/devtools_app.html?ws=127.0.0.1:{}/ws",
|
||||
chrome_host_port, proxy_port
|
||||
);
|
||||
let body = format!(
|
||||
"<html><body>Redirecting to <a href=\"{url}\">{url}</a></body></html>",
|
||||
url = location
|
||||
);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
location,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let mut stream = br.into_inner();
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_ws_proxy(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
) -> Result<(), String> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket handshake failed: {}", e))?;
|
||||
|
||||
// Create a dedicated CDP session for this DevTools connection.
|
||||
// Each connection gets its own session so domain enablements (DOM.enable, etc.)
|
||||
// always trigger fresh initial state dumps from Chrome.
|
||||
let attach_id = ATTACH_ID.fetch_sub(1, Ordering::SeqCst);
|
||||
let attach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.attachToTarget","params":{{"targetId":"{}","flatten":true}}}}"#,
|
||||
attach_id, target_id
|
||||
);
|
||||
|
||||
// Subscribe BEFORE sending so we don't miss the response (tokio broadcast
|
||||
// receivers only deliver messages to receivers that already exist).
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
|
||||
proxy
|
||||
.send_raw(attach_cmd)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send attachToTarget: {}", e))?;
|
||||
|
||||
// Wait for the attachToTarget response to extract the session ID
|
||||
let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while let Ok(raw_msg) = raw_rx.recv().await {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw_msg.text) {
|
||||
if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) {
|
||||
if let Some(sid) = val
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
{
|
||||
return Ok(sid.to_string());
|
||||
}
|
||||
return Err("attachToTarget failed".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("raw message channel closed".to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Timed out waiting for attachToTarget response".to_string())?
|
||||
.map_err(|e| format!("Failed to create DevTools session: {}", e))?;
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
let ws_tx_clone = ws_tx.clone();
|
||||
let session_id_clone = session_id.clone();
|
||||
|
||||
// Chrome -> DevTools: forward messages matching our session, strip sessionId
|
||||
let mut chrome_to_devtools = tokio::spawn(async move {
|
||||
loop {
|
||||
let raw_msg = match raw_rx.recv().await {
|
||||
Ok(msg) => msg,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[inspect] warning: dropped {} CDP messages (channel lag)",
|
||||
n
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if raw_msg.session_id.as_deref() != Some(&session_id_clone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stripped = strip_session_id(&raw_msg.text);
|
||||
|
||||
let mut tx = ws_tx_clone.lock().await;
|
||||
if tx.send(Message::Text(stripped)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DevTools -> Chrome: inject sessionId and forward
|
||||
let proxy_for_send = proxy.clone();
|
||||
let session_id_for_send = session_id.clone();
|
||||
let mut devtools_to_chrome = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let injected = inject_session_id(&text, &session_id_for_send);
|
||||
if proxy_for_send.send_raw(injected).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut chrome_to_devtools => {
|
||||
devtools_to_chrome.abort();
|
||||
},
|
||||
_ = &mut devtools_to_chrome => {
|
||||
chrome_to_devtools.abort();
|
||||
},
|
||||
}
|
||||
|
||||
// Clean up the CDP session so Chrome doesn't leak attached targets
|
||||
let detach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.detachFromTarget","params":{{"sessionId":"{}"}}}}"#,
|
||||
ATTACH_ID.fetch_sub(1, Ordering::SeqCst),
|
||||
session_id
|
||||
);
|
||||
let _ = proxy.send_raw(detach_cmd).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inject_session_id(json: &str, session_id: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert(
|
||||
"sessionId".to_string(),
|
||||
serde_json::Value::String(session_id.to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_session_id(json: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.remove("sessionId");
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id() {
|
||||
let input = r#"{"id":1,"method":"DOM.getDocument"}"#;
|
||||
let result = inject_session_id(input, "abc123");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc123");
|
||||
assert_eq!(parsed["method"], "DOM.getDocument");
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id_empty_object() {
|
||||
let result = inject_session_id("{}", "abc");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_session_id() {
|
||||
let input = r#"{"id":1,"result":{},"sessionId":"abc123"}"#;
|
||||
let result = strip_session_id(input);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert!(parsed.get("sessionId").is_none());
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_then_strip_roundtrip() {
|
||||
let input = r#"{"id":42,"method":"Runtime.evaluate"}"#;
|
||||
let injected = inject_session_id(input, "sess1");
|
||||
let stripped = strip_session_id(&injected);
|
||||
let original: serde_json::Value = serde_json::from_str(input).unwrap();
|
||||
let result: serde_json::Value = serde_json::from_str(&stripped).unwrap();
|
||||
assert_eq!(original, result);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
#[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 inspect_server;
|
||||
#[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 stealth;
|
||||
#[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,672 @@
|
||||
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, location.href);
|
||||
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,
|
||||
handle_auth_requests: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut params = json!({
|
||||
"patterns": [{ "urlPattern": "*" }]
|
||||
});
|
||||
if handle_auth_requests {
|
||||
params["handleAuthRequests"] = json!(true);
|
||||
}
|
||||
client
|
||||
.send_command("Fetch.enable", Some(params), 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],
|
||||
handle_auth_requests: bool,
|
||||
) -> Result<(), String> {
|
||||
install_domain_filter_script(client, session_id, allowed_domains).await?;
|
||||
install_domain_filter_fetch(client, session_id, handle_auth_requests).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console arg formatting (CDP RemoteObject → human-readable string)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a single CDP RemoteObject arg into a human-readable string.
|
||||
/// Priority: value → preview → description.
|
||||
pub fn format_console_arg(arg: &Value) -> Option<String> {
|
||||
let obj_type = arg.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let subtype = arg.get("subtype").and_then(|v| v.as_str());
|
||||
|
||||
if obj_type == "undefined" {
|
||||
return Some("undefined".to_string());
|
||||
}
|
||||
|
||||
if subtype == Some("null") {
|
||||
return Some("null".to_string());
|
||||
}
|
||||
|
||||
// Primitive value
|
||||
if let Some(v) = arg.get("value") {
|
||||
return Some(match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "null".to_string(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Skip preview for Map/Set — their description ("Map(1)", "Set(3)") is more useful
|
||||
// than their preview properties (which only show "size")
|
||||
if let Some(preview) = arg.get("preview") {
|
||||
let preview_subtype = preview.get("subtype").and_then(|v| v.as_str());
|
||||
if matches!(preview_subtype, Some("map" | "set" | "weakmap" | "weakset")) {
|
||||
return arg
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
let is_array = subtype == Some("array") || preview_subtype == Some("array");
|
||||
if let Some(props) = preview.get("properties").and_then(|v| v.as_array()) {
|
||||
let overflow = preview
|
||||
.get("overflow")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let formatted_props: Vec<String> = props
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let value_str = p.get("value").and_then(|v| v.as_str())?;
|
||||
let prop_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let formatted_value = if prop_type == "string" {
|
||||
format!("\"{}\"", value_str)
|
||||
} else {
|
||||
value_str.to_string()
|
||||
};
|
||||
if is_array {
|
||||
Some(formatted_value)
|
||||
} else {
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
Some(format!("{}: {}", name, formatted_value))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let inner = if overflow {
|
||||
format!("{}, ...", formatted_props.join(", "))
|
||||
} else {
|
||||
formatted_props.join(", ")
|
||||
};
|
||||
|
||||
return if is_array {
|
||||
Some(format!("[{}]", inner))
|
||||
} else {
|
||||
Some(format!("{{{}}}", inner))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to description
|
||||
arg.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Format an array of CDP RemoteObject args into a single space-separated string.
|
||||
pub fn format_console_args(args: &[Value]) -> String {
|
||||
args.iter()
|
||||
.filter_map(format_console_arg)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console and error tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConsoleEntry {
|
||||
pub level: String,
|
||||
pub text: String,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
#[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, args: Vec<Value>) {
|
||||
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(),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
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 clear_console(&mut self) {
|
||||
self.console_entries.clear();
|
||||
}
|
||||
|
||||
pub fn get_console_json(&self) -> Value {
|
||||
let messages: Vec<Value> = self
|
||||
.console_entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut msg = json!({ "type": e.level, "text": e.text });
|
||||
if !e.args.is_empty() {
|
||||
msg.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("args".to_string(), Value::Array(e.args.clone()));
|
||||
}
|
||||
msg
|
||||
})
|
||||
.collect();
|
||||
json!({ "messages": messages })
|
||||
}
|
||||
|
||||
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", vec![]);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_json_includes_args() {
|
||||
let mut tracker = EventTracker::new();
|
||||
let raw_args = vec![
|
||||
json!({"type": "string", "value": "hello"}),
|
||||
json!({"type": "number", "value": 42}),
|
||||
];
|
||||
tracker.add_console("log", "hello 42", raw_args);
|
||||
|
||||
let result = tracker.get_console_json();
|
||||
let messages = result.get("messages").unwrap().as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].get("text").unwrap(), "hello 42");
|
||||
let args = messages[0].get("args").unwrap().as_array().unwrap();
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(args[0], json!({"type": "string", "value": "hello"}));
|
||||
assert_eq!(args[1], json!({"type": "number", "value": 42}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_json_empty_args_omits_field() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "text only", vec![]);
|
||||
|
||||
let result = tracker.get_console_json();
|
||||
let messages = result.get("messages").unwrap().as_array().unwrap();
|
||||
assert!(messages[0].get("args").is_none());
|
||||
}
|
||||
|
||||
// -- format_console_arg: primitives --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_string() {
|
||||
let arg = json!({"type": "string", "value": "hello"});
|
||||
assert_eq!(format_console_arg(&arg), Some("hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_number() {
|
||||
let arg = json!({"type": "number", "value": 42});
|
||||
assert_eq!(format_console_arg(&arg), Some("42".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_null() {
|
||||
let arg = json!({"type": "object", "subtype": "null", "value": null});
|
||||
assert_eq!(format_console_arg(&arg), Some("null".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_undefined() {
|
||||
let arg = json!({"type": "undefined"});
|
||||
assert_eq!(format_console_arg(&arg), Some("undefined".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: objects with preview --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_object_preview() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [
|
||||
{"name": "userId", "type": "string", "value": "abc123"},
|
||||
{"name": "count", "type": "number", "value": "42"}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
format_console_arg(&arg),
|
||||
Some("{userId: \"abc123\", count: 42}".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_object_preview_overflow() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [
|
||||
{"name": "a", "type": "number", "value": "1"}
|
||||
],
|
||||
"overflow": true
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("{a: 1, ...}".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: arrays with preview --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_array_preview() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"subtype": "array",
|
||||
"preview": {
|
||||
"subtype": "array",
|
||||
"properties": [
|
||||
{"name": "0", "type": "number", "value": "1"},
|
||||
{"name": "1", "type": "number", "value": "2"},
|
||||
{"name": "2", "type": "number", "value": "3"}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("[1, 2, 3]".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: map/set use description --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_map_uses_description() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"subtype": "map",
|
||||
"description": "Map(1)",
|
||||
"preview": {
|
||||
"subtype": "map",
|
||||
"properties": [{"name": "size", "type": "number", "value": "1"}]
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("Map(1)".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: fallback --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_description_fallback() {
|
||||
let arg = json!({"type": "object", "description": "RegExp"});
|
||||
assert_eq!(format_console_arg(&arg), Some("RegExp".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_no_value_no_preview_no_description() {
|
||||
let arg = json!({"type": "object"});
|
||||
assert_eq!(format_console_arg(&arg), None);
|
||||
}
|
||||
|
||||
// -- format_console_args --
|
||||
|
||||
#[test]
|
||||
fn test_format_console_args_join() {
|
||||
let args = vec![
|
||||
json!({"type": "string", "value": "user"}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [{"name": "id", "type": "number", "value": "1"}],
|
||||
"overflow": false
|
||||
}
|
||||
}),
|
||||
];
|
||||
assert_eq!(format_console_args(&args), "user {id: 1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_console_args_filters_none() {
|
||||
// An arg that returns None should be skipped, not produce empty string
|
||||
let args = vec![
|
||||
json!({"type": "string", "value": "before"}),
|
||||
json!({"type": "object"}), // no value, preview, or description → None
|
||||
json!({"type": "string", "value": "after"}),
|
||||
];
|
||||
assert_eq!(format_console_args(&args), "before after");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
//! 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};
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
|
||||
struct TestKeyGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl TestKeyGuard {
|
||||
fn new() -> Self {
|
||||
let lock = super::auth::AUTH_TEST_MUTEX
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
|
||||
Self {
|
||||
_lock: lock,
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
|
||||
match &self.original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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",
|
||||
"request_detail",
|
||||
"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("source".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 _key_guard = TestKeyGuard::new();
|
||||
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 _key_guard = TestKeyGuard::new();
|
||||
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());
|
||||
assert!(state.stream_client.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(),
|
||||
request_id: "1.1".to_string(),
|
||||
post_data: None,
|
||||
status: Some(200),
|
||||
response_headers: None,
|
||||
mime_type: Some("text/html".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(),
|
||||
request_id: "1.1".to_string(),
|
||||
post_data: None,
|
||||
status: None,
|
||||
response_headers: None,
|
||||
mime_type: None,
|
||||
});
|
||||
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(),
|
||||
request_id: "1.2".to_string(),
|
||||
post_data: None,
|
||||
status: None,
|
||||
response_headers: None,
|
||||
mime_type: None,
|
||||
});
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_status_filter() {
|
||||
use super::actions::matches_status_filter;
|
||||
|
||||
// Exact match
|
||||
assert!(matches_status_filter(Some(200), "200"));
|
||||
assert!(!matches_status_filter(Some(201), "200"));
|
||||
|
||||
// Class match (Nxx)
|
||||
assert!(matches_status_filter(Some(200), "2xx"));
|
||||
assert!(matches_status_filter(Some(299), "2xx"));
|
||||
assert!(!matches_status_filter(Some(301), "2xx"));
|
||||
assert!(matches_status_filter(Some(404), "4xx"));
|
||||
|
||||
// Range match
|
||||
assert!(matches_status_filter(Some(400), "400-499"));
|
||||
assert!(matches_status_filter(Some(499), "400-499"));
|
||||
assert!(!matches_status_filter(Some(500), "400-499"));
|
||||
|
||||
// None status
|
||||
assert!(!matches_status_filter(None, "200"));
|
||||
assert!(!matches_status_filter(None, "2xx"));
|
||||
}
|
||||
|
||||
#[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,217 @@
|
||||
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::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[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() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_CONFIRM_ACTIONS"]);
|
||||
_guard.set("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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
//! Browser provider connections for remote CDP sessions.
|
||||
//!
|
||||
//! Supports AgentCore, Browserbase, Browserless, 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.
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderSession {
|
||||
pub provider: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderConnection {
|
||||
pub ws_url: String,
|
||||
pub session: Option<ProviderSession>,
|
||||
/// If true, the WebSocket IS the page session (no Target.* commands).
|
||||
pub direct_page: bool,
|
||||
}
|
||||
|
||||
/// 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<ProviderConnection, String> {
|
||||
match provider_name.to_lowercase().as_str() {
|
||||
"browserbase" => {
|
||||
let (url, session) = connect_browserbase().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browserless" => {
|
||||
let (url, session) = connect_browserless().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browser-use" | "browseruse" => {
|
||||
let (url, session) = connect_browser_use().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"kernel" => {
|
||||
let (url, session) = connect_kernel().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"agentcore" => {
|
||||
let (url, session) = connect_agentcore().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
_ => Err(format!(
|
||||
"Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel, agentcore",
|
||||
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
|
||||
.post(format!(
|
||||
"https://api.browserbase.com/v1/sessions/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.json(&serde_json::json!({ "status": "REQUEST_RELEASE" }))
|
||||
.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;
|
||||
}
|
||||
}
|
||||
"browserless" => {
|
||||
// session_id holds the stop URL for browserless
|
||||
let _ = client.delete(&session.session_id).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;
|
||||
}
|
||||
}
|
||||
"agentcore" => {
|
||||
// AgentCore session cleanup is handled via signed DELETE request
|
||||
let _ = close_agentcore_session(&session.session_id).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 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)
|
||||
.body("{}")
|
||||
.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_browserless() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERLESS_API_KEY")
|
||||
.map_err(|_| "BROWSERLESS_API_KEY environment variable is not set")?;
|
||||
|
||||
let api_url = env::var("BROWSERLESS_API_URL")
|
||||
.unwrap_or_else(|_| "https://production-sfo.browserless.io".to_string());
|
||||
let browser_type =
|
||||
env::var("BROWSERLESS_BROWSER_TYPE").unwrap_or_else(|_| "chromium".to_string());
|
||||
|
||||
let supported = ["chromium", "chrome"];
|
||||
if !supported.contains(&browser_type.as_str()) {
|
||||
return Err(format!(
|
||||
"BROWSERLESS_BROWSER_TYPE \"{}\" is not supported. Only {} are allowed.",
|
||||
browser_type,
|
||||
supported.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let ttl: u64 = env::var("BROWSERLESS_TTL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(300000);
|
||||
let stealth = env::var("BROWSERLESS_STEALTH")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
|
||||
let url = format!("{}/session", api_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.query(&[("token", &api_key)])
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"ttl": ttl,
|
||||
"stealth": stealth,
|
||||
"browser": browser_type,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserless request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browserless response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browserless API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserless response: {}", e))?;
|
||||
|
||||
let connect_url = json
|
||||
.get("connect")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserless response missing 'connect' URL".to_string())?;
|
||||
|
||||
let stop_url = json
|
||||
.get("stop")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserless response missing 'stop' URL".to_string())?;
|
||||
|
||||
Ok((
|
||||
connect_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browserless".to_string(),
|
||||
// Store the stop URL as the session_id for cleanup
|
||||
session_id: stop_url,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
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 ws_url = format!("wss://connect.browser-use.com?apiKey={}", api_key);
|
||||
|
||||
Ok((ws_url, None))
|
||||
}
|
||||
|
||||
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("KERNEL_API_KEY").ok();
|
||||
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 mut request = client.post(&url).header("Content-Type", "application/json");
|
||||
if let Some(ref key) = api_key {
|
||||
request = request.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
let response = request
|
||||
.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,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentCore Provider (AWS Bedrock AgentCore Browser)
|
||||
// ============================================================================
|
||||
|
||||
mod agentcore {
|
||||
use super::*;
|
||||
|
||||
/// AgentCore-specific session info for Live View URL
|
||||
pub struct AgentCoreSessionInfo {
|
||||
pub session_id: String,
|
||||
pub browser_identifier: String,
|
||||
pub region: String,
|
||||
pub live_view_url: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static AGENTCORE_INFO: std::cell::RefCell<Option<AgentCoreSessionInfo>> = const { std::cell::RefCell::new(None) };
|
||||
static AGENTCORE_WS_HEADERS: std::cell::RefCell<Option<Vec<(String, String)>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn set_agentcore_info(info: AgentCoreSessionInfo) {
|
||||
AGENTCORE_INFO.with(|cell| *cell.borrow_mut() = Some(info));
|
||||
}
|
||||
|
||||
pub fn get_agentcore_info() -> Option<AgentCoreSessionInfo> {
|
||||
AGENTCORE_INFO.with(|cell| {
|
||||
cell.borrow().as_ref().map(|i| AgentCoreSessionInfo {
|
||||
session_id: i.session_id.clone(),
|
||||
browser_identifier: i.browser_identifier.clone(),
|
||||
region: i.region.clone(),
|
||||
live_view_url: i.live_view_url.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_agentcore_ws_headers(headers: Vec<(String, String)>) {
|
||||
AGENTCORE_WS_HEADERS.with(|cell| *cell.borrow_mut() = Some(headers));
|
||||
}
|
||||
|
||||
pub fn take_agentcore_ws_headers() -> Option<Vec<(String, String)>> {
|
||||
AGENTCORE_WS_HEADERS.with(|cell| cell.borrow_mut().take())
|
||||
}
|
||||
|
||||
pub async fn connect() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let region = env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let browser_id =
|
||||
env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
let timeout_secs: u64 = env::var("AGENTCORE_SESSION_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3600);
|
||||
|
||||
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
|
||||
let path = format!(
|
||||
"/browsers/{}/sessions/start",
|
||||
urlencoding::encode(&browser_id)
|
||||
);
|
||||
let url = format!("https://{}{}", host, path);
|
||||
|
||||
// Generate a unique session name
|
||||
let session_name = format!("agent-browser-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let mut body_json = json!({
|
||||
"name": session_name,
|
||||
"sessionTimeoutSeconds": timeout_secs
|
||||
});
|
||||
if let Ok(profile_id) = env::var("AGENTCORE_PROFILE_ID") {
|
||||
if !profile_id.is_empty() {
|
||||
body_json.as_object_mut().unwrap().insert(
|
||||
"profileConfiguration".to_string(),
|
||||
json!({ "profileIdentifier": profile_id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
let body = serde_json::to_string(&body_json)
|
||||
.map_err(|e| format!("Failed to serialize request body: {}", e))?;
|
||||
|
||||
let signed_headers = sign_request("PUT", &url, ®ion, Some(&body)).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.put(&url).body(body.clone());
|
||||
for (key, value) in &signed_headers {
|
||||
req = req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("AgentCore request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read AgentCore response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"AgentCore API error ({}): {}",
|
||||
status.as_u16(),
|
||||
resp_body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&resp_body)
|
||||
.map_err(|e| format!("Invalid AgentCore response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("sessionId")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "AgentCore response missing sessionId".to_string())?
|
||||
.to_string();
|
||||
|
||||
let browser_identifier = json
|
||||
.get("browserIdentifier")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&browser_id)
|
||||
.to_string();
|
||||
|
||||
let live_view_url = format!(
|
||||
"https://{}.console.aws.amazon.com/bedrock-agentcore/browser/{}/session/{}#",
|
||||
region, browser_identifier, session_id
|
||||
);
|
||||
|
||||
set_agentcore_info(AgentCoreSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
browser_identifier: browser_identifier.clone(),
|
||||
region: region.clone(),
|
||||
live_view_url: live_view_url.clone(),
|
||||
});
|
||||
|
||||
eprintln!("Session: {}", session_id);
|
||||
eprintln!("Live View: {}", live_view_url);
|
||||
|
||||
let ws_path = format!(
|
||||
"/browser-streams/{}/sessions/{}/automation",
|
||||
browser_identifier, session_id
|
||||
);
|
||||
let ws_url = format!("wss://{}{}", host, ws_path);
|
||||
|
||||
let ws_headers = sign_request(
|
||||
"GET",
|
||||
&format!("https://{}{}", host, ws_path),
|
||||
®ion,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
set_agentcore_ws_headers(ws_headers);
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "agentcore".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get AWS credentials from environment variables or AWS CLI
|
||||
fn get_aws_credentials() -> Result<(String, String, Option<String>), String> {
|
||||
// First try environment variables
|
||||
if let (Ok(access_key), Ok(secret_key)) = (
|
||||
env::var("AWS_ACCESS_KEY_ID"),
|
||||
env::var("AWS_SECRET_ACCESS_KEY"),
|
||||
) {
|
||||
return Ok((access_key, secret_key, env::var("AWS_SESSION_TOKEN").ok()));
|
||||
}
|
||||
|
||||
// Fall back to AWS CLI
|
||||
let mut cmd = std::process::Command::new("aws");
|
||||
cmd.args(["configure", "export-credentials", "--format", "env"]);
|
||||
|
||||
// Honor AWS_PROFILE
|
||||
if let Ok(profile) = env::var("AWS_PROFILE") {
|
||||
cmd.args(["--profile", &profile]);
|
||||
}
|
||||
|
||||
let output = cmd.output()
|
||||
.map_err(|e| format!("Failed to run aws CLI: {}. Install AWS CLI or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"AWS CLI failed: {}. Run 'aws sso login' or set credentials",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut access_key = None;
|
||||
let mut secret_key = None;
|
||||
let mut session_token = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(val) = line.strip_prefix("export AWS_ACCESS_KEY_ID=") {
|
||||
access_key = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("export AWS_SECRET_ACCESS_KEY=") {
|
||||
secret_key = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("export AWS_SESSION_TOKEN=") {
|
||||
session_token = Some(val.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match (access_key, secret_key) {
|
||||
(Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
|
||||
_ => Err("Failed to parse credentials from AWS CLI output".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sign_request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
region: &str,
|
||||
body: Option<&str>,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Get credentials from environment or AWS CLI
|
||||
let (access_key, secret_key, session_token) = get_aws_credentials()?;
|
||||
|
||||
let parsed_url = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed_url.host_str().unwrap_or("");
|
||||
|
||||
// Get current time
|
||||
let now = chrono::Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
|
||||
// Create canonical request
|
||||
let payload_hash = if let Some(b) = body {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
} else {
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string()
|
||||
// empty string hash
|
||||
};
|
||||
|
||||
let canonical_uri = parsed_url.path();
|
||||
let canonical_querystring = parsed_url.query().unwrap_or("");
|
||||
|
||||
let mut signed_headers = "content-type;host;x-amz-date".to_string();
|
||||
let mut canonical_headers = format!(
|
||||
"content-type:application/json\nhost:{}\nx-amz-date:{}\n",
|
||||
host, amz_date
|
||||
);
|
||||
|
||||
if let Some(ref token) = session_token {
|
||||
signed_headers = "content-type;host;x-amz-date;x-amz-security-token".to_string();
|
||||
canonical_headers = format!(
|
||||
"content-type:application/json\nhost:{}\nx-amz-date:{}\nx-amz-security-token:{}\n",
|
||||
host, amz_date, token
|
||||
);
|
||||
}
|
||||
|
||||
let canonical_request = format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
method,
|
||||
canonical_uri,
|
||||
canonical_querystring,
|
||||
canonical_headers,
|
||||
signed_headers,
|
||||
payload_hash
|
||||
);
|
||||
|
||||
// Create string to sign
|
||||
let algorithm = "AWS4-HMAC-SHA256";
|
||||
let credential_scope = format!("{}/{}/bedrock-agentcore/aws4_request", date_stamp, region);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical_request.as_bytes());
|
||||
let canonical_request_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let string_to_sign = format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
algorithm, amz_date, credential_scope, canonical_request_hash
|
||||
);
|
||||
|
||||
// Calculate signature
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let k_date = HmacSha256::new_from_slice(format!("AWS4{}", secret_key).as_bytes())
|
||||
.unwrap()
|
||||
.chain_update(date_stamp.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_region = HmacSha256::new_from_slice(&k_date)
|
||||
.unwrap()
|
||||
.chain_update(region.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_service = HmacSha256::new_from_slice(&k_region)
|
||||
.unwrap()
|
||||
.chain_update(b"bedrock-agentcore")
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_signing = HmacSha256::new_from_slice(&k_service)
|
||||
.unwrap()
|
||||
.chain_update(b"aws4_request")
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let signature = hex::encode(
|
||||
HmacSha256::new_from_slice(&k_signing)
|
||||
.unwrap()
|
||||
.chain_update(string_to_sign.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes(),
|
||||
);
|
||||
|
||||
// Build authorization header
|
||||
let authorization = format!(
|
||||
"{} Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
algorithm, access_key, credential_scope, signed_headers, signature
|
||||
);
|
||||
|
||||
let mut headers = vec![
|
||||
("host".to_string(), host.to_string()),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("x-amz-date".to_string(), amz_date),
|
||||
("authorization".to_string(), authorization),
|
||||
];
|
||||
|
||||
if let Some(token) = session_token {
|
||||
headers.push(("x-amz-security-token".to_string(), token));
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub async fn close_session(session_id: &str) -> Result<(), String> {
|
||||
let info = get_agentcore_info();
|
||||
let (region, browser_id) = match &info {
|
||||
Some(i) => (i.region.clone(), i.browser_identifier.clone()),
|
||||
None => {
|
||||
let region = env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let browser_id = env::var("AGENTCORE_BROWSER_ID")
|
||||
.unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
(region, browser_id)
|
||||
}
|
||||
};
|
||||
|
||||
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
|
||||
let path = format!(
|
||||
"/browsers/{}/sessions/stop",
|
||||
urlencoding::encode(&browser_id)
|
||||
);
|
||||
let url = format!("https://{}{}", host, path);
|
||||
|
||||
let body = serde_json::to_string(&json!({ "sessionId": session_id }))
|
||||
.map_err(|e| format!("Failed to serialize close request: {}", e))?;
|
||||
|
||||
let signed_headers = sign_request("PUT", &url, ®ion, Some(&body)).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.put(&url).body(body);
|
||||
for (key, value) in &signed_headers {
|
||||
req = req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let _ = req.send().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub use agentcore::{get_agentcore_info, take_agentcore_ws_headers};
|
||||
|
||||
async fn connect_agentcore() -> Result<(String, Option<ProviderSession>), String> {
|
||||
agentcore::connect().await
|
||||
}
|
||||
|
||||
async fn close_agentcore_session(session_id: &str) -> Result<(), String> {
|
||||
agentcore::close_session(session_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_provider_unknown() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let result = rt.block_on(connect_provider("unknown-provider"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_env_defaults() {
|
||||
// Test that default values are used when env vars not set
|
||||
std::env::remove_var("AGENTCORE_REGION");
|
||||
std::env::remove_var("AGENTCORE_BROWSER_ID");
|
||||
std::env::remove_var("AGENTCORE_SESSION_TIMEOUT");
|
||||
|
||||
// These would be used in connect() - just verify they don't panic
|
||||
let region = std::env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| std::env::var("AWS_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
assert_eq!(region, "us-east-1");
|
||||
|
||||
let browser_id =
|
||||
std::env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
assert_eq!(browser_id, "aws.browser.v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_session_info_storage() {
|
||||
let info = agentcore::AgentCoreSessionInfo {
|
||||
session_id: "test-session".to_string(),
|
||||
browser_identifier: "aws.browser.v1".to_string(),
|
||||
region: "us-east-1".to_string(),
|
||||
live_view_url: "https://example.com".to_string(),
|
||||
};
|
||||
|
||||
agentcore::set_agentcore_info(info);
|
||||
let retrieved = get_agentcore_info();
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.session_id, "test-session");
|
||||
assert_eq!(retrieved.region, "us-east-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_ws_headers_storage() {
|
||||
let headers = vec![
|
||||
(
|
||||
"Authorization".to_string(),
|
||||
"AWS4-HMAC-SHA256...".to_string(),
|
||||
),
|
||||
("X-Amz-Date".to_string(), "20260304T180000Z".to_string()),
|
||||
];
|
||||
|
||||
agentcore::set_agentcore_ws_headers(headers);
|
||||
let taken = take_agentcore_ws_headers();
|
||||
assert!(taken.is_some());
|
||||
assert_eq!(taken.unwrap().len(), 2);
|
||||
|
||||
// Should be None after take
|
||||
let taken_again = take_agentcore_ws_headers();
|
||||
assert!(taken_again.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{CaptureScreenshotParams, CaptureScreenshotResult};
|
||||
|
||||
const CAPTURE_INTERVAL_MS: u64 = 100;
|
||||
const CAPTURE_FPS: u32 = 10;
|
||||
|
||||
pub struct RecordingState {
|
||||
pub active: bool,
|
||||
pub output_path: String,
|
||||
pub frame_count: u64,
|
||||
pub capture_task: Option<tokio::task::JoinHandle<Result<(), String>>>,
|
||||
pub shared_frame_count: Option<Arc<AtomicU64>>,
|
||||
pub cancel_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl RecordingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
output_path: String::new(),
|
||||
frame_count: 0,
|
||||
capture_task: None,
|
||||
shared_frame_count: None,
|
||||
cancel_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
if state.active {
|
||||
return Err("Recording already active".to_string());
|
||||
}
|
||||
|
||||
state.active = true;
|
||||
state.output_path = path.to_string();
|
||||
state.frame_count = 0;
|
||||
|
||||
Ok(json!({ "started": true, "path": path }))
|
||||
}
|
||||
|
||||
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 {
|
||||
return Err("No frames captured".to_string());
|
||||
}
|
||||
|
||||
Ok(json!({ "path": &state.output_path, "frames": state.frame_count }))
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_ffmpeg_command(output_path: &str) -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||
|
||||
cmd.args(["-y"])
|
||||
.args(["-avioflags", "direct"])
|
||||
.args([
|
||||
"-fpsprobesize",
|
||||
"0",
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
])
|
||||
.args([
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
"-framerate",
|
||||
&CAPTURE_FPS.to_string(),
|
||||
"-i",
|
||||
"pipe:0",
|
||||
])
|
||||
.args(["-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]);
|
||||
|
||||
if output_path.ends_with(".webm") {
|
||||
cmd.args(["-c:v", "libvpx", "-crf", "30", "-b:v", "1M"]);
|
||||
} else {
|
||||
cmd.args(["-c:v", "libx264", "-preset", "ultrafast"]);
|
||||
}
|
||||
|
||||
cmd.args(["-pix_fmt", "yuv420p", "-threads", "1"])
|
||||
.arg(output_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Spawn a background task that captures screenshots at a fixed interval
|
||||
/// and pipes them to ffmpeg in real-time.
|
||||
pub fn spawn_recording_task(
|
||||
client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
output_path: String,
|
||||
shared_count: Arc<AtomicU64>,
|
||||
cancel_rx: oneshot::Receiver<()>,
|
||||
) -> tokio::task::JoinHandle<Result<(), String>> {
|
||||
tokio::spawn(async move {
|
||||
let mut cancel_rx = std::pin::pin!(cancel_rx);
|
||||
|
||||
let mut ffmpeg = build_ffmpeg_command(&output_path).spawn().map_err(|e| {
|
||||
format!(
|
||||
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut stdin = ffmpeg
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to open ffmpeg stdin".to_string())?;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(CAPTURE_INTERVAL_MS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let params = CaptureScreenshotParams {
|
||||
format: Some("jpeg".to_string()),
|
||||
quality: Some(80),
|
||||
clip: None,
|
||||
from_surface: Some(true),
|
||||
capture_beyond_viewport: None,
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut cancel_rx => break,
|
||||
_ = interval.tick() => {}
|
||||
}
|
||||
|
||||
let result: Result<CaptureScreenshotResult, _> = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(&session_id))
|
||||
.await;
|
||||
|
||||
let screenshot = match result {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if e.contains("Target closed") || e.contains("not found") {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = match base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&screenshot.data,
|
||||
) {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if stdin.write_all(&bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
shared_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
drop(stdin);
|
||||
|
||||
let output = ffmpeg
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| format!("ffmpeg wait failed: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"ffmpeg failed: {}",
|
||||
stderr.chars().take(300).collect::<String>()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_recording_task(state: &mut RecordingState) -> Result<(), String> {
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
let counter = state.shared_frame_count.take();
|
||||
let handle = state.capture_task.take();
|
||||
|
||||
let result = if let Some(h) = handle {
|
||||
match h.await {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(e) => Err(format!("Recording task panicked: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Some(c) = counter {
|
||||
state.frame_count = c.load(Ordering::Relaxed);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
|
||||
let result = recording_start(&mut state, "/tmp/test2.mp4");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("already active"));
|
||||
}
|
||||
|
||||
#[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_restart_while_inactive() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_restart(&mut state, "/tmp/new.webm");
|
||||
assert!(result.is_ok());
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/new.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_restart_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/old.webm").unwrap();
|
||||
state.frame_count = 10;
|
||||
let result = recording_restart(&mut state, "/tmp/new.webm").unwrap();
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/new.webm");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
assert_eq!(result["previousPath"], "/tmp/old.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ffmpeg_command_webm() {
|
||||
let cmd = build_ffmpeg_command("/tmp/out.webm");
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
|
||||
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
|
||||
assert!(args_str.contains(&"libvpx"));
|
||||
assert!(args_str.contains(&"/tmp/out.webm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ffmpeg_command_mp4() {
|
||||
let cmd = build_ffmpeg_command("/tmp/out.mp4");
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
|
||||
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
|
||||
assert!(args_str.contains(&"libx264"));
|
||||
assert!(args_str.contains(&"/tmp/out.mp4"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::RefMap;
|
||||
|
||||
const ANNOTATION_OVERLAY_ID: &str = "__agent_browser_annotations__";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Rect {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RawAnnotation {
|
||||
ref_id: String,
|
||||
number: u64,
|
||||
role: String,
|
||||
name: Option<String>,
|
||||
rect: Rect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AnnotationBox {
|
||||
pub x: i64,
|
||||
pub y: i64,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotAnnotation {
|
||||
pub ref_id: String,
|
||||
pub number: u64,
|
||||
pub role: String,
|
||||
pub name: Option<String>,
|
||||
pub box_: AnnotationBox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotResult {
|
||||
pub path: String,
|
||||
pub base64: String,
|
||||
pub annotations: Vec<ScreenshotAnnotation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub full_page: bool,
|
||||
pub format: String,
|
||||
pub quality: Option<i32>,
|
||||
pub annotate: bool,
|
||||
pub output_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
path: None,
|
||||
full_page: false,
|
||||
format: "png".to_string(),
|
||||
quality: None,
|
||||
annotate: false,
|
||||
output_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ScreenshotAnnotation {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
|
||||
let mut state = serializer.serialize_struct("ScreenshotAnnotation", 5)?;
|
||||
state.serialize_field("ref", &self.ref_id)?;
|
||||
state.serialize_field("number", &self.number)?;
|
||||
state.serialize_field("role", &self.role)?;
|
||||
if let Some(name) = &self.name {
|
||||
state.serialize_field("name", name)?;
|
||||
}
|
||||
state.serialize_field("box", &self.box_)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures a screenshot via CDP and optionally overlays numbered annotations
|
||||
/// that mirror the Node.js screenshot `annotate` mode.
|
||||
pub async fn take_screenshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<ScreenshotResult, String> {
|
||||
let target_rect = if options.annotate {
|
||||
match options.selector.as_deref() {
|
||||
Some(selector) => {
|
||||
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let raw_annotations = if options.annotate {
|
||||
collect_annotations(client, session_id, ref_map).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let overlay_items = filter_annotations(raw_annotations, target_rect.as_ref());
|
||||
let overlay_injected = if options.annotate && !overlay_items.is_empty() {
|
||||
inject_annotation_overlay(client, session_id, &overlay_items).await?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let base64 =
|
||||
capture_screenshot_base64(client, session_id, ref_map, options, iframe_sessions).await;
|
||||
|
||||
if overlay_injected {
|
||||
let _ = remove_annotation_overlay(client, session_id).await;
|
||||
}
|
||||
|
||||
let base64 = base64?;
|
||||
let annotations = if options.annotate {
|
||||
let scroll = if options.full_page {
|
||||
Some(get_scroll_offsets(client, session_id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
project_annotations(&overlay_items, target_rect.as_ref(), scroll)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let ext = if options.format == "jpeg" {
|
||||
"jpg"
|
||||
} else {
|
||||
"png"
|
||||
};
|
||||
let path = save_screenshot(
|
||||
&base64,
|
||||
options.path.as_deref(),
|
||||
ext,
|
||||
options.output_dir.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(ScreenshotResult {
|
||||
path,
|
||||
base64,
|
||||
annotations,
|
||||
})
|
||||
}
|
||||
|
||||
async fn capture_screenshot_base64(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<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 {
|
||||
if let Some(rect) =
|
||||
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions).await?
|
||||
{
|
||||
params.clip = Some(Viewport {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result: CaptureScreenshotResult = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(session_id))
|
||||
.await?;
|
||||
|
||||
Ok(result.data)
|
||||
}
|
||||
|
||||
async fn collect_annotations(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
) -> Result<Vec<RawAnnotation>, String> {
|
||||
let entries = ref_map.entries_sorted();
|
||||
if entries.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Collect entries that have backend_node_ids for batch resolution.
|
||||
let with_backend_ids: Vec<(String, super::element::RefEntry, i64)> = entries
|
||||
.iter()
|
||||
.filter_map(|(ref_id, entry)| {
|
||||
entry
|
||||
.backend_node_id
|
||||
.map(|bid| (ref_id.clone(), entry.clone(), bid))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if with_backend_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-resolve all backend_node_ids to object IDs using concurrent CDP calls.
|
||||
let resolve_futures: Vec<_> = with_backend_ids
|
||||
.iter()
|
||||
.map(|(_, _, backend_node_id)| {
|
||||
client.send_command(
|
||||
"DOM.resolveNode",
|
||||
Some(serde_json::json!({
|
||||
"backendNodeId": backend_node_id,
|
||||
"objectGroup": "agent-browser-annotate"
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resolve_results = futures_util::future::join_all(resolve_futures).await;
|
||||
|
||||
// Collect resolved object IDs paired with their ref info.
|
||||
let mut resolved: Vec<(String, super::element::RefEntry, String)> = Vec::new();
|
||||
for (i, result) in resolve_results.into_iter().enumerate() {
|
||||
if let Ok(val) = result {
|
||||
if let Some(oid) = val
|
||||
.get("object")
|
||||
.and_then(|o| o.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let (ref_id, entry, _) = &with_backend_ids[i];
|
||||
resolved.push((ref_id.clone(), entry.clone(), oid.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-get bounding rects for all resolved elements using concurrent CDP calls.
|
||||
let rect_futures: Vec<_> = resolved
|
||||
.iter()
|
||||
.map(|(_, _, object_id)| get_rect_for_object(client, session_id, object_id))
|
||||
.collect();
|
||||
|
||||
let rect_results = futures_util::future::join_all(rect_futures).await;
|
||||
|
||||
let mut annotations = Vec::new();
|
||||
for (i, rect_result) in rect_results.into_iter().enumerate() {
|
||||
let rect = match rect_result {
|
||||
Ok(Some(r)) if r.width > 0.0 && r.height > 0.0 => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let (ref_id, entry, _) = &resolved[i];
|
||||
let number = ref_id
|
||||
.strip_prefix('e')
|
||||
.and_then(|n| n.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
annotations.push(RawAnnotation {
|
||||
ref_id: ref_id.clone(),
|
||||
number,
|
||||
role: entry.role.clone(),
|
||||
name: (!entry.name.is_empty()).then_some(entry.name.clone()),
|
||||
rect,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(annotations)
|
||||
}
|
||||
|
||||
async fn get_rect_for_selector(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Option<Rect>, String> {
|
||||
let (object_id, effective_session_id) = super::element::resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
get_rect_for_object(client, &effective_session_id, &object_id).await
|
||||
}
|
||||
|
||||
async fn get_rect_for_object(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
object_id: &str,
|
||||
) -> Result<Option<Rect>, String> {
|
||||
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.to_string()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.as_ref().and_then(parse_rect))
|
||||
}
|
||||
|
||||
fn parse_rect(value: &Value) -> Option<Rect> {
|
||||
Some(Rect {
|
||||
x: value.get("x")?.as_f64()?,
|
||||
y: value.get("y")?.as_f64()?,
|
||||
width: value.get("width")?.as_f64()?,
|
||||
height: value.get("height")?.as_f64()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn filter_annotations(
|
||||
annotations: Vec<RawAnnotation>,
|
||||
target_rect: Option<&Rect>,
|
||||
) -> Vec<RawAnnotation> {
|
||||
let mut items = annotations
|
||||
.into_iter()
|
||||
.filter(|annotation| match target_rect {
|
||||
Some(target) => overlaps(&annotation.rect, target),
|
||||
None => true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
items.sort_by_key(|annotation| annotation.number);
|
||||
items
|
||||
}
|
||||
|
||||
fn overlaps(left: &Rect, right: &Rect) -> bool {
|
||||
let left_x2 = left.x + left.width;
|
||||
let left_y2 = left.y + left.height;
|
||||
let right_x2 = right.x + right.width;
|
||||
let right_y2 = right.y + right.height;
|
||||
|
||||
left.x < right_x2 && left_x2 > right.x && left.y < right_y2 && left_y2 > right.y
|
||||
}
|
||||
|
||||
async fn inject_annotation_overlay(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
annotations: &[RawAnnotation],
|
||||
) -> Result<(), String> {
|
||||
let overlay_data = annotations
|
||||
.iter()
|
||||
.map(|annotation| {
|
||||
serde_json::json!({
|
||||
"number": annotation.number,
|
||||
"x": round(annotation.rect.x),
|
||||
"y": round(annotation.rect.y),
|
||||
"width": round(annotation.rect.width),
|
||||
"height": round(annotation.rect.height),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let expression = format!(
|
||||
r#"(() => {{
|
||||
var items = {items};
|
||||
var id = {overlay_id};
|
||||
var existing = document.getElementById(id);
|
||||
if (existing) existing.remove();
|
||||
var sx = window.scrollX || 0;
|
||||
var sy = window.scrollY || 0;
|
||||
var c = document.createElement('div');
|
||||
c.id = id;
|
||||
c.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
|
||||
for (var i = 0; i < items.length; i++) {{
|
||||
var it = items[i];
|
||||
var dx = it.x + sx;
|
||||
var dy = it.y + sy;
|
||||
var b = document.createElement('div');
|
||||
b.style.cssText = 'position:absolute;left:' + dx + 'px;top:' + dy + 'px;width:' + it.width + 'px;height:' + it.height + 'px;border:2px solid rgba(255,0,0,0.8);box-sizing:border-box;pointer-events:none;';
|
||||
var l = document.createElement('div');
|
||||
l.textContent = String(it.number);
|
||||
var labelTop = dy < 14 ? '2px' : '-14px';
|
||||
l.style.cssText = 'position:absolute;top:' + labelTop + ';left:-2px;background:rgba(255,0,0,0.9);color:#fff;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;';
|
||||
b.appendChild(l);
|
||||
c.appendChild(b);
|
||||
}}
|
||||
document.documentElement.appendChild(c);
|
||||
return true;
|
||||
}})()"#,
|
||||
items = serde_json::to_string(&overlay_data).unwrap_or_else(|_| "[]".to_string()),
|
||||
overlay_id =
|
||||
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
|
||||
let _: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_annotation_overlay(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
let expression = format!(
|
||||
r#"(() => {{
|
||||
var el = document.getElementById({overlay_id});
|
||||
if (el) el.remove();
|
||||
return true;
|
||||
}})()"#,
|
||||
overlay_id =
|
||||
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
|
||||
let _: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_scroll_offsets(client: &CdpClient, session_id: &str) -> Result<(f64, f64), String> {
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: "({x: window.scrollX || 0, y: window.scrollY || 0})".to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let value = result.result.value.unwrap_or(Value::Null);
|
||||
let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
Ok((x, y))
|
||||
}
|
||||
|
||||
fn project_annotations(
|
||||
annotations: &[RawAnnotation],
|
||||
target_rect: Option<&Rect>,
|
||||
scroll: Option<(f64, f64)>,
|
||||
) -> Vec<ScreenshotAnnotation> {
|
||||
annotations
|
||||
.iter()
|
||||
.map(|annotation| {
|
||||
let rect = if let Some(target) = target_rect {
|
||||
Rect {
|
||||
x: annotation.rect.x - target.x,
|
||||
y: annotation.rect.y - target.y,
|
||||
width: annotation.rect.width,
|
||||
height: annotation.rect.height,
|
||||
}
|
||||
} else if let Some((scroll_x, scroll_y)) = scroll {
|
||||
Rect {
|
||||
x: annotation.rect.x + scroll_x,
|
||||
y: annotation.rect.y + scroll_y,
|
||||
width: annotation.rect.width,
|
||||
height: annotation.rect.height,
|
||||
}
|
||||
} else {
|
||||
annotation.rect.clone()
|
||||
};
|
||||
|
||||
ScreenshotAnnotation {
|
||||
ref_id: annotation.ref_id.clone(),
|
||||
number: annotation.number,
|
||||
role: annotation.role.clone(),
|
||||
name: annotation.name.clone(),
|
||||
box_: AnnotationBox {
|
||||
x: round(rect.x),
|
||||
y: round(rect.y),
|
||||
width: round(rect.width),
|
||||
height: round(rect.height),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_screenshot(
|
||||
base64_data: &str,
|
||||
explicit_path: Option<&str>,
|
||||
ext: &str,
|
||||
output_dir: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let save_path = match explicit_path {
|
||||
Some(path) => path.to_string(),
|
||||
None => {
|
||||
let dir = match output_dir {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => 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, base64_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)
|
||||
}
|
||||
|
||||
fn round(value: f64) -> i64 {
|
||||
value.round() as i64
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filters_annotations_to_target_overlap() {
|
||||
let annotations = vec![
|
||||
RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Inside".to_string()),
|
||||
rect: Rect {
|
||||
x: 10.0,
|
||||
y: 10.0,
|
||||
width: 50.0,
|
||||
height: 20.0,
|
||||
},
|
||||
},
|
||||
RawAnnotation {
|
||||
ref_id: "e2".to_string(),
|
||||
number: 2,
|
||||
role: "button".to_string(),
|
||||
name: Some("Outside".to_string()),
|
||||
rect: Rect {
|
||||
x: 200.0,
|
||||
y: 200.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let target = Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
};
|
||||
|
||||
let filtered = filter_annotations(annotations, Some(&target));
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].ref_id, "e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_selector_annotations_relative_to_target() {
|
||||
let annotations = vec![RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Inside".to_string()),
|
||||
rect: Rect {
|
||||
x: 25.0,
|
||||
y: 35.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
}];
|
||||
|
||||
let target = Rect {
|
||||
x: 10.0,
|
||||
y: 15.0,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
};
|
||||
|
||||
let projected = project_annotations(&annotations, Some(&target), None);
|
||||
assert_eq!(projected[0].box_.x, 15);
|
||||
assert_eq!(projected[0].box_.y, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_full_page_annotations_to_document_space() {
|
||||
let annotations = vec![RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Bottom".to_string()),
|
||||
rect: Rect {
|
||||
x: 5.0,
|
||||
y: 12.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
}];
|
||||
|
||||
let projected = project_annotations(&annotations, None, Some((10.0, 1000.0)));
|
||||
assert_eq!(projected[0].box_.x, 15);
|
||||
assert_eq!(projected[0].box_.y, 1012);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,887 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{
|
||||
AttachToTargetParams, AttachToTargetResult, CloseTargetParams, CreateTargetParams,
|
||||
CreateTargetResult, 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,
|
||||
}
|
||||
|
||||
fn collect_frame_origins(tree: &Value, origins: &mut HashSet<String>) {
|
||||
if let Some(frame) = tree.get("frame") {
|
||||
if let Some(url_str) = frame.get("url").and_then(|v| v.as_str()) {
|
||||
if let Ok(parsed) = url::Url::parse(url_str) {
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
if origin != "null" && !origin.is_empty() {
|
||||
origins.insert(origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
collect_frame_origins(child, origins);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the JS-evaluated origin storage data into an OriginStorage struct.
|
||||
fn parse_origin_storage(data: &Value) -> Option<OriginStorage> {
|
||||
if !data.is_object() {
|
||||
return None;
|
||||
}
|
||||
let origin = data
|
||||
.get("origin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if origin.is_empty() || origin == "null" {
|
||||
return None;
|
||||
}
|
||||
let local_storage: Vec<StorageEntry> = data
|
||||
.get("localStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let session_storage: Vec<StorageEntry> = data
|
||||
.get("sessionStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(OriginStorage {
|
||||
origin,
|
||||
local_storage,
|
||||
session_storage,
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate the storage-collection JS snippet and parse the result.
|
||||
async fn eval_origin_storage(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
origin_js: &str,
|
||||
) -> Option<OriginStorage> {
|
||||
let result = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: origin_js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let data = result.result.value.unwrap_or(Value::Null);
|
||||
parse_origin_storage(&data)
|
||||
}
|
||||
|
||||
/// Create a temporary CDP target, navigate it to each origin to collect localStorage,
|
||||
/// then close it. Uses Fetch interception to serve blank HTML instead of making real
|
||||
/// network requests.
|
||||
async fn collect_storage_via_temp_target(
|
||||
client: &CdpClient,
|
||||
origins: &[String],
|
||||
origin_js: &str,
|
||||
) -> Result<Vec<OriginStorage>, String> {
|
||||
let create_result: CreateTargetResult = client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let target_id = create_result.target_id;
|
||||
|
||||
// Ensure the target is closed even if attach or later steps fail
|
||||
let result = collect_storage_in_target(client, &target_id, origins, origin_js).await;
|
||||
|
||||
let _ = client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Target.closeTarget",
|
||||
&CloseTargetParams { target_id },
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn collect_storage_in_target(
|
||||
client: &CdpClient,
|
||||
target_id: &str,
|
||||
origins: &[String],
|
||||
origin_js: &str,
|
||||
) -> Result<Vec<OriginStorage>, String> {
|
||||
let attach_result: AttachToTargetResult = client
|
||||
.send_command_typed(
|
||||
"Target.attachToTarget",
|
||||
&AttachToTargetParams {
|
||||
target_id: target_id.to_string(),
|
||||
flatten: true,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let temp_session = &attach_result.session_id;
|
||||
|
||||
client
|
||||
.send_command_no_params("Page.enable", Some(temp_session))
|
||||
.await?;
|
||||
client
|
||||
.send_command_no_params("Runtime.enable", Some(temp_session))
|
||||
.await?;
|
||||
|
||||
// Blank HTML response body, pre-encoded to avoid repeated base64 work per request
|
||||
let blank_html_b64 = base64::engine::general_purpose::STANDARD.encode("<html></html>");
|
||||
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Fetch.enable",
|
||||
Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut event_rx = client.subscribe();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for target_origin in origins {
|
||||
let nav_url = format!("{}/", target_origin.trim_end_matches('/'));
|
||||
if client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": nav_url })),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fulfill intercepted requests with blank HTML until the page loads
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
|
||||
let mut page_loaded = false;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(tokio::time::Duration::from_secs(2), event_rx.recv()).await {
|
||||
Ok(Ok(evt)) if evt.session_id.as_deref() == Some(temp_session) => {
|
||||
if evt.method == "Fetch.requestPaused" {
|
||||
if let Some(request_id) =
|
||||
evt.params.get("requestId").and_then(|v| v.as_str())
|
||||
{
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Fetch.fulfillRequest",
|
||||
Some(json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": 200,
|
||||
"responseHeaders": [
|
||||
{ "name": "Content-Type", "value": "text/html" }
|
||||
],
|
||||
"body": &blank_html_b64
|
||||
})),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else if evt.method == "Page.loadEventFired" {
|
||||
page_loaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(_)) => continue, // event for a different session
|
||||
Ok(Err(_)) => continue, // lagged or closed — retry within deadline
|
||||
Err(_) => break, // outer timeout elapsed
|
||||
}
|
||||
}
|
||||
|
||||
if !page_loaded {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(storage) = eval_origin_storage(client, temp_session, origin_js).await {
|
||||
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
|
||||
results.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub async fn save_state(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
path: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
session_id_str: &str,
|
||||
visited_origins: &HashSet<String>,
|
||||
) -> Result<String, String> {
|
||||
let cookies = cookies::get_all_cookies(client, session_id).await?;
|
||||
|
||||
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;
|
||||
})()"#;
|
||||
|
||||
// Merge visited origins with current frame tree origins
|
||||
let mut all_origins = visited_origins.clone();
|
||||
if let Ok(tree_result) = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(session_id))
|
||||
.await
|
||||
{
|
||||
if let Some(tree) = tree_result.get("frameTree") {
|
||||
collect_frame_origins(tree, &mut all_origins);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Collect localStorage from the current page
|
||||
let mut origins = Vec::new();
|
||||
let mut current_origin = String::new();
|
||||
|
||||
if let Some(storage) = eval_origin_storage(client, session_id, origin_js).await {
|
||||
current_origin = storage.origin.clone();
|
||||
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
|
||||
origins.push(storage);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collect localStorage from remaining origins via a disposable temp target
|
||||
all_origins.remove(¤t_origin);
|
||||
if !all_origins.is_empty() {
|
||||
let remaining: Vec<String> = all_origins.into_iter().collect();
|
||||
if let Ok(temp_origins) =
|
||||
collect_storage_via_temp_target(client, &remaining, origin_js).await
|
||||
{
|
||||
origins.extend(temp_origins);
|
||||
}
|
||||
}
|
||||
|
||||
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().is_none_or(|(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
}
|
||||
best_path.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Dispatch a state management command from its JSON payload.
|
||||
/// Returns `Some(result)` for recognised state_* actions, `None` otherwise.
|
||||
pub fn dispatch_state_command(cmd: &Value) -> Option<Result<Value, String>> {
|
||||
let action = cmd.get("action").and_then(|v| v.as_str())?;
|
||||
match action {
|
||||
"state_list" => Some(state_list()),
|
||||
"state_show" => Some(
|
||||
cmd.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'path' parameter".to_string())
|
||||
.and_then(state_show),
|
||||
),
|
||||
"state_clear" => {
|
||||
let path = cmd.get("path").and_then(|v| v.as_str());
|
||||
Some(state_clear(path))
|
||||
}
|
||||
"state_clean" => {
|
||||
let days = cmd.get("days").and_then(|v| v.as_u64()).unwrap_or(30);
|
||||
Some(state_clean(days))
|
||||
}
|
||||
"state_rename" => Some(
|
||||
cmd.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'path' parameter".to_string())
|
||||
.and_then(|path| {
|
||||
cmd.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'name' parameter".to_string())
|
||||
.and_then(|name| state_rename(path, name))
|
||||
}),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_routes_state_list() {
|
||||
let cmd = serde_json::json!({ "action": "state_list" });
|
||||
let result = dispatch_state_command(&cmd);
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_returns_none_for_unknown() {
|
||||
let cmd = serde_json::json!({ "action": "navigate" });
|
||||
assert!(dispatch_state_command(&cmd).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_returns_none_for_missing_action() {
|
||||
let cmd = serde_json::json!({});
|
||||
assert!(dispatch_state_command(&cmd).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_show_missing_path() {
|
||||
let cmd = serde_json::json!({ "action": "state_show" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_rename_missing_params() {
|
||||
let cmd = serde_json::json!({ "action": "state_rename" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
|
||||
|
||||
let cmd = serde_json::json!({ "action": "state_rename", "path": "/tmp/test.json" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'name' parameter");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Stealth anti-detection module.
|
||||
//!
|
||||
//! Injects browser-level patches to evade bot detection (creepjs, sannysoft,
|
||||
//! Cloudflare Turnstile, etc.) by normalizing fingerprint signals that betray
|
||||
//! headless or automated Chrome instances.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
/// Full stealth JS payload compiled at build time (for --launch mode).
|
||||
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js");
|
||||
|
||||
/// Minimal stealth script for CDP-attach mode (connecting to user's real Chrome).
|
||||
/// Only removes navigator.webdriver — the browser's own fingerprint is already real.
|
||||
/// Minimal stealth script for CDP-attach mode.
|
||||
/// Emulation.setAutomationOverride handles navigator.webdriver at the native
|
||||
/// level, so no JS patching is needed in CdpAttach mode. An empty script
|
||||
/// avoids creating any detectable lie-props artifacts.
|
||||
const MINIMAL_STEALTH_SCRIPT: &str = "";
|
||||
|
||||
/// Chrome launch arguments that reduce automation fingerprint surface.
|
||||
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=default",
|
||||
];
|
||||
|
||||
/// Connection mode determines which stealth patches to apply.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum StealthMode {
|
||||
/// Connected to user's real Chrome — minimal patches only (webdriver removal).
|
||||
/// The browser already has a real fingerprint; heavy patches would create detectable lies.
|
||||
CdpAttach,
|
||||
/// Launched a new Chrome instance — apply full stealth patches.
|
||||
FullLaunch,
|
||||
}
|
||||
|
||||
/// Build the stealth JS payload for the given mode and locale.
|
||||
pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
if mode == StealthMode::CdpAttach {
|
||||
return MINIMAL_STEALTH_SCRIPT.to_string();
|
||||
}
|
||||
|
||||
// Full launch mode: inject all patches
|
||||
let locale = locale.unwrap_or("en-US");
|
||||
let base_lang = locale.split('-').next().unwrap_or(locale);
|
||||
let languages: Vec<&str> = if base_lang == locale {
|
||||
vec![locale]
|
||||
} else {
|
||||
vec![locale, base_lang]
|
||||
};
|
||||
let config_line = format!(
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
|
||||
locale,
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
);
|
||||
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply stealth patches to a browser session.
|
||||
///
|
||||
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
||||
/// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override.
|
||||
pub async fn apply_stealth(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// First: disable the automation flag at the CDP protocol level.
|
||||
// This tells Chrome to natively set navigator.webdriver = false,
|
||||
// which is undetectable by lie-detection systems like CreepJS.
|
||||
// Falls back gracefully on older Chrome versions that don't support this.
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Emulation.setAutomationOverride",
|
||||
Some(json!({ "enabled": false })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
let script = build_stealth_script(mode, locale);
|
||||
|
||||
// Inject stealth scripts to run before page JS
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// In full launch mode, also override UA to remove HeadlessChrome marker
|
||||
if mode == StealthMode::FullLaunch {
|
||||
let ua = get_browser_user_agent(client, session_id).await;
|
||||
if let Some(ua) = ua {
|
||||
let cleaned = ua.replace("HeadlessChrome", "Chrome");
|
||||
if cleaned != ua {
|
||||
client
|
||||
.send_command(
|
||||
"Emulation.setUserAgentOverride",
|
||||
Some(json!({
|
||||
"userAgent": cleaned,
|
||||
"acceptLanguage": locale.unwrap_or("en-US"),
|
||||
"platform": platform_string(),
|
||||
"userAgentMetadata": build_ua_metadata(&cleaned, locale),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the browser's User-Agent string via CDP.
|
||||
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
||||
let result = client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(json!({ "expression": "navigator.userAgent", "returnByValue": true })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
result
|
||||
.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Also run stealth script on the current page (for already-loaded pages after CDP attach).
|
||||
pub async fn apply_stealth_to_current_page(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let script = build_stealth_script(mode, locale);
|
||||
client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(json!({
|
||||
"expression": script,
|
||||
"returnByValue": true,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip sourceURL comments from CDP expressions to avoid leaking
|
||||
/// automation-framework identifiers in stack traces.
|
||||
pub fn strip_source_url_labels(input: &str) -> String {
|
||||
// Remove //# sourceURL=... and //@ sourceURL=...
|
||||
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
|
||||
let output = re_line.replace_all(input, "");
|
||||
// Remove /*# sourceURL=...*/ block comments
|
||||
let re_block =
|
||||
regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
||||
re_block.replace_all(&output, "").to_string()
|
||||
}
|
||||
|
||||
fn platform_string() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Win32"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_hint() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_version_hint() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"14.0.0"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"10.0.0"
|
||||
} else {
|
||||
"6.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value {
|
||||
// Extract Chrome version from UA string
|
||||
let chrome_version = ua
|
||||
.split("Chrome/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.unwrap_or("130.0.0.0");
|
||||
let major = chrome_version.split('.').next().unwrap_or("130");
|
||||
|
||||
let _lang = locale.unwrap_or("en-US");
|
||||
|
||||
json!({
|
||||
"brands": [
|
||||
{ "brand": "Chromium", "version": major },
|
||||
{ "brand": "Google Chrome", "version": major },
|
||||
{ "brand": "Not?A_Brand", "version": "99" },
|
||||
],
|
||||
"fullVersionList": [
|
||||
{ "brand": "Chromium", "version": chrome_version },
|
||||
{ "brand": "Google Chrome", "version": chrome_version },
|
||||
{ "brand": "Not?A_Brand", "version": "99.0.0.0" },
|
||||
],
|
||||
"fullVersion": chrome_version,
|
||||
"platform": platform_hint(),
|
||||
"platformVersion": platform_version_hint(),
|
||||
"architecture": if cfg!(target_arch = "aarch64") { "arm" } else { "x86" },
|
||||
"model": "",
|
||||
"mobile": false,
|
||||
"bitness": "64",
|
||||
"wow64": false,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drag Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.4 sans-serif;
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
#pad {
|
||||
position: relative;
|
||||
width: 800px;
|
||||
height: 500px;
|
||||
margin: 24px;
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#target {
|
||||
position: absolute;
|
||||
left: 320px;
|
||||
top: 40px;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
background: #e34c26;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#target.dragging {
|
||||
cursor: grabbing;
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
#log {
|
||||
margin: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="pad">
|
||||
<div id="target">drag me</div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const target = document.getElementById("target");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
window.__dragProbe = {
|
||||
dragging: false,
|
||||
events: [],
|
||||
finalLeft: 320,
|
||||
finalTop: 40,
|
||||
};
|
||||
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
function pushEvent(event, extra = {}) {
|
||||
window.__dragProbe.events.push({
|
||||
type: event.type,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
target: event.target.id || event.target.tagName,
|
||||
...extra,
|
||||
});
|
||||
logEl.textContent = JSON.stringify(window.__dragProbe, null, 2);
|
||||
}
|
||||
|
||||
function onPointerLikeStart(event) {
|
||||
if (event.type === "mousedown") {
|
||||
const rect = target.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
window.__dragProbe.dragging = true;
|
||||
target.classList.add("dragging");
|
||||
event.preventDefault();
|
||||
}
|
||||
pushEvent(event, { phase: "start" });
|
||||
}
|
||||
|
||||
target.addEventListener("mousedown", (event) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
window.__dragProbe.dragging = true;
|
||||
target.classList.add("dragging");
|
||||
event.preventDefault();
|
||||
pushEvent(event, { phase: "start" });
|
||||
});
|
||||
target.addEventListener("pointerdown", onPointerLikeStart);
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (window.__dragProbe.dragging) {
|
||||
const left = event.clientX - offsetX;
|
||||
const top = event.clientY - offsetY;
|
||||
target.style.left = `${left}px`;
|
||||
target.style.top = `${top}px`;
|
||||
window.__dragProbe.finalLeft = left;
|
||||
window.__dragProbe.finalTop = top;
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
pushEvent(event);
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", (event) => {
|
||||
if (window.__dragProbe.dragging) {
|
||||
window.__dragProbe.dragging = false;
|
||||
target.classList.remove("dragging");
|
||||
}
|
||||
pushEvent(event, { phase: "end" });
|
||||
});
|
||||
document.addEventListener("pointerup", (event) => {
|
||||
pushEvent(event, { phase: "end" });
|
||||
});
|
||||
target.addEventListener("dragstart", (event) => {
|
||||
pushEvent(event, { phase: "dragstart" });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>HTML5 Drag Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font: 14px/1.4 sans-serif;
|
||||
}
|
||||
|
||||
#source, #dest {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #666;
|
||||
user-select: none;
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
#source {
|
||||
background: #f97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#dest {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="source" draggable="true">drag source</div>
|
||||
<div id="dest">drop zone</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const source = document.getElementById("source");
|
||||
const dest = document.getElementById("dest");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
window.__html5DragProbe = { events: [] };
|
||||
|
||||
function pushEvent(event, extra = {}) {
|
||||
window.__html5DragProbe.events.push({
|
||||
type: event.type,
|
||||
target: event.target.id || event.target.tagName,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
...extra,
|
||||
});
|
||||
logEl.textContent = JSON.stringify(window.__html5DragProbe, null, 2);
|
||||
}
|
||||
|
||||
for (const type of ["pointerdown", "mousedown", "dragstart", "drag", "dragend"]) {
|
||||
source.addEventListener(type, (event) => {
|
||||
if (type === "dragstart") {
|
||||
event.dataTransfer.setData("text/plain", "probe");
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
for (const type of ["pointermove", "mousemove", "dragenter", "dragover", "drop", "pointerup", "mouseup"]) {
|
||||
document.addEventListener(type, (event) => {
|
||||
if (type === "dragover") {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (type === "drop") {
|
||||
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
|
||||
return;
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
dest.addEventListener("dragover", (event) => event.preventDefault());
|
||||
dest.addEventListener("drop", (event) => {
|
||||
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pointer Capture Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font: 14px/1.4 sans-serif;
|
||||
}
|
||||
#crop {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
height: 180px;
|
||||
border: 2px solid #fff;
|
||||
outline: 1px solid #555;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
#handle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: -16px;
|
||||
left: -16px;
|
||||
padding-top: 13px;
|
||||
padding-left: 13px;
|
||||
box-sizing: content-box;
|
||||
background: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
#handle::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-top: 2px solid white;
|
||||
border-left: 2px solid white;
|
||||
}
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="crop" aria-label="crop area">
|
||||
<div id="handle" aria-label="crop handle topLeft" data-anchor="topLeft"></div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const crop = document.getElementById("crop");
|
||||
const handle = document.getElementById("handle");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
const state = {
|
||||
targetAnchor: null,
|
||||
dragging: false,
|
||||
moved: false,
|
||||
events: [],
|
||||
};
|
||||
window.__pointerCaptureProbe = state;
|
||||
|
||||
function sync() {
|
||||
logEl.textContent = JSON.stringify(state, null, 2);
|
||||
}
|
||||
|
||||
function push(event, extra = {}) {
|
||||
state.events.push({
|
||||
type: event.type,
|
||||
target: event.target.id || event.target.tagName,
|
||||
currentTarget: event.currentTarget.id || event.currentTarget.tagName,
|
||||
pointerId: event.pointerId,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
hasCapture: event.currentTarget.hasPointerCapture?.(event.pointerId) ?? false,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
...extra,
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
crop.addEventListener("pointerdown", (event) => {
|
||||
state.targetAnchor = event.target.getAttribute("data-anchor");
|
||||
crop.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
push(event, { phase: "down", targetAnchor: state.targetAnchor });
|
||||
});
|
||||
|
||||
crop.addEventListener("pointermove", (event) => {
|
||||
const hasCapture = crop.hasPointerCapture(event.pointerId);
|
||||
if (hasCapture && state.targetAnchor) {
|
||||
state.dragging = true;
|
||||
state.moved = true;
|
||||
}
|
||||
push(event, { phase: hasCapture ? "drag" : "hover", targetAnchor: state.targetAnchor });
|
||||
});
|
||||
|
||||
crop.addEventListener("pointerup", (event) => {
|
||||
const hadCapture = crop.hasPointerCapture(event.pointerId);
|
||||
state.dragging = false;
|
||||
push(event, { phase: "up", targetAnchor: state.targetAnchor, hadCapture });
|
||||
state.targetAnchor = null;
|
||||
});
|
||||
|
||||
handle.addEventListener("pointerdown", (event) => push(event, { listener: "handle" }));
|
||||
handle.addEventListener("pointermove", (event) => push(event, { listener: "handle" }));
|
||||
handle.addEventListener("pointerup", (event) => push(event, { listener: "handle" }));
|
||||
|
||||
sync();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,240 @@
|
||||
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 fn build_ios_capabilities(
|
||||
device_udid: Option<&str>,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Value {
|
||||
let mut caps = json!({
|
||||
"platformName": "iOS",
|
||||
"appium:automationName": "XCUITest",
|
||||
"browserName": "Safari",
|
||||
"appium:noReset": true,
|
||||
});
|
||||
|
||||
if let Some(name) = device_name {
|
||||
caps["appium:deviceName"] = json!(name);
|
||||
} else {
|
||||
caps["appium:deviceName"] = json!("iPhone");
|
||||
}
|
||||
|
||||
if let Some(ver) = platform_version {
|
||||
caps["appium:platformVersion"] = json!(ver);
|
||||
}
|
||||
|
||||
if let Some(udid) = device_udid {
|
||||
caps["appium:udid"] = json!(udid);
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
|
||||
pub async fn create_ios_session(
|
||||
&mut self,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let caps = Self::build_ios_capabilities(
|
||||
self.device_udid.as_deref(),
|
||||
device_name,
|
||||
platform_version,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ios_capabilities_use_vendor_prefix() {
|
||||
let caps = AppiumManager::build_ios_capabilities(
|
||||
Some("TEST-UDID-123"),
|
||||
Some("iPhone 16 Pro"),
|
||||
Some("18.5"),
|
||||
);
|
||||
|
||||
// W3C standard capabilities must NOT have vendor prefix
|
||||
assert!(caps.get("platformName").is_some());
|
||||
assert!(caps.get("browserName").is_some());
|
||||
|
||||
// Non-standard capabilities MUST have appium: vendor prefix
|
||||
assert!(caps.get("appium:automationName").is_some());
|
||||
assert!(caps.get("appium:noReset").is_some());
|
||||
assert!(caps.get("appium:deviceName").is_some());
|
||||
assert!(caps.get("appium:platformVersion").is_some());
|
||||
assert!(caps.get("appium:udid").is_some());
|
||||
|
||||
// Must NOT have unprefixed non-standard capabilities
|
||||
assert!(caps.get("automationName").is_none());
|
||||
assert!(caps.get("noReset").is_none());
|
||||
assert!(caps.get("deviceName").is_none());
|
||||
assert!(caps.get("udid").is_none());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
+857
-110
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Global mutex shared across all test modules to prevent parallel tests from
|
||||
/// interfering with each other when mutating environment variables.
|
||||
pub static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard that locks [`ENV_MUTEX`] and restores environment variables on drop.
|
||||
pub struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
pub fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), std::env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
|
||||
pub fn set(&self, name: &str, value: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::set called with unregistered var: {name}"
|
||||
);
|
||||
std::env::set_var(name, value);
|
||||
}
|
||||
|
||||
pub fn remove(&self, name: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::remove called with unregistered var: {name}"
|
||||
);
|
||||
std::env::remove_var(name);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => std::env::set_var(name, v),
|
||||
None => std::env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use crate::color;
|
||||
use std::path::Path;
|
||||
use std::process::{exit, Command, Stdio};
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/agent-browser/latest";
|
||||
|
||||
enum InstallMethod {
|
||||
Npm,
|
||||
Pnpm,
|
||||
Yarn,
|
||||
Bun,
|
||||
Homebrew,
|
||||
Cargo,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
async fn fetch_latest_version() -> Result<String, String> {
|
||||
let resp = reqwest::get(NPM_REGISTRY_URL)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse version info: {}", e))?;
|
||||
|
||||
body.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No version field in registry response".to_string())
|
||||
}
|
||||
|
||||
/// Parse the `.install-method` marker written by postinstall.js.
|
||||
fn read_install_method_marker(exe_dir: &Path) -> Option<InstallMethod> {
|
||||
let contents = std::fs::read_to_string(exe_dir.join(".install-method")).ok()?;
|
||||
match contents.trim() {
|
||||
"npm" => Some(InstallMethod::Npm),
|
||||
"pnpm" => Some(InstallMethod::Pnpm),
|
||||
"yarn" => Some(InstallMethod::Yarn),
|
||||
"bun" => Some(InstallMethod::Bun),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_install_method() -> InstallMethod {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Resolve symlinks to find the real binary location
|
||||
let real_path = exe.canonicalize().unwrap_or(exe);
|
||||
|
||||
// Preferred: read the marker file written at install time
|
||||
if let Some(dir) = real_path.parent() {
|
||||
if let Some(method) = read_install_method_marker(dir) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: infer from executable path
|
||||
let path_str = real_path.to_string_lossy();
|
||||
|
||||
if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
|
||||
return InstallMethod::Cargo;
|
||||
}
|
||||
|
||||
if path_str.contains("/Cellar/agent-browser/")
|
||||
|| path_str.contains("/homebrew/")
|
||||
|| path_str.contains("/linuxbrew/")
|
||||
{
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
|
||||
if path_str.contains("/pnpm/") || path_str.contains("/pnpm-global/") {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if path_str.contains("/.yarn/") || path_str.contains("/yarn/global/") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if path_str.contains("/.bun/") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if path_str.contains("node_modules/agent-browser")
|
||||
|| path_str.contains("node_modules\\agent-browser")
|
||||
{
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: probe package managers via subprocess
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
if command_succeeds("brew", &["list", "agent-browser"]) {
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
}
|
||||
|
||||
if command_output_contains(
|
||||
"pnpm",
|
||||
&["list", "-g", "agent-browser", "--depth=0"],
|
||||
"agent-browser",
|
||||
) {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if command_output_contains("yarn", &["global", "list", "--depth=0"], "agent-browser") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if command_output_contains("bun", &["pm", "ls", "-g"], "agent-browser") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if command_succeeds("npm", &["list", "-g", "agent-browser", "--depth=0"]) {
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
|
||||
InstallMethod::Unknown
|
||||
}
|
||||
|
||||
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn command_output_contains(cmd: &str, args: &[&str], needle: &str) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains(needle))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn run_upgrade_command(method: &InstallMethod) -> bool {
|
||||
let (cmd, args, display): (&str, &[&str], &str) = match method {
|
||||
InstallMethod::Npm => (
|
||||
"npm",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"npm install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Pnpm => (
|
||||
"pnpm",
|
||||
&["add", "-g", "agent-browser@latest"],
|
||||
"pnpm add -g agent-browser@latest",
|
||||
),
|
||||
// NOTE: `yarn global` is Yarn Classic (v1) only; Yarn Berry (v2+) removed it.
|
||||
// Users on Yarn v2+ won't reach this path — detection falls through to Unknown.
|
||||
InstallMethod::Yarn => (
|
||||
"yarn",
|
||||
&["global", "add", "agent-browser@latest"],
|
||||
"yarn global add agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Bun => (
|
||||
"bun",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"bun install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Homebrew => (
|
||||
"brew",
|
||||
&["upgrade", "agent-browser"],
|
||||
"brew upgrade agent-browser",
|
||||
),
|
||||
InstallMethod::Cargo => (
|
||||
"cargo",
|
||||
&["install", "agent-browser", "--force"],
|
||||
"cargo install agent-browser --force",
|
||||
),
|
||||
InstallMethod::Unknown => return false,
|
||||
};
|
||||
|
||||
println!("Running: {}", display);
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn run_upgrade() {
|
||||
let current = CURRENT_VERSION;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
});
|
||||
|
||||
let latest = match rt.block_on(fetch_latest_version()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not check latest version: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
if !latest.is_empty() && current == latest.as_str() {
|
||||
println!(
|
||||
"{} agent-browser is already at the latest version (v{})",
|
||||
color::success_indicator(),
|
||||
current
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let method = detect_install_method();
|
||||
|
||||
let method_name = match &method {
|
||||
InstallMethod::Npm => "npm",
|
||||
InstallMethod::Pnpm => "pnpm",
|
||||
InstallMethod::Yarn => "yarn",
|
||||
InstallMethod::Bun => "bun",
|
||||
InstallMethod::Homebrew => "Homebrew",
|
||||
InstallMethod::Cargo => "Cargo",
|
||||
InstallMethod::Unknown => "",
|
||||
};
|
||||
|
||||
if matches!(method, InstallMethod::Unknown) {
|
||||
eprintln!(
|
||||
"{} Could not detect installation method.",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" To update manually, run one of:");
|
||||
eprintln!(" npm install -g agent-browser@latest # npm");
|
||||
eprintln!(" pnpm add -g agent-browser@latest # pnpm");
|
||||
eprintln!(" yarn global add agent-browser@latest # yarn");
|
||||
eprintln!(" bun install -g agent-browser@latest # bun");
|
||||
eprintln!(" brew upgrade agent-browser # Homebrew");
|
||||
eprintln!(" cargo install agent-browser --force # Cargo");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
println!("Detected installation via {}.", method_name);
|
||||
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!(
|
||||
"Upgrading agent-browser... v{} → v{}",
|
||||
current, latest
|
||||
))
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!("Upgrading agent-browser (v{})...", current))
|
||||
);
|
||||
}
|
||||
|
||||
let success = run_upgrade_command(&method);
|
||||
|
||||
if success {
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{} Done! v{} → v{}",
|
||||
color::success_indicator(),
|
||||
current,
|
||||
latest
|
||||
);
|
||||
} else {
|
||||
println!("{} Done!", color::success_indicator());
|
||||
}
|
||||
} else {
|
||||
eprintln!("{} Upgrade failed.", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
import Link from "next/link";
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractText(children: React.ReactNode): string {
|
||||
if (typeof children === "string") return children;
|
||||
if (typeof children === "number") return String(children);
|
||||
if (Array.isArray(children)) return children.map(extractText).join("");
|
||||
if (children && typeof children === "object") {
|
||||
const obj = children as unknown as Record<string, unknown>;
|
||||
if ("props" in obj) {
|
||||
const props = obj.props as { children?: React.ReactNode } | undefined;
|
||||
return extractText(props?.children);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
return {
|
||||
...components,
|
||||
h2: ({ children }: { children?: React.ReactNode }) => {
|
||||
const id = slugify(extractText(children));
|
||||
return <h2 id={id}>{children}</h2>;
|
||||
},
|
||||
h3: ({ children }: { children?: React.ReactNode }) => {
|
||||
const id = slugify(extractText(children));
|
||||
return <h3 id={id}>{children}</h3>;
|
||||
},
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
if (href?.startsWith("/")) {
|
||||
return <Link href={href}>{children}</Link>;
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
if (className) {
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
return <code>{children}</code>;
|
||||
},
|
||||
pre: async ({ children }: { children?: React.ReactNode }) => {
|
||||
const codeElement = children as React.ReactElement<{
|
||||
className?: string;
|
||||
children?: string;
|
||||
}>;
|
||||
const className = codeElement?.props?.className || "";
|
||||
const lang = className.replace("language-", "") || "bash";
|
||||
const code = codeElement?.props?.children || "";
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
code={typeof code === "string" ? code : String(code)}
|
||||
lang={lang}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import createMDX from "@next/mdx";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
|
||||
serverExternalPackages: ["just-bash", "bash-tool"],
|
||||
};
|
||||
|
||||
const withMDX = createMDX({});
|
||||
|
||||
export default withMDX(nextConfig);
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless agent-browser next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.80",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/mdx": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^16.1.6",
|
||||
"@streamdown/code": "^1.0.2",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.36.2",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"ai": "^6.0.78",
|
||||
"bash-tool": "^1.3.14",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"just-bash": "^2.9.6",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shiki": "^3.21.0",
|
||||
"streamdown": "^2.1.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Generated
-8135
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,119 +0,0 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { convertToModelMessages, stepCountIs, streamText } from "ai";
|
||||
import type { ModelMessage, UIMessage } from "ai";
|
||||
import { createBashTool } from "bash-tool";
|
||||
import { headers } from "next/headers";
|
||||
import { allDocsPages } from "@/lib/docs-navigation";
|
||||
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents.
|
||||
|
||||
GitHub repository: https://github.com/leeguooooo/agent-browser
|
||||
Documentation: https://agent-browser.dev
|
||||
npm package: agent-browser-stealth
|
||||
|
||||
You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory.
|
||||
|
||||
When answering questions:
|
||||
- Use the bash tool to list files (ls /workspace/) or search for content (grep -r "keyword" /workspace/)
|
||||
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/index.md")
|
||||
- Do NOT use bash to write, create, modify, or delete files (no tee, cat >, sed -i, echo >, cp, mv, rm, mkdir, touch, etc.) — you are read-only
|
||||
- Always base your answers on the actual documentation content
|
||||
- Be concise and accurate
|
||||
- If the docs don't cover a topic, say so honestly
|
||||
- Do NOT include source references or file paths in your response
|
||||
- Do NOT use emojis in your responses`;
|
||||
|
||||
async function loadDocsFiles(): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
allDocsPages.map(async (page) => {
|
||||
const slug = page.href === "/" ? "" : page.href.replace(/^\//, "");
|
||||
const filePath = slug
|
||||
? join(process.cwd(), "src", "app", slug, "page.mdx")
|
||||
: join(process.cwd(), "src", "app", "page.mdx");
|
||||
|
||||
const raw = await readFile(filePath, "utf-8");
|
||||
const md = mdxToCleanMarkdown(raw);
|
||||
const fileName = slug ? `/${slug}.md` : "/index.md";
|
||||
return { fileName, md };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
files[result.value.fileName] = result.value.md;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function addCacheControl(messages: ModelMessage[]): ModelMessage[] {
|
||||
if (messages.length === 0) return messages;
|
||||
return messages.map((message, index) => {
|
||||
if (index === messages.length - 1) {
|
||||
return {
|
||||
...message,
|
||||
providerOptions: {
|
||||
...message.providerOptions,
|
||||
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||
},
|
||||
};
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const headersList = await headers();
|
||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
const [minuteResult, dailyResult] = await Promise.all([
|
||||
minuteRateLimit.limit(ip),
|
||||
dailyRateLimit.limit(ip),
|
||||
]);
|
||||
|
||||
if (!minuteResult.success || !dailyResult.success) {
|
||||
const isMinuteLimit = !minuteResult.success;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Rate limit exceeded",
|
||||
message: isMinuteLimit
|
||||
? "Too many requests. Please wait a moment before trying again."
|
||||
: "Daily limit reached. Please try again tomorrow.",
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
|
||||
const docsFiles = await loadDocsFiles();
|
||||
const {
|
||||
tools: { bash, readFile },
|
||||
} = await createBashTool({ files: docsFiles });
|
||||
|
||||
const result = streamText({
|
||||
model: DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: await convertToModelMessages(messages),
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: { bash, readFile },
|
||||
prepareStep: ({ messages: stepMessages }) => ({
|
||||
messages: addCacheControl(stepMessages),
|
||||
}),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
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).
|
||||
|
||||
Project policy:
|
||||
|
||||
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
|
||||
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
|
||||
|
||||
```bash
|
||||
# Start Chrome with: google-chrome --remote-debugging-port=9222
|
||||
|
||||
# Connect once, then run commands without --cdp
|
||||
agent-browser connect 9222
|
||||
agent-browser snapshot
|
||||
agent-browser tab
|
||||
agent-browser close
|
||||
|
||||
# Or pass --cdp on each command
|
||||
agent-browser --cdp 9222 snapshot
|
||||
```
|
||||
|
||||
## Remote WebSocket URLs
|
||||
|
||||
Connect to remote browser services via WebSocket URL:
|
||||
|
||||
```bash
|
||||
# Connect to remote browser service
|
||||
agent-browser --cdp "wss://browser-service.com/cdp?token=..." snapshot
|
||||
|
||||
# Works with any CDP-compatible service
|
||||
agent-browser --cdp "ws://localhost:9222/devtools/browser/abc123" open example.com
|
||||
```
|
||||
|
||||
The `--cdp` flag accepts either:
|
||||
|
||||
- A port number (e.g., `9222`) for local connections via `http://localhost:{port}`
|
||||
- A full WebSocket URL (e.g., `wss://...` or `ws://...`) for remote browser services
|
||||
|
||||
## Auto-Connect
|
||||
|
||||
Use `--auto-connect` to automatically discover and connect to a running Chrome instance without specifying a port:
|
||||
|
||||
```bash
|
||||
# Auto-discover running Chrome with remote debugging
|
||||
agent-browser --auto-connect open example.com
|
||||
agent-browser --auto-connect snapshot
|
||||
|
||||
# Or via environment variable
|
||||
AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot
|
||||
```
|
||||
|
||||
Auto-connect discovers Chrome by:
|
||||
|
||||
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
|
||||
2. Falling back to probing common debugging ports (9222, 9229, 9333)
|
||||
|
||||
This is useful when:
|
||||
|
||||
- Chrome 144+ has remote debugging enabled via `chrome://inspect/#remote-debugging` (which uses a dynamic port)
|
||||
- You want a zero-configuration connection to your existing browser
|
||||
- You don't want to track which port Chrome is using
|
||||
|
||||
## Color scheme
|
||||
|
||||
Playwright overrides the browser's color scheme to `light` by default when connecting via CDP. Use `--color-scheme` to set a persistent preference:
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 --color-scheme dark open https://example.com
|
||||
agent-browser --cdp 9222 snapshot # stays in dark mode
|
||||
```
|
||||
|
||||
Or set it globally via config or environment variable:
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.com
|
||||
```
|
||||
|
||||
## Stealth behavior
|
||||
|
||||
`--stealth` is enabled by default across connection modes, but capabilities depend on how you connect:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Use `--debug` to print the active connection type and applied stealth capabilities.
|
||||
|
||||
## Use cases
|
||||
|
||||
This enables control of:
|
||||
|
||||
- Electron apps
|
||||
- Chrome/Chromium with remote debugging
|
||||
- WebView2 applications
|
||||
- Remote browser services (via WebSocket URL)
|
||||
- Any browser exposing a CDP endpoint
|
||||
|
||||
## Global options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Cloud providers
|
||||
|
||||
Use cloud browser infrastructure when local browsers aren't available:
|
||||
|
||||
```bash
|
||||
# Browserbase
|
||||
export BROWSERBASE_API_KEY="your-api-key"
|
||||
export BROWSERBASE_PROJECT_ID="your-project-id"
|
||||
agent-browser -p browserbase open https://example.com
|
||||
|
||||
# Browser Use
|
||||
export BROWSER_USE_API_KEY="your-api-key"
|
||||
agent-browser -p browseruse open https://example.com
|
||||
|
||||
# Kernel
|
||||
export KERNEL_API_KEY="your-api-key"
|
||||
agent-browser -p kernel open https://example.com
|
||||
|
||||
# Or via environment variable
|
||||
export AGENT_BROWSER_PROVIDER=browserbase
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
The `-p` flag takes precedence over `AGENT_BROWSER_PROVIDER`.
|
||||
@@ -1,399 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("changelog")
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.10.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Session persistence** - Automatic save/restore of cookies and localStorage across browser restarts using `--session-name` flag
|
||||
- **Encrypted state** - Optional AES-256-GCM encryption for saved session state data
|
||||
- **State management commands** - New commands for listing, showing, renaming, clearing, and cleaning up session state files
|
||||
- **New tab on click** - Added `--new-tab` option for click commands to open links in new tabs
|
||||
|
||||
```bash
|
||||
# Persist session state
|
||||
agent-browser --session-name myapp open https://example.com
|
||||
|
||||
# Manage saved states
|
||||
agent-browser state list
|
||||
agent-browser state show myapp
|
||||
agent-browser state clear myapp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.9.4
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed all Clippy lint warnings in the Rust CLI
|
||||
|
||||
---
|
||||
|
||||
## v0.9.3
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added support for custom executable path in CLI browser launch options
|
||||
- Documentation site UI improvements including a new chat component with sheet-based interface
|
||||
|
||||
---
|
||||
|
||||
## v0.9.2
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Migrated documentation site to MDX for improved content authoring
|
||||
- Added AI-powered docs chat feature
|
||||
- Updated README with Homebrew installation instructions for macOS users
|
||||
|
||||
---
|
||||
|
||||
## v0.9.1
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **`--allow-file-access` flag** - Enable opening and interacting with local `file://` URLs (PDFs, HTML files) by passing Chromium flags that allow JavaScript access to local files
|
||||
- **`-C`/`--cursor` flag for snapshots** - Include cursor-interactive elements like divs with onclick handlers or `cursor:pointer` styles
|
||||
|
||||
```bash
|
||||
agent-browser --allow-file-access open file:///path/to/document.pdf
|
||||
agent-browser snapshot -C
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.9.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **iOS Simulator support** - Mobile Safari testing via Appium with real device and simulator support
|
||||
|
||||
```bash
|
||||
# List available iOS simulators
|
||||
agent-browser device list
|
||||
|
||||
# Launch on iOS device
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
|
||||
# Touch interactions
|
||||
agent-browser tap @e1
|
||||
agent-browser swipe up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.8.10
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added `--stdin` flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
|
||||
- Fixed binary permission issues on macOS/Linux when postinstall scripts don't run
|
||||
|
||||
---
|
||||
|
||||
## v0.8.9
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added `--stdin` flag for eval command to read JavaScript from stdin
|
||||
|
||||
---
|
||||
|
||||
## v0.8.8
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added base64 encoding support for the eval command with `-b`/`--base64` flag to avoid shell escaping issues
|
||||
- Updated documentation with AI agent setup instructions
|
||||
|
||||
---
|
||||
|
||||
## v0.8.7
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed browser launch options not being passed correctly when using persistent profiles
|
||||
- Added pre-flight checks for socket path length limits and directory write permissions
|
||||
- Improved error handling to properly exit with failure status when browser launch fails
|
||||
|
||||
---
|
||||
|
||||
## v0.8.6
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Improved daemon connection reliability with automatic retry logic for transient errors
|
||||
- CLI now cleans up stale socket and PID files before starting a new daemon
|
||||
|
||||
---
|
||||
|
||||
## v0.8.5
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed version synchronization to automatically update Cargo.lock alongside Cargo.toml during releases
|
||||
- Made the CLI binary executable in the npm package
|
||||
|
||||
---
|
||||
|
||||
## v0.8.4
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed "Daemon not found" error when running through AI agents by resolving symlinks in the executable path
|
||||
|
||||
---
|
||||
|
||||
## v0.8.3
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Replaced shell-based CLI wrappers with a cross-platform Node.js wrapper to enable npx support on Windows
|
||||
- Added postinstall logic to patch npm bin entry on global installs for zero-overhead native binary invocation
|
||||
- Added CI tests to verify global installation across all platforms
|
||||
|
||||
---
|
||||
|
||||
## v0.8.2
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed the Windows CMD wrapper to use the native binary directly instead of routing through Node.js
|
||||
- Added retry logic to CI install command for transient browser installation failures
|
||||
|
||||
---
|
||||
|
||||
## v0.8.1
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved release workflow to validate binary file sizes and ensure binaries are executable after npm install
|
||||
- Updated documentation site with a new mobile navigation system
|
||||
|
||||
---
|
||||
|
||||
## v0.8.0
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Kernel cloud browser provider** - Connect to Kernel (kernel.sh) for remote browser infrastructure with stealth mode and persistent profiles
|
||||
|
||||
```bash
|
||||
# Via -p flag
|
||||
agent-browser -p kernel open https://example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_PROVIDER=kernel
|
||||
export KERNEL_API_KEY=your-api-key
|
||||
agent-browser open https://example.com
|
||||
|
||||
# With persistent profile
|
||||
export KERNEL_PROFILE_NAME=my-profile
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
- **Ignore HTTPS certificate errors** - New flag for working with self-signed certificates and development environments
|
||||
|
||||
```bash
|
||||
agent-browser --ignore-https-errors open https://localhost:3000
|
||||
```
|
||||
|
||||
- **Enhanced cookie management** - Extended `cookies set` command with additional flags for setting cookies before page load
|
||||
|
||||
```bash
|
||||
agent-browser cookies set session_id "abc123" --url https://app.example.com --httpOnly --secure
|
||||
agent-browser cookies set token "xyz" --domain .example.com --path /api --expires 1735689600
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed tab list command not recognizing new pages opened via clicks or `target="_blank"` links
|
||||
- Fixed `check` command hanging indefinitely
|
||||
- Fixed `set device` not applying deviceScaleFactor - HiDPI screenshots now work correctly
|
||||
- Fixed state load and profile persistence not working in v0.7.6
|
||||
- Screenshots now save to temp directory when no path is provided
|
||||
|
||||
### Security
|
||||
|
||||
- Daemon and stream server now reject cross-origin connections
|
||||
|
||||
---
|
||||
|
||||
## v0.7.1
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Fix native binary distribution** - Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Cloud browser providers** - Connect to Browserbase or Browser Use for remote browser infrastructure
|
||||
|
||||
```bash
|
||||
# Via -p flag (recommended)
|
||||
agent-browser -p browserbase open https://example.com
|
||||
agent-browser -p browseruse open https://example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_PROVIDER=browserbase
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
- **Persistent browser profiles** - Store cookies, localStorage, and login sessions across browser restarts
|
||||
|
||||
```bash
|
||||
agent-browser --profile ~/.myapp-profile open myapp.com
|
||||
# Login persists across restarts
|
||||
```
|
||||
|
||||
- **Remote CDP WebSocket URLs** - Connect to remote browser services via WebSocket
|
||||
|
||||
```bash
|
||||
agent-browser --cdp "wss://browser-service.com/cdp?token=..." snapshot
|
||||
```
|
||||
|
||||
- **`download` command** - Trigger downloads and wait for completion
|
||||
|
||||
```bash
|
||||
agent-browser download @e1 ./file.pdf
|
||||
agent-browser wait --download ./output.zip --timeout 30000
|
||||
```
|
||||
|
||||
- **Browser launch configuration** - Fine-grained control over browser startup
|
||||
|
||||
```bash
|
||||
agent-browser --args "--disable-gpu,--no-sandbox" open example.com
|
||||
agent-browser --user-agent "Custom UA" open example.com
|
||||
agent-browser --proxy-bypass "localhost,*.internal" open example.com
|
||||
```
|
||||
|
||||
- **Enhanced skills** - Hierarchical structure with references and templates for Claude Code
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Screenshot command now supports refs and has improved error messages
|
||||
- WebSocket URLs work in `connect` command
|
||||
- Fixed socket file location (uses `~/.agent-browser` instead of TMPDIR)
|
||||
- Windows binary path fix (.exe extension)
|
||||
- State load and path-based actions now show correct output messages
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added Claude Code marketplace plugin installation instructions
|
||||
- Updated skill documentation with references and templates
|
||||
- Improved error documentation
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Video recording** - Record browser sessions to WebM using Playwright's native recording
|
||||
|
||||
```bash
|
||||
agent-browser record start ./demo.webm
|
||||
agent-browser click @e1
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
- **`connect` command** - Connect to a browser via CDP and persist the connection for subsequent commands
|
||||
|
||||
```bash
|
||||
agent-browser connect 9222
|
||||
agent-browser snapshot # No --cdp needed after connect
|
||||
```
|
||||
|
||||
- **`--proxy` flag** - Configure browser proxy with optional authentication
|
||||
|
||||
```bash
|
||||
agent-browser --proxy http://user:pass@proxy.com:8080 open example.com
|
||||
```
|
||||
|
||||
- **`get styles` command** - Extract computed styles from elements
|
||||
|
||||
```bash
|
||||
agent-browser get styles "button"
|
||||
```
|
||||
|
||||
- **Claude marketplace plugin** - Added `.claude-plugin/marketplace.json` for Claude Code integration
|
||||
- **Enhanced network output** - `network requests` now shows method, URL, and resource type
|
||||
- **`--version` flag** - Display CLI version
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix Windows daemon startup and port calculation
|
||||
- Support `libasound2t64` on newer Ubuntu versions (24.04+)
|
||||
- Prevent CDP timeout on empty URL tabs
|
||||
- Output screenshot as base64 when no path provided
|
||||
- Resolve refs in `get value` command
|
||||
- Support URL parameter in `tab new` command
|
||||
- Allow `about:`, `data:`, and `file:` URL schemes
|
||||
- Detect stale unix socket by attempting connection
|
||||
- Respect `AGENT_BROWSER_HEADED` environment variable
|
||||
- Handle SIGPIPE to prevent panic when piping to `head`/`tail`
|
||||
- Fix null path validation in screenshot command
|
||||
|
||||
### Protocol Alignment
|
||||
|
||||
These changes align the CLI with the daemon protocol for consistency:
|
||||
|
||||
- `select` command now uses `values` field (supports multiple selections)
|
||||
- `frame main` uses `mainframe` action
|
||||
- `mouse wheel` uses `wheel` action
|
||||
- `set media` uses `emulatemedia` action
|
||||
- Console output uses `messages` field
|
||||
|
||||
### Documentation
|
||||
|
||||
- Expanded SKILL.md with comprehensive command reference
|
||||
- Updated README with new commands and options
|
||||
- Updated CDP mode documentation with `connect` workflow
|
||||
@@ -1,287 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("commands")
|
||||
|
||||
# Commands
|
||||
|
||||
## Core
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
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
|
||||
agent-browser type <sel> <text> [--delay <ms>] # Type into element
|
||||
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
|
||||
agent-browser keyboard type <text> [--delay <ms>] # Type at current focus (no selector needed)
|
||||
agent-browser keyboard inserttext <text> # Insert text without key events
|
||||
agent-browser keydown <key> # Hold key down
|
||||
agent-browser keyup <key> # Release key
|
||||
agent-browser hover <sel> # Hover element
|
||||
agent-browser focus <sel> # Focus element
|
||||
agent-browser select <sel> <val> # Select dropdown option
|
||||
agent-browser check <sel> # Check checkbox
|
||||
agent-browser uncheck <sel> # Uncheck checkbox
|
||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
|
||||
agent-browser scrollintoview <sel> # Scroll element into view
|
||||
agent-browser drag <src> <dst> # Drag and drop
|
||||
agent-browser upload <sel> <files> # Upload files
|
||||
agent-browser screenshot [path] # Screenshot (--full for full page)
|
||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||
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 --version # Show CLI version
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
```
|
||||
|
||||
Fork builds print dual-version metadata with `--version`:
|
||||
|
||||
```bash
|
||||
agent-browser 0.14.0-fork.1 (upstream 0.14.0, fork 1)
|
||||
```
|
||||
|
||||
## Get info
|
||||
|
||||
```bash
|
||||
agent-browser get text <sel> # Get text content
|
||||
agent-browser get html <sel> # Get innerHTML
|
||||
agent-browser get value <sel> # Get input value
|
||||
agent-browser get attr <sel> <attr> # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get count <sel> # Count matching elements
|
||||
agent-browser get box <sel> # Get bounding box
|
||||
agent-browser get styles <sel> # Get computed styles
|
||||
```
|
||||
|
||||
## Check state
|
||||
|
||||
```bash
|
||||
agent-browser is visible <sel> # Check if visible
|
||||
agent-browser is enabled <sel> # Check if enabled
|
||||
agent-browser is checked <sel> # Check if checked
|
||||
```
|
||||
|
||||
## Find elements
|
||||
|
||||
Semantic locators with actions (`click`, `fill`, `type`, `hover`, `focus`, `check`, `uncheck`, `text`):
|
||||
|
||||
```bash
|
||||
agent-browser find role <role> <action> [value]
|
||||
agent-browser find text <text> <action>
|
||||
agent-browser find label <label> <action> [value]
|
||||
agent-browser find placeholder <ph> <action> [value]
|
||||
agent-browser find alt <text> <action>
|
||||
agent-browser find title <text> <action>
|
||||
agent-browser find testid <id> <action> [value]
|
||||
agent-browser find first <sel> <action> [value]
|
||||
agent-browser find last <sel> <action> [value]
|
||||
agent-browser find nth <n> <sel> <action> [value]
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--name <name>` -- filter role by accessible name
|
||||
- `--exact` -- require exact text match
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find label "Email" fill "test@test.com"
|
||||
agent-browser find alt "Logo" click
|
||||
agent-browser find first ".item" click
|
||||
agent-browser find last ".item" text
|
||||
agent-browser find nth 2 ".card" hover
|
||||
```
|
||||
|
||||
## Wait
|
||||
|
||||
```bash
|
||||
agent-browser wait <selector> # Wait for element
|
||||
agent-browser wait <ms> # Wait for time
|
||||
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
|
||||
agent-browser wait --text "Welcome" # Wait for text
|
||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for load state
|
||||
agent-browser wait --fn "condition" # Wait for JS condition
|
||||
agent-browser wait --download [path] # Wait for download
|
||||
```
|
||||
|
||||
## Downloads
|
||||
|
||||
```bash
|
||||
agent-browser download <sel> <path> # Click element to trigger download
|
||||
agent-browser wait --download [path] # Wait for any download to complete
|
||||
```
|
||||
|
||||
## Mouse
|
||||
|
||||
```bash
|
||||
agent-browser mouse move <x> <y> # Move mouse
|
||||
agent-browser mouse down [button] # Press button
|
||||
agent-browser mouse up [button] # Release button
|
||||
agent-browser mouse wheel <dy> [dx] # Scroll wheel
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
```bash
|
||||
agent-browser set viewport <w> <h> # Set viewport size
|
||||
agent-browser set device <name> # Emulate device ("iPhone 14")
|
||||
agent-browser set geo <lat> <lng> # Set geolocation
|
||||
agent-browser set offline [on|off] # Toggle offline mode
|
||||
agent-browser set headers <json> # Extra HTTP headers
|
||||
agent-browser set credentials <u> <p> # HTTP basic auth
|
||||
agent-browser set media [dark|light] # Emulate color scheme (persists for session)
|
||||
```
|
||||
|
||||
Use `--color-scheme` for persistent dark/light mode across all commands:
|
||||
|
||||
```bash
|
||||
agent-browser --color-scheme dark open https://example.com
|
||||
```
|
||||
|
||||
## Cookies & storage
|
||||
|
||||
```bash
|
||||
agent-browser cookies # Get all cookies
|
||||
agent-browser cookies set <name> <val> # Set cookie
|
||||
agent-browser cookies clear # Clear cookies
|
||||
|
||||
agent-browser storage local # Get all localStorage
|
||||
agent-browser storage local <key> # Get specific key
|
||||
agent-browser storage local set <k> <v> # Set value
|
||||
agent-browser storage local clear # Clear all
|
||||
|
||||
agent-browser storage session # Same for sessionStorage
|
||||
```
|
||||
|
||||
## Network
|
||||
|
||||
```bash
|
||||
agent-browser network route <url> # Intercept requests
|
||||
agent-browser network route <url> --abort # Block requests
|
||||
agent-browser network route <url> --body <json> # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --clear # Clear request log
|
||||
agent-browser network requests --filter <pat> # Filter by URL pattern
|
||||
```
|
||||
|
||||
## Tabs & frames
|
||||
|
||||
```bash
|
||||
agent-browser tab # List tabs
|
||||
agent-browser tab new [url] # New tab
|
||||
agent-browser tab <n> # Switch to tab
|
||||
agent-browser tab close [n] # Close tab
|
||||
agent-browser window new # Open new browser window
|
||||
agent-browser frame <sel> # Switch to iframe
|
||||
agent-browser frame main # Back to main frame
|
||||
```
|
||||
|
||||
## Dialogs
|
||||
|
||||
```bash
|
||||
agent-browser dialog accept [text] # Accept dialog (with optional prompt text)
|
||||
agent-browser dialog dismiss # Dismiss dialog
|
||||
```
|
||||
|
||||
## Debug
|
||||
|
||||
```bash
|
||||
agent-browser trace start [path] # Start trace
|
||||
agent-browser trace stop [path] # Stop and save trace
|
||||
agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop [path] # Stop and save profile (.json)
|
||||
agent-browser record start <path> # Start video recording (WebM)
|
||||
agent-browser record stop # Stop and save video
|
||||
agent-browser record restart <path> # Stop current and start new recording
|
||||
agent-browser console # View console messages
|
||||
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
|
||||
```
|
||||
|
||||
## State management
|
||||
|
||||
```bash
|
||||
agent-browser state save <path> # Save auth state to file
|
||||
agent-browser state load <path> # Load auth state from file
|
||||
agent-browser state list # List saved state files
|
||||
agent-browser state show <file> # Show state summary
|
||||
agent-browser state rename <old> <new> # Rename state file
|
||||
agent-browser state clear [name] # Clear states for session name
|
||||
agent-browser state clear --all # Clear all saved states
|
||||
agent-browser state clean --older-than <days> # Delete old states
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
```bash
|
||||
agent-browser session # Show current session name
|
||||
agent-browser session list # List active sessions
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
```bash
|
||||
agent-browser back # Go back
|
||||
agent-browser forward # Go forward
|
||||
agent-browser reload # Reload page
|
||||
```
|
||||
|
||||
## Global options
|
||||
|
||||
```bash
|
||||
--session <name> # Isolated browser session
|
||||
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
|
||||
--state <path> # Load storage state from JSON file
|
||||
--headers <json> # HTTP headers scoped to URL's origin
|
||||
--executable-path <path> # Custom browser executable
|
||||
--extension <path> # Load browser extension (repeatable)
|
||||
--args <args> # Browser launch args (comma separated)
|
||||
--user-agent <ua> # Custom User-Agent string
|
||||
--proxy <url> # Proxy server URL
|
||||
--proxy-bypass <hosts> # Hosts to bypass proxy
|
||||
--ignore-https-errors # Ignore HTTPS certificate errors
|
||||
--allow-file-access # Allow file:// URLs to access local files (Chromium only)
|
||||
--stealth # Stealth mode (always on by default)
|
||||
-p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse)
|
||||
--device <name> # iOS device name (e.g., "iPhone 15 Pro")
|
||||
--json # JSON output (for scripts)
|
||||
--full, -f # Full page screenshot
|
||||
--annotate # Annotated screenshot with numbered element labels
|
||||
--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
|
||||
--debug # Debug output (includes stealth connection type + capabilities)
|
||||
```
|
||||
|
||||
## Command chaining
|
||||
|
||||
Chain commands with `&&` in a single shell invocation. The browser persists via a background daemon, so chaining works naturally and is more efficient than separate calls:
|
||||
|
||||
```bash
|
||||
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "pass" && agent-browser click @e3
|
||||
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
|
||||
```
|
||||
|
||||
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact with those refs).
|
||||
|
||||
## Local files
|
||||
|
||||
Open local files (PDFs, HTML) using `file://` URLs:
|
||||
|
||||
```bash
|
||||
agent-browser --allow-file-access open file:///path/to/document.pdf
|
||||
agent-browser --allow-file-access open file:///path/to/page.html
|
||||
agent-browser screenshot output.png
|
||||
```
|
||||
|
||||
The `--allow-file-access` flag enables JavaScript to access other local files. Chromium only.
|
||||
@@ -1,172 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
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.
|
||||
|
||||
## Config File Locations
|
||||
|
||||
agent-browser checks two locations, merged in priority order:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Project-level values override user-level values. Environment variables override both. CLI flags always win.
|
||||
|
||||
Use `--config <path>` or the `AGENT_BROWSER_CONFIG` environment variable to load a specific config file instead of the default locations:
|
||||
|
||||
```bash
|
||||
agent-browser --config ./ci-config.json open example.com
|
||||
AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
|
||||
```
|
||||
|
||||
## Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"proxy": "http://localhost:8080",
|
||||
"userAgent": "my-agent/1.0",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
## All Options
|
||||
|
||||
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>
|
||||
</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>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Common Configurations
|
||||
|
||||
### Local Development
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"sessionName": "local-dev"
|
||||
}
|
||||
```
|
||||
|
||||
### Behind a Proxy
|
||||
|
||||
```json
|
||||
{
|
||||
"proxy": "http://proxy.corp.example.com:8080",
|
||||
"proxyBypass": "localhost,*.internal.com",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
### CI / Devcontainer
|
||||
|
||||
```json
|
||||
{
|
||||
"args": "--no-sandbox,--disable-gpu",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
### iOS Testing
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "ios",
|
||||
"device": "iPhone 16 Pro"
|
||||
}
|
||||
```
|
||||
|
||||
## Overriding Boolean Options
|
||||
|
||||
Boolean flags accept an optional `true`/`false` value to override config settings:
|
||||
|
||||
```bash
|
||||
agent-browser --headed false open example.com
|
||||
```
|
||||
|
||||
A bare flag is equivalent to passing `true`:
|
||||
|
||||
```bash
|
||||
agent-browser --headed open example.com # same as --headed true
|
||||
agent-browser --headed true open example.com # explicit
|
||||
```
|
||||
|
||||
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`.
|
||||
|
||||
## Extensions Merging
|
||||
|
||||
Extensions from user-level and project-level configs are **concatenated**, not replaced. For example, if `~/.agent-browser/config.json` specifies `["/ext1"]` and `./agent-browser.json` specifies `["/ext2"]`, the result is `["/ext1", "/ext2"]`.
|
||||
|
||||
The `AGENT_BROWSER_EXTENSIONS` environment variable and CLI `--extension` flags follow the standard priority rules (env replaces config, CLI appends).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
These environment variables configure additional daemon and runtime behavior:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<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_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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Auto-discovered config files** (`~/.agent-browser/config.json`, `./agent-browser.json`) that are missing are silently ignored.
|
||||
- **`--config <path>`** with a missing or malformed file exits with an error.
|
||||
- **Malformed JSON** in auto-discovered files prints a warning to stderr and continues without that file.
|
||||
- **Unknown keys** are silently ignored for forward compatibility.
|
||||
|
||||
> **Tip:** If your project-level `agent-browser.json` contains environment-specific values (paths, proxies), consider adding it to `.gitignore`.
|
||||
@@ -1,179 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("diffing")
|
||||
|
||||
import { DiffDemo } from "@/components/diff-demo"
|
||||
|
||||
# Diffing
|
||||
|
||||
Compare page states to detect changes -- structurally via accessibility tree snapshots, visually via pixel comparison, or across two different URLs.
|
||||
|
||||
<DiffDemo />
|
||||
|
||||
## Commands
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Command</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>diff snapshot</code></td><td>Compare current snapshot to last snapshot in session</td></tr>
|
||||
<tr><td><code>diff snapshot --baseline <file></code></td><td>Compare current snapshot to a saved file</td></tr>
|
||||
<tr><td><code>diff screenshot --baseline <file></code></td><td>Visual pixel diff against a baseline image</td></tr>
|
||||
<tr><td><code>diff url <url1> <url2></code></td><td>Compare two pages (snapshot + optional screenshot)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Snapshot diff
|
||||
|
||||
Compares the accessibility tree between two points in time using a line-level text diff.
|
||||
|
||||
```bash
|
||||
# Compare against the last snapshot taken in this session
|
||||
agent-browser diff snapshot
|
||||
|
||||
# Compare against a saved baseline file
|
||||
agent-browser diff snapshot --baseline before.txt
|
||||
|
||||
# Scope to a specific part of the page
|
||||
agent-browser diff snapshot --selector "#main" --compact
|
||||
```
|
||||
|
||||
Without `--baseline`, the command automatically compares against the most recent snapshot taken in the current session. This is the primary use case for agents verifying that an action had the intended effect.
|
||||
|
||||
### Options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Flag</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>-b, --baseline <file></code></td><td>Path to a saved snapshot file to compare against</td></tr>
|
||||
<tr><td><code>-s, --selector <sel></code></td><td>Scope the current snapshot to a CSS selector or @ref</td></tr>
|
||||
<tr><td><code>-c, --compact</code></td><td>Use compact snapshot format</td></tr>
|
||||
<tr><td><code>-d, --depth <n></code></td><td>Limit snapshot tree depth</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Output
|
||||
|
||||
The diff uses `+` for added lines and `-` for removed lines, similar to unified diff format. A summary line shows the count of additions, removals, and unchanged lines.
|
||||
|
||||
```
|
||||
- button "Submit" [ref=e2]
|
||||
+ button "Submit" [ref=e2] [disabled]
|
||||
3 additions, 2 removals, 41 unchanged
|
||||
```
|
||||
|
||||
## Screenshot diff
|
||||
|
||||
Compares the current page screenshot against a baseline image at the pixel level. Produces a diff image with changed pixels highlighted in red.
|
||||
|
||||
```bash
|
||||
# Basic visual diff
|
||||
agent-browser diff screenshot --baseline before.png
|
||||
|
||||
# Save diff image to a specific path
|
||||
agent-browser diff screenshot --baseline before.png --output diff.png
|
||||
|
||||
# Adjust threshold and scope to element
|
||||
agent-browser diff screenshot --baseline before.png --threshold 0.2 --selector "#hero"
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Flag</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>-b, --baseline <file></code></td><td>Baseline PNG/JPEG image to compare against (required)</td></tr>
|
||||
<tr><td><code>-o, --output <file></code></td><td>Path for the generated diff image (default: temp dir)</td></tr>
|
||||
<tr><td><code>-t, --threshold <0-1></code></td><td>Color distance threshold (default: 0.1). Higher = more tolerant</td></tr>
|
||||
<tr><td><code>-s, --selector <sel></code></td><td>Scope the current screenshot to an element</td></tr>
|
||||
<tr><td><code>--full</code></td><td>Take a full-page screenshot</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Output
|
||||
|
||||
Reports the diff image path, number of different pixels, and mismatch percentage. The diff image shows unchanged pixels dimmed with changed pixels in red.
|
||||
|
||||
If the baseline and current images have different dimensions, the command reports a dimension mismatch instead of attempting pixel comparison.
|
||||
|
||||
## URL diff
|
||||
|
||||
Compares two pages by navigating to each in sequence and diffing the results.
|
||||
|
||||
```bash
|
||||
# Compare two URLs (snapshot diff)
|
||||
agent-browser diff url https://staging.example.com https://prod.example.com
|
||||
|
||||
# Include visual comparison
|
||||
agent-browser diff url https://v1.example.com https://v2.example.com --screenshot
|
||||
|
||||
# Full-page screenshot comparison
|
||||
agent-browser diff url https://v1.example.com https://v2.example.com --screenshot --full
|
||||
```
|
||||
|
||||
The command navigates to the first URL, captures state, then navigates to the second URL and captures again. Snapshot diff is always included. Screenshot diff requires the `--screenshot` flag.
|
||||
|
||||
After completion, the browser remains on the second URL.
|
||||
|
||||
### Options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Flag</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>--screenshot</code></td><td>Also perform visual screenshot comparison</td></tr>
|
||||
<tr><td><code>--full</code></td><td>Use full-page screenshots</td></tr>
|
||||
<tr><td><code>--wait-until <strategy></code></td><td>Navigation wait strategy: <code>load</code>, <code>domcontentloaded</code>, <code>networkidle</code> (default: <code>load</code>)</td></tr>
|
||||
<tr><td><code>-s, --selector <sel></code></td><td>Scope snapshots to a CSS selector or @ref</td></tr>
|
||||
<tr><td><code>-c, --compact</code></td><td>Use compact snapshot format</td></tr>
|
||||
<tr><td><code>-d, --depth <n></code></td><td>Limit snapshot tree depth</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Use cases
|
||||
|
||||
### Verifying agent actions
|
||||
|
||||
The most common use case: confirm that an action (click, fill, submit) changed the page as expected.
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i # Take interactive-only snapshot (baseline)
|
||||
agent-browser fill @e3 "test@example.com"
|
||||
agent-browser diff snapshot # Compare current snapshot to the baseline
|
||||
```
|
||||
|
||||
### Monitoring for changes
|
||||
|
||||
Periodically compare a page against a saved baseline to detect updates.
|
||||
|
||||
```bash
|
||||
# Save baseline
|
||||
agent-browser open https://example.com && agent-browser snapshot > baseline.txt
|
||||
|
||||
# Later, check for changes
|
||||
agent-browser open https://example.com && agent-browser diff snapshot --baseline baseline.txt
|
||||
```
|
||||
|
||||
### Visual regression testing
|
||||
|
||||
Compare screenshots before and after a deploy to catch unintended visual changes.
|
||||
|
||||
```bash
|
||||
agent-browser open https://staging.example.com && agent-browser screenshot baseline.png
|
||||
# ... deploy happens ...
|
||||
agent-browser open https://staging.example.com && agent-browser diff screenshot --baseline baseline.png
|
||||
```
|
||||
|
||||
### Comparing environments
|
||||
|
||||
Diff staging against production to verify parity.
|
||||
|
||||
```bash
|
||||
agent-browser diff url https://staging.example.com https://prod.example.com --screenshot
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,290 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../../node_modules/streamdown/dist/index.js";
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
pre {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
background: var(--card);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Prose */
|
||||
.prose {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.prose h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
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);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose li strong {
|
||||
color: var(--foreground);
|
||||
opacity: 0.85;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.prose th {
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.prose td {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.prose td code {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Tool call shimmer animation */
|
||||
@keyframes tool-shimmer {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-tool-shimmer {
|
||||
animation: tool-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Override prose text color in chat so agent responses use primary foreground */
|
||||
.docs-chat-content p,
|
||||
.docs-chat-content li,
|
||||
.docs-chat-content td,
|
||||
.docs-chat-content th,
|
||||
.docs-chat-content strong,
|
||||
.docs-chat-content code {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Reset global pre styles inside chat so Streamdown's own styling takes effect */
|
||||
.docs-chat-content pre {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: revert-layer;
|
||||
}
|
||||
|
||||
/* Fix list rendering in chat content */
|
||||
.docs-chat-content ul,
|
||||
.docs-chat-content ol {
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
|
||||
.docs-chat-content li > p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.docs-chat-content li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("installation")
|
||||
|
||||
# Installation
|
||||
|
||||
## Global installation (recommended)
|
||||
|
||||
Installs the native Rust binary for maximum performance:
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser-stealth
|
||||
agent-browser install # Download Chromium
|
||||
```
|
||||
|
||||
This is the fastest option -- commands run through the native Rust CLI directly with sub-millisecond parsing overhead.
|
||||
|
||||
## Quick start (no install)
|
||||
|
||||
Run directly with `npx` if you want to try it without installing globally:
|
||||
|
||||
```bash
|
||||
npx agent-browser-stealth install # Download Chromium (first time only)
|
||||
npx agent-browser-stealth open example.com
|
||||
```
|
||||
|
||||
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
|
||||
|
||||
## Project installation (local dependency)
|
||||
|
||||
For projects that want to pin the version in `package.json`:
|
||||
|
||||
```bash
|
||||
npm install agent-browser-stealth
|
||||
npx agent-browser-stealth install
|
||||
```
|
||||
|
||||
Then use via `npx` or `package.json` scripts:
|
||||
|
||||
```bash
|
||||
npx agent-browser-stealth open example.com
|
||||
```
|
||||
|
||||
## Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew install agent-browser
|
||||
agent-browser install # Download Chromium
|
||||
```
|
||||
|
||||
## From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/leeguooooo/agent-browser
|
||||
cd agent-browser
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build:native
|
||||
./bin/agent-browser install
|
||||
pnpm link --global
|
||||
```
|
||||
|
||||
## Fork versioning
|
||||
|
||||
Fork releases use a dual-version format:
|
||||
|
||||
- `<upstream>-fork.<fork>`
|
||||
- Example: `0.14.0-fork.1`
|
||||
|
||||
`agent-browser --version` prints the full version and also shows upstream and fork parts for fork builds.
|
||||
|
||||
## Linux dependencies
|
||||
|
||||
On Linux, install system dependencies:
|
||||
|
||||
```bash
|
||||
agent-browser install --with-deps
|
||||
# or manually: npx playwright install-deps chromium
|
||||
```
|
||||
|
||||
## Custom browser
|
||||
|
||||
Use a custom browser executable instead of bundled Chromium:
|
||||
|
||||
- **Serverless** - Use `@sparticuz/chromium` (~50MB vs ~684MB)
|
||||
- **System browser** - Use existing Chrome installation
|
||||
- **Custom builds** - Use modified browser builds
|
||||
|
||||
```bash
|
||||
# Via flag
|
||||
agent-browser --executable-path /path/to/chromium open example.com
|
||||
|
||||
# Via environment variable
|
||||
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
|
||||
```
|
||||
|
||||
### Serverless example
|
||||
|
||||
```typescript
|
||||
import chromium from '@sparticuz/chromium';
|
||||
import { BrowserManager } from 'agent-browser-stealth';
|
||||
|
||||
export async function handler() {
|
||||
const browser = new BrowserManager();
|
||||
await browser.launch({
|
||||
executablePath: await chromium.executablePath(),
|
||||
headless: true,
|
||||
});
|
||||
// ... use browser
|
||||
}
|
||||
```
|
||||
|
||||
## AI agent setup
|
||||
|
||||
agent-browser works with any AI agent out of the box. For richer context:
|
||||
|
||||
### AI coding assistants (recommended)
|
||||
|
||||
Install the skill for your AI coding assistant:
|
||||
|
||||
```bash
|
||||
npx skills add leeguooooo/agent-browser
|
||||
```
|
||||
|
||||
This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository and stays up to date automatically.
|
||||
|
||||
> **Do not** copy `SKILL.md` from `node_modules` -- it will become stale as new features are added. Always use `npx skills add` or reference the repository version.
|
||||
|
||||
### AGENTS.md / CLAUDE.md
|
||||
|
||||
Add to your instructions file:
|
||||
|
||||
```markdown
|
||||
## Browser Automation
|
||||
|
||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
||||
|
||||
Core workflow:
|
||||
1. `agent-browser open <url>` - Navigate to page
|
||||
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
|
||||
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
|
||||
4. Re-snapshot after page changes
|
||||
```
|
||||
@@ -1,211 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("ios")
|
||||
|
||||
# iOS Simulator
|
||||
|
||||
Control real Mobile Safari in the iOS Simulator for authentic mobile
|
||||
web testing. Uses Appium with XCUITest for native automation.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS with Xcode installed
|
||||
- iOS Simulator runtimes (download via Xcode)
|
||||
- Appium with XCUITest driver
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install Appium globally
|
||||
npm install -g appium
|
||||
|
||||
# Install the XCUITest driver for iOS
|
||||
appium driver install xcuitest
|
||||
```
|
||||
|
||||
## List available devices
|
||||
|
||||
See all iOS simulators available on your system:
|
||||
|
||||
```bash
|
||||
agent-browser device list
|
||||
|
||||
# Output:
|
||||
# Available iOS Simulators:
|
||||
#
|
||||
# ○ iPhone 16 Pro (iOS 18.0)
|
||||
# F21EEC0D-7618-419F-811B-33AF27A8B2FD
|
||||
# ○ iPhone 16 Pro Max (iOS 18.0)
|
||||
# 50402807-C9B8-4D37-9F13-2E00E782C744
|
||||
# ○ iPad Pro 13-inch (M4) (iOS 18.0)
|
||||
# 3A6C6436-B909-4593-866D-91D1062BB070
|
||||
# ...
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
Use the `-p ios` flag to enable iOS mode. The workflow is
|
||||
identical to desktop:
|
||||
|
||||
```bash
|
||||
# Launch Safari on iPhone 16 Pro
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
|
||||
# Get snapshot with refs (same as desktop)
|
||||
agent-browser -p ios snapshot -i
|
||||
|
||||
# Interact using refs
|
||||
agent-browser -p ios tap @e1
|
||||
agent-browser -p ios fill @e2 "text"
|
||||
|
||||
# Take screenshot
|
||||
agent-browser -p ios screenshot mobile.png
|
||||
|
||||
# Close session (shuts down simulator)
|
||||
agent-browser -p ios close
|
||||
```
|
||||
|
||||
## Mobile-specific commands
|
||||
|
||||
```bash
|
||||
# Swipe gestures
|
||||
agent-browser -p ios swipe up
|
||||
agent-browser -p ios swipe down
|
||||
agent-browser -p ios swipe left
|
||||
agent-browser -p ios swipe right
|
||||
|
||||
# Swipe with distance (pixels)
|
||||
agent-browser -p ios swipe up 500
|
||||
|
||||
# Tap (alias for click, semantically clearer for touch)
|
||||
agent-browser -p ios tap @e1
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Configure iOS mode via environment variables:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_PROVIDER=ios
|
||||
export AGENT_BROWSER_IOS_DEVICE="iPhone 16 Pro"
|
||||
|
||||
# Now all commands use iOS
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser tap @e1
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>AGENT_BROWSER_PROVIDER</code></td><td>Set to <code>ios</code> to enable iOS mode</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Device name (e.g., "iPhone 16 Pro")</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Device UDID (alternative to device name)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Supported devices
|
||||
|
||||
All iOS Simulators available in Xcode are supported, including:
|
||||
|
||||
- All iPhone models (iPhone 15, 16, 17, SE, etc.)
|
||||
- All iPad models (iPad Pro, iPad Air, iPad mini, etc.)
|
||||
- Multiple iOS versions (17.x, 18.x, etc.)
|
||||
|
||||
**Real devices** are also supported via USB connection (see below).
|
||||
|
||||
## Real device support
|
||||
|
||||
Appium can control Safari on real iOS devices connected via USB. This
|
||||
requires additional one-time setup.
|
||||
|
||||
### 1. Get your device UDID
|
||||
|
||||
```bash
|
||||
# List connected devices
|
||||
xcrun xctrace list devices
|
||||
|
||||
# Or via system profiler
|
||||
system_profiler SPUSBDataType | grep -A 5 "iPhone\|iPad"
|
||||
```
|
||||
|
||||
### 2. Sign WebDriverAgent (one-time)
|
||||
|
||||
WebDriverAgent needs to be signed with your Apple Developer
|
||||
certificate to run on real devices.
|
||||
|
||||
```bash
|
||||
# Open the WebDriverAgent Xcode project
|
||||
cd ~/.appium/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent
|
||||
open WebDriverAgent.xcodeproj
|
||||
```
|
||||
|
||||
In Xcode:
|
||||
|
||||
1. Select the `WebDriverAgentRunner` target
|
||||
2. Go to Signing & Capabilities
|
||||
3. Select your Team (requires Apple Developer account, free tier works)
|
||||
4. Let Xcode manage signing automatically
|
||||
|
||||
### 3. Use with agent-browser
|
||||
|
||||
```bash
|
||||
# Connect device via USB, then use the UDID
|
||||
agent-browser -p ios --device "<DEVICE_UDID>" open https://example.com
|
||||
|
||||
# Or use the device name if unique
|
||||
agent-browser -p ios --device "John's iPhone" open https://example.com
|
||||
```
|
||||
|
||||
### Real device notes
|
||||
|
||||
- First run installs WebDriverAgent to the device (may require Trust prompt on device)
|
||||
- Device must be unlocked and connected via USB
|
||||
- Slightly slower initial connection than simulator
|
||||
- Tests against real Safari performance and behavior
|
||||
- On first install, go to Settings → General → VPN & Device Management to trust the developer certificate
|
||||
|
||||
## Performance notes
|
||||
|
||||
- **First launch:** Takes 30-60 seconds to boot the simulator and start Appium
|
||||
- **Subsequent commands:** Fast (simulator stays running)
|
||||
- **Close command:** Shuts down simulator and Appium server
|
||||
|
||||
## Differences from desktop
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>Desktop</th><th>iOS</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Browser</td><td>Chromium/Firefox/WebKit</td><td>Safari only</td></tr>
|
||||
<tr><td>Tabs</td><td>Supported</td><td>Single tab only</td></tr>
|
||||
<tr><td>PDF export</td><td>Supported</td><td>Not supported</td></tr>
|
||||
<tr><td>Screencast</td><td>Supported</td><td>Not supported</td></tr>
|
||||
<tr><td>Swipe gestures</td><td>Not native</td><td>Native support</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Appium not found
|
||||
|
||||
```bash
|
||||
# Make sure Appium is installed globally
|
||||
npm install -g appium
|
||||
appium driver install xcuitest
|
||||
|
||||
# Verify installation
|
||||
appium --version
|
||||
```
|
||||
|
||||
### No simulators available
|
||||
|
||||
Open Xcode and download iOS Simulator runtimes from **Settings → Platforms**.
|
||||
|
||||
### Simulator won't boot
|
||||
|
||||
Try booting the simulator manually from Xcode or the Simulator app to
|
||||
ensure it works, then retry with agent-browser.
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, 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 { 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",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://agent-browser.dev"),
|
||||
title: {
|
||||
default: "agent-browser | Headless Browser Automation for AI",
|
||||
template: "%s | agent-browser",
|
||||
},
|
||||
description: "Headless browser automation CLI for AI agents",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://agent-browser.dev",
|
||||
siteName: "agent-browser",
|
||||
title: "agent-browser | Headless Browser Automation for AI",
|
||||
description: "Headless browser automation CLI for AI agents",
|
||||
images: [{ url: "/og", width: 1200, height: 630, alt: "agent-browser" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "agent-browser | Headless Browser Automation for AI",
|
||||
description: "Headless browser automation CLI for AI agents",
|
||||
images: ["/og"],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
|
||||
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{chatOpen && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${geist.variable} ${geistMono.variable} ${GeistPixelSquare.variable} antialiased bg-background text-foreground`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
</MobileNavProvider>
|
||||
</ThemeProvider>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPageTitle, renderOgImage } from "../og-image";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ slug: string[] }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const title = getPageTitle(slug.join("/"));
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return renderOgImage(title);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export { getPageTitle } from "@/lib/page-titles";
|
||||
|
||||
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null =
|
||||
null;
|
||||
|
||||
async function loadFonts() {
|
||||
if (fontCache) return fontCache;
|
||||
const [geistRegular, geistPixelSquare] = await Promise.all([
|
||||
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
|
||||
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
|
||||
]);
|
||||
fontCache = { geistRegular, geistPixelSquare };
|
||||
return fontCache;
|
||||
}
|
||||
|
||||
export async function renderOgImage(title: string) {
|
||||
const { geistRegular, geistPixelSquare } = await loadFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "black",
|
||||
padding: "60px 80px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<svg width="36" height="36" viewBox="0 0 16 16" fill="white">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M8 1L16 15H0L8 1Z" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 36,
|
||||
color: "#666",
|
||||
fontFamily: "Geist",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontFamily: "GeistPixelSquare",
|
||||
fontWeight: 400,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
agent-browser
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{title.split("\n").map((line, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontFamily: "Geist",
|
||||
fontWeight: 400,
|
||||
color: "white",
|
||||
letterSpacing: "-0.02em",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: "Geist",
|
||||
data: geistRegular.buffer as ArrayBuffer,
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: "GeistPixelSquare",
|
||||
data: geistPixelSquare.buffer as ArrayBuffer,
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { getPageTitle, renderOgImage } from "./og-image";
|
||||
|
||||
export async function GET() {
|
||||
const title = getPageTitle("")!;
|
||||
return renderOgImage(title);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("")
|
||||
|
||||
# agent-browser
|
||||
|
||||
Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback.
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser-stealth # all platforms (fastest, native Rust CLI)
|
||||
brew install agent-browser # macOS
|
||||
|
||||
# or try without installing
|
||||
npx agent-browser-stealth open example.com
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Agent-first** - Compact text output uses fewer tokens than JSON, designed for AI context efficiency
|
||||
- **Ref-based** - Snapshot returns accessibility tree with refs for deterministic element selection
|
||||
- **Fast** - Native Rust CLI for instant command parsing
|
||||
- **Complete** - 50+ commands for navigation, forms, screenshots, network, storage
|
||||
- **Sessions** - Multiple isolated browser instances with separate auth
|
||||
- **Cross-platform** - macOS, Linux, Windows with native binaries
|
||||
- **Auto region detection** - Locale, timezone, and Accept-Language automatically match the target site's TLD
|
||||
- **Captcha auto-retry** - Detects captcha/verification pages and retries with randomized backoff
|
||||
|
||||
## Works with
|
||||
|
||||
Claude Code, Cursor, GitHub Copilot, OpenAI Codex, Google Gemini, opencode, and any agent that can run shell commands.
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Navigate and get snapshot
|
||||
agent-browser open example.com
|
||||
agent-browser snapshot -i
|
||||
|
||||
# Output:
|
||||
# - heading "Example Domain" [ref=e1]
|
||||
# - link "More information..." [ref=e2]
|
||||
|
||||
# Interact using refs
|
||||
agent-browser click @e2
|
||||
agent-browser screenshot page.png
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
## Why refs?
|
||||
|
||||
The `snapshot` command returns a compact accessibility tree where each element
|
||||
has a unique ref like `@e1`, `@e2`. This provides:
|
||||
|
||||
- **Context-efficient** - Text output uses ~200-400 tokens vs ~3000-5000 for full DOM
|
||||
- **Deterministic** - Ref points to exact element from snapshot
|
||||
- **Fast** - No DOM re-query needed
|
||||
- **AI-friendly** - LLMs parse text output naturally
|
||||
|
||||
## Architecture
|
||||
|
||||
Client-daemon architecture for optimal performance:
|
||||
|
||||
1. **Rust CLI** - Parses commands, communicates with daemon
|
||||
2. **Node.js Daemon** - Manages Playwright browser instance
|
||||
|
||||
Daemon starts automatically and persists between commands.
|
||||
|
||||
## Platforms
|
||||
|
||||
Native Rust binaries for macOS (ARM64, x64), Linux (ARM64, x64), and Windows (x64).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user